Node.js 13.2.0 で--experimental-modulesが外れたのでESMを試す


はじめに

Node.js 13.2.0 で--experimental-modulesが外れた。

晴れて Node.js の世界でも ECMAScript 標準のモジュール管理を標準で使える!

と思って使ってみた。

というわけで本稿は所謂"やってみた系"の話である。

デフォルトESMにして嬉しいケースは「ソース=配布物なライブラリ開発」の時に限られるが※1、tscでコンパイルする環境でも"module":"esnext"でどこまで頑張れるかやってみた。

※1: TypeScriptやbabelのようなトランスパイラやwebpackのようなバンドラによって出力結果は環境に応じて変更するのが一般的なので

※本稿では、{...}のような記法を使っている場合、中身が省略されていることを示す(スプレッド演算子は使っていない)

※今回のやってみたの趣旨は、「"target":"esnext"でtscコンパイルしたファイルをオプション指定無しでNode.jsで実行し、なんとか動かすところまでを目指すこと」である。その目的とバッティングするESLintのエラーやTypeScriptのエラーは都度ignoreしていく。

主張

結構しんどいしTypeScript開発をしたい昨今の事情を鑑みると、旨味が少ない

おさらい

JavaScriptには長らくモジュール機構が備わっていなかったため様々な仕様が考えられた。

「え、じゃあどうやって別ファイルに機能を切り出していたの?」という質問に対しては、皆さんご存知の通りHTML内で各JSファイルを読み込み、サーバにファイルを要求してダウンロードしていた、というのが回答になるであろう。

そして各ファイルではグローバル変数としてモジュールを提供し、それぞれが別々のグローバル変数に依存する仕組みをとっていた。モジュール機能なんてなかったのだ。

一般的なmodule機能について

ここで呼ぶモジュール機能とは、他言語でよく見られるrequireincludeimportのようなものである。個別ファイルとして切り出されたモジュールを読み込む方法のことだ。

例えば C++ では下記のように helloworld.cpp の処理を main.cpp から呼び出すことが出来る。

// helloworld.cpp
export module helloworld;  // モジュール宣言。
import <iostream>;         // インポート宣言。

export void hello() {      // エクスポート宣言。
    std::cout << "Hello world!\n";
}
// main.cpp
import helloworld;  // インポート宣言。

int main() {
    hello();
}

出典: https://ja.cppreference.com/w/cpp/language/modules

JavaScriptにおけるモジュール機能

JavaScriptにおいて考案された様々なモジュール機能の具体例としては、RequireJSやAMD、CommonJSやECMAScriptモジュール等が挙げられる。今回それぞれの詳しい説明は省くが、気になる方は調べてみると良いだろう。ちなみに筆者はRequireJSやAMDを知ることなく、ECMAScriptモジュールでフロントエンドを書くことが当たり前の世界になってからフロントエンドの門を叩いた幸せ者である。

本稿ではCommonJSとECMAScriptモジュールについて触れることが多いので、知らない方のために軽く紹介する。

CommonJS

CommonJSとは言語仕様でありモジュール解決するために主にNode.jsに実装されている。

syntaxは下記のような形になっている。

// helloworld.js
exports.helloworld = () => {
  process.stdout.write("hello world\n");
};
// index.js
const { helloworld } = require("./helloworld");
helloworld(); // => hello world

ESLintの設定ファイルやwebpackの設定ファイル等で下記のような記法を目にした方も多いのではないだろうか。

module.exports = {...}

module.exportsは(関数も含む)一つのオブジェクトをこのファイルから提供したい場合に使う。大抵はmodule.exportsを使うことになるだろう。

ECMAScriptモジュール

ECMAScriptについては各所で解説がされているのでここでの説明は省く。

JavaScriptの言語仕様であるECMAScriptが定めたモジュール機能の仕様をEcmaScriptモジュールと呼ぶ。CommonJSから遅れること○○年、ようやくEcmaScriptでもモジュール機能に関する仕様が定められたのだ。

syntaxは下記のようになる

// helloworld.js
export const helloworld = () => process.stdout.write("hello world\n");
// index.js
import { helloworld } from "./helloworld.js"
helloworld(); // hello world

CommonJSと同様に単一オブジェクトのみをexportしたいケースではexport default {...}という構文を用いる。

以降、CommonJSをCJS、ECMAScriptモジュールをESMと記載する

ホスト側でのESM対応

冒頭でこのように書いた。

Node.js 13.2.0 で--experimental-modulesが外れた。

これが意味するところについて説明する。

WHATWGとNode.js

ECMAScriptでのモジュール機能が定まった。次はホスト側が、どう動くのか、というところを定めていくこととなった。

ESMの仕様に関して、TC39がsyntaxを決めたが、どのように動くのか、というところはホストに任されている事情があるため、ブラウザはWHATWG、サーバはNode.jsと別々に定めていく必要があった。

ブラウザ側の動きについては、フラグ付きではあるが全モダンブラウザでの初期実装も揃った。※2

しかしNode.jsは既にCommonJSというモジュール機能が存在しているため、後方互換性をどう担保していくかを考えていかなければならなかった。そのためブラウザでの実装に遅れをとっているという状況である。(とはいえフロントエンドでESMネイティブな開発が行える環境は多くはないだろう)

※2: https://teppeis.hatenablog.com/entry/2017/08/es-modules-in-nodejs

Node.jsのESM対応

Node.jsのESM対応については完了までのフェーズを5つに分けられていて、現在はPhase3が終了して最終フェーズのPhase4に差し掛かったところかと思われる

At the end of this phase, the --experimental-modules flag is dropped.

無事にNode.js 13.2.0 で--experimental-modulesが外れた。

以降ではNode.jsでESMを使っていく上での現状での課題を示す。

ESMの課題1: CJS→ESM呼び出しができない

CJS→ESM, ESM→CJSの検証

下記のようなディレクトリ構造でプロジェクトを作り、それぞれでCJS、MJSのモジュール機能を使って欲しい。

├── node-type-common
│   ├── export.js
│   ├── import.js
│   └── package.json
└── node-type-esm
    ├── export.js
    ├── import.js
    └── package.json

CJS→EJSプロジェクト

  • node-type-common/export.js
const Bar = {
  name: "bar"
};

console.log(`cjs: ${JSON.stringify(Bar, null, 2)}`);

module.exports = Bar;
  • node-type-common/import.js
const Foo = require("../node-type-esm/export");

console.log(`cjs: ${JSON.stringify(Foo)}`);
  • node-type-common/package.json
{
  "name": "node-type-common",
  "version": "1.0.0",
  "license": "MIT",
  "type": "commonjs"
}

ESM→CJSプロジェクト

  • node-type-esm/export.js
const Foo = {
  name: "foo"
};

console.log(`esm: ${JSON.stringify(Foo, null, 2)}`);

export default Foo;
  • node-type-esm/import.js
import Bar from "../node-type-common/export.js";

console.log(`esm: ${JSON.stringify(Bar, null, 2)}`);
  • node-type-esm/package.json
{
  "name": "node-type-esm",
  "version": "1.0.0",
  "license": "MIT",
  "type": "module"
}

ESM→CJS

cd node-type-esm
node import.js
(node:72280) ExperimentalWarning: The ESM module loader is experimental.
cjs: {
  "name": "bar"
}
esm: {
  "name": "bar"
}

CJS→ESM

cd node-type-common
node import.js
(node:72455) Warning: require() of ES modules is not supported.
require() of /*****/node-type-esm/export.js from /*****/node-type-common/import.js is an ES module file as it is a .js file whose nearest parent package.json contains "type": "module" which defines all .js files in that package scope as ES modules.
Instead rename export.js to end in .cjs, change the requiring code to use import(), or remove "type": "module" from /*****/node-type-esm/package.json.
internal/modules/cjs/loader.js:1156
      throw new ERR_REQUIRE_ESM(filename);
      ^

Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /*****/node-type/node-type-esm/export.js
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1156:13)
    at Module.load (internal/modules/cjs/loader.js:976:32)
    at Function.Module._load (internal/modules/cjs/loader.js:884:14)
    at Module.require (internal/modules/cjs/loader.js:1016:19)
    at require (internal/modules/cjs/helpers.js:69:18)
    at Object.<anonymous> (/*****/node-type-common/import.js:1:13)
    at Module._compile (internal/modules/cjs/loader.js:1121:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1160:10)
    at Module.load (internal/modules/cjs/loader.js:976:32)
    at Function.Module._load (internal/modules/cjs/loader.js:884:14) {
  code: 'ERR_REQUIRE_ESM'
}

やってみたように、MJSからCJSの呼び出しはbabel×WebpackやTypeScriptで使っていた方法そのままとはいかないが可能である一方、CJSからのMJS呼び出しはできなくなっている。

MJSからCJSの呼び出しが可能とはいえ__dirnameやいくつかの予約語が使えなくなったり、named exportが使えなかったりする。

注意点は@teppeisさんのブログエントリを見ていただきたい。

CJS→ESMができない

さて本題のCJS→MJSができない件である。

具体的にどういったケースで困るのか考えていく。

以下では参考に作ったこのリポジトリで議論を進める。

拡張子まで指定しないといけない

CJSのモジュール機能では、index.jsが呼ばれる形でのディレクトリ指定や拡張子の省略が可能である。

// ./helloworld/index.js
module.exports = () => process.stdout.write('hello world\n');
const helloworld = require("./helloworld"); // ./helloworld/index.js 
helloworld(); // hello world

という具合である。

WebpackやTypeScriptでのESMは、このCJSの仕様を踏襲しているため拡張子を省いた記述が可能である。

.eslintrc.jsはCJSで呼び出される

検証リポジトリを見れば分かるが、ESLintの設定ファイルの拡張子が.cjsとなっている。

.cjsと.mjs

唐突に.cjs拡張子のファイルが出てきた。これが何を表すのかというと、.cjs拡張子であればCJSのモジュール機能、.mjs拡張子であればESMのモジュール機能を使う、というNode.jsの仕様である。

Node.jsのESM対応ではこのファイルがCJSなのかMJSなのかという判定をせねばならず、一つの解決策が拡張子.mjs, .cjsであった。

.eslintrc.jsがCJSで呼び出される理由
ESLintパッケージがCJSである理由

Node.jsのドキュメントには下記のように記載がある

Node.js will treat as CommonJS all other forms of input, such as .js files where the nearest parent package.json file contains no top-level "type" field

これは後方互換性を保つためのものとすぐ後に説明があるが、ESLintのパッケージのpackage.jsonには"type"フィールドがない。

そのため、プロジェクトのESLint設定ファイルを読みに行くこのファイルのモジュール解決はCJSとなる。

プロジェクト自体はESM

Node.jsのドキュメントには下記のように記載がある

Files ending in .js, or extensionless files, when the nearest parent package.json file contains a top-level field "type" with a value of "module".

検証用リポジトリのpackage.jsonには、"type"フィールドがあり、"module"と定義されている。

もしこのプロジェクトに存在するESLintの設定ファイル名が.eslintrc.jsだとしたら、CJSであるESLintパッケージからESMである検証用プロジェクトのファイルを呼び出すことになる。

前段で検証したとおりCJS→ESMは不可能なためエラーが発生する。

 ~/*****/node-type-module  master●●  0m  yarn lint                                                                                                             
yarn run v1.21.1
$ eslint --ext ts .
(node:79774) Warning: require() of ES modules is not supported.
require() of /*****/node-type-module/.eslintrc.js from /*****/node-type-module/node_modules/eslint/lib/cli-engine/config-array-factory.js is an ES module file as it is a .js file whose nearest parent package.json contains "type": "module" which defines all .js files in that package scope as ES modules.
Instead rename .eslintrc.js to end in .cjs, change the requiring code to use import(), or remove "type": "module" from /*****/node-type-module/package.json.
Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /*****/node-type-module/.eslintrc.js
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1156:13)
    at Module.load (internal/modules/cjs/loader.js:976:32)
    at Function.Module._load (internal/modules/cjs/loader.js:884:14)
    at Module.require (internal/modules/cjs/loader.js:1016:19)
    at module.exports (/*****/node-type-module/node_modules/import-fresh/index.js:31:59)
    at loadJSConfigFile (/*****/node-type-module/node_modules/eslint/lib/cli-engine/config-array-factory.js:201:16)
    at loadConfigFile (/*****/node-type-module/node_modules/eslint/lib/cli-engine/config-array-factory.js:284:20)
    at ConfigArrayFactory._loadConfigDataInDirectory (/*****/node-type-module/node_modules/eslint/lib/cli-engine/config-array-factory.js:517:34)
    at ConfigArrayFactory.loadInDirectory (/*****/node-type-module/node_modules/eslint/lib/cli-engine/config-array-factory.js:434:18)
    at CascadingConfigArrayFactory._loadConfigInAncestors (/*****/node-type-module/node_modules/eslint/lib/cli-engine/cascading-config-array-factory.js:328:46)
error Command failed with exit code 2.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

対応策

.eslintrc.js.eslintrc.cjsに書き換えればうまくいくだろう。なぜなら、.cjsファイルはCJSモジュール解決なのだから。」と思うであろう。筆者も同様に考えて検証を行ったがESLint側が.eslintrc.cjsに対応していないため何らかの対策が必要である。

ESLint側の.mjs, .cjs対応についてはRFCが公開されているので是非読んで欲しい。

とはいえ「待てない、すぐに動くようにしたい」と思う方もいるであろう。そのような方は一旦forkして限定的に動くようにしてしまうのも手である。

(ここからは決して動作を保証するものではないのでやらない方が良い。もしやる場合は自己責任でやること。)

ESLintの修正

差分はこれだけである

@@ -61,6 +61,7 @@ 
 const debug = require("debug")("eslint:config-array-factory");
 const eslintRecommendedPath = path.resolve(__dirname, "../../conf/eslint-recommended.js");
 const eslintAllPath = path.resolve(__dirname, "../../conf/eslint-all.js");
 const configFilenames = [
+    ".eslintrc.cjs",
     ".eslintrc.js",
     ".eslintrc.yaml",
     ".eslintrc.yml",
@@ -279,6 +280,7 @@ 
function configMissingError(configName, importerName) {
function loadConfigFile(filePath) {
    switch (path.extname(filePath)) {
        case ".js":
+       case ".cjs":
            return loadJSConfigFile(filePath); 

        case ".json":

リポジトリはこちら

あとは、このリポジトリをpackage.jsonに指定すれば良い。検証用のリポジトリのpackage.jsonを参照すること。

.eslintrc.jsの拡張子を.cjsに変える

残りは.eslintrc.jsの拡張子を.cjsに変えるだけだ。

 ~/*****/node-type-module  master  0m  yarn lint                                                                                                               
yarn run v1.21.1
$ eslint --ext ts .
   Done in 1.99s.

無事にESLintが動いた。

設定ファイルをjsonやymlで定義する

ESLintの設定ファイルについて.jsにこだわる必要はない。jsonやymlで書いていてjsで書きたいという気持ちが訪れた時に書けば良いと考えている。

ESMの課題2: 拡張子省略したモジュール読み込みに対応していない

先にも述べたが、CJSのモジュール解決方法は.jsを省略することができる。ryan dahlが失敗と述べているが、この挙動はブラウザのセマンティクスと少し離れてしまっている。

CJSのモジュール解決方法と異なり、Node.jsのMJSのデフォルトの仕様は拡張子を必須としている。

The --experimental-specifier-resolution=[mode] flag can be used to customize the extension resolution algorithm. The default mode is explicit, which requires the full path to a module be provided to the loader. To enable the automatic extension resolution and importing from directories that include an index file use the node mode.

defaultのモードが"explicit"であり、CJS標準で書きたい場合は"node"と指定する必要がある。

冒頭で述べた通り、no optionでESMのプロジェクトを動かすのが本稿の趣旨のため、今回このオプションは特に指定せず、デフォルトの"explicit"のまま進めていく。

TypeScriptコンパイラは拡張子を補完しない

TypeScriptではESMの仕様を踏襲したモジュール解決を行う。つまり、import Foo from "./foo";exort default {...}という構文を用いる。

ここで問題になるのが出力結果だ。esnextで出力した際にはモジュール解決部分の記述は概ねそのままビルドされるので、拡張子が補完されることはない。

ということはTypeScriptでモジュール解決を行っているプロジェクトで、esnextで出力したものをoption指定せずに実行すると、Error: Cannnot find module ...というエラーを吐く。

試しに検証プロジェクトのソースファイルのimport文の拡張子を削って実行してみよう。

 ~/*****/node-type-module  master●  0m  SECRET=***** API_KEY=***** node dist/index.js                                                                    
(node:83848) ExperimentalWarning: The ESM module loader is experimental.
internal/modules/esm/default_resolve.js:94
  let url = moduleWrapResolve(specifier, parentURL);
            ^

Error: Cannot find module /*****/node-type-module/dist/lib/search_slide imported from /*****/node-type-module/dist/index.js
    at Loader.resolve [as _resolve] (internal/modules/esm/default_resolve.js:94:13)
    at Loader.resolve (internal/modules/esm/loader.js:74:33)
    at Loader.getModuleJob (internal/modules/esm/loader.js:148:40)
    at ModuleWrap.<anonymous> (internal/modules/esm/module_job.js:41:40)
    at link (internal/modules/esm/module_job.js:40:36) {
  code: 'ERR_MODULE_NOT_FOUND'
}

このようにエラーが出てしまう。実行時にThe --experimental-specifier-resolution=nodeにすればこのエラーは抑えられるが、本稿の趣旨とは外れてしまうため違う形で対応することにした。

こちらの問題についてはこのissueで議論が行われている

補完しないことを逆手に取る

TypeScriptコンパイラは、import文について補完は行わないようなので、拡張子も含めてそのまま出力される。つまり、import Foo from "./foo.ts"と書いたらそのまま出力されるのだ。(.ts拡張子を指定した場合ビルドの段階でエラーが出るがファイルは出力される。)

※これに関しては実行環境までTypeScriptの環境で困ったことになる。正常な挙動なのにエラーが出てくるのだ。

※現段階で実行環境がTypeScriptなものはあまり多くはないがdenoなどはそれに当たるだろう

※closeされているがこのissueがこの困り事に関するものである。

話がそれてしまった。拡張子も含めたimport文を書くことでこの問題を辛うじて乗り切ることができる。

TypeScriptのビルドタイムでは嘘になるが、コンパイラは.tsのファイルを.jsとして読み込ませても正常にコンパイルする上、推論も効くので、ビルドから結果出力まで問題なく行うことができる。

結果的にTypeScriptのファイルでTypeScriptのファイルを.jsで呼び出すという奇妙な形になった。

// helloworld.ts
export default (): void => {
  process.stdout.write("hello world");
};
// index.ts
import helloworld from "./helloworld.js";

helloworld(); // hello world

ESLintのimport/no-unresolved

残る課題はESLintのエラーだけとなった。.jsを読み込ませようとすると、そのようなファイルは存在しないため、ESLintのルールに引っかかってしまう。勿論ルールにはよるが、検証用プロジェクトで採用しているルールではエラーになるようになっている。

この問題に対してはeslint-ignoreすることでしか解決できなかった。

勿論badな選択肢なのだが、本稿の趣旨は「Node.jsでオプションなしでESMを実行し切る」であるため、この方法でエラーを解消することにした。

結論

繰り返しになるが、結論は、「結構しんどいしTypeScript開発をしたい昨今の事情を鑑みると、旨味が少ない」だが、あくまで「現在は」である。

先にも述べたように、ESLintではRFCが出ていてTypeScriptでもissueで議論が行われている。TypeScript側のissueは2017年から動きが無かったようだが、数日前からまた議論が行われている。

ソースファイルとビルド結果ファイルの差分が少ないに越したことは無いので、Node.jsエコシステムがESMにフレンドリーになる日を心待ちにしている。

参考

最後に

(雑感)検証用のリポジトリはSlideShareのAPIを叩いています。ちょっとオーバーエンジニアリングな感じが見受けられますが何かの参考になれば良いと思います。

明日は@massaaaaanさんの『たった10行でOCR!RubyとGoogle Cloud Vision APIで飲食店のメニュー画像を文字認識してみた』です。