本日は誕生日です。みなさんプレゼントありがとうございます。まだの方は急いでください。
あと年齢は聞かないでください。
はじめに
Node.js v12で変更されるES Modulesの挙動についてと、Babelでの対応方法についての記事です。
10月に開催された関西Node学園 8時限目で発表した内容+α(後日談含む)です。
対象者
- ES Modules(
import
構文)は知ってるけどNode.js v12で何か変わったの?
非対象者
- v12での変更点もちゃんと知ってるし!
- そういう強い子は、この記事本文はスルーしてもいいので最後にある「おまけ」だけでも見てください。
- ES Modulesって何?
- ていうかJavaScriptって何?
この記事のゴール
- Node.js v12におけるES Modulesの変更点について理解し、適切なコードを書けるようになる
- Babelを使っている場合は適切な設定を行えるようになる
Node.js v12での仕様変更
まずは、Node.js v12におけるES Modulesの変更点について説明します。
Announcing a new --experimental-modulesが一次ソースです。
以下の記事では、この内容を引用している箇所がいくつかあります。
変更点の概要
大きくこの2点。
- ES Modulesの構文を使うとき、拡張子が必須になる
- ES Modules用の拡張子は
.mjs
だけではなく.js
も追加される
ES Modulesの構文を使うとき、拡張子が必須になる
By default in the new --experimental-modules, file extensions are mandatory in import statements: import ‘./file.js’, not import ‘./file’.
import"foo";// 今まではOKだったけどNGになるimport"foo/index";// 今まではOKだったけどNGになるimport"foo/index.mjs";// こうしないとダメ(.mjsという拡張子については後述)
Node.jsのコミュニティでは、ブラウザ側と挙動を合わせようという動きが以前からあったようです。
具体的には、以下のような違いがありました。
- ブラウザで
script
タグを使ってJavaScriptファイルを読み込むときは拡張子が必須 - Node.jsでは拡張子は省略可能
- さらに
index.(m)js
も省略可能(foo/index.js
やfoo/index.mjs
というファイルはfoo
だけでも読み込み可能)
- さらに
これを、拡張子を必須にすることによりブラウザ側に寄せようという話です。
ES Modules用の拡張子は.mjs
だけではなく.js
も追加される
The .cjs extension provides a way to save CommonJS files in a project where both .mjs and .js files are treated as ES modules.
import"foo.js";// 今後は".js"はES Modules形式とみなされる(仕様変更)import"foo.mjs";// ".mjs"も同様にES Modules形式とみなされる(従来どおり)require("foo.cjs");// CommonJS形式の拡張子は".cjs"になる(新仕様)
今後、モジュールの主流はCommonJS形式からES Modules形式に移っていくことは容易に予想できます。
そんな状況で、例えば10年後でも.js
=CommonJSで、ES Modulesを使いたければ.mjs
にしないといけないのか?という話です。
JavaScriptの拡張子は.js
なのだから、ES Modulesでこの拡張子を使っていきたいというのは自然な流れでしょう。
互換性の確保
.js
の扱い
Add “type”: “module” to the package.json for your project, and Node.js will treat all .js files in your project as ES modules.
{..."type":"module",//.jsをESModulesとして扱いたい場合"type":"commonjs",//.jsをCommonJSとして扱いたい場合(デフォルト)...}
とはいっても、いきなり「じゃあNode.js v12では.js
はES Modulesだからね!」と言ってしまうと、全世界のNode.jsで動いているシステムがバージョンアップした途端に停止し、阿鼻叫喚の地獄絵図と化します。ちなみに弊社の一部サービスも止まります。
互換性を確保するため、package.json
に"type": "module"
という設定を入れた場合に限り.js
をES Modules形式とみなすようになりました。"type": "commonjs"
の場合は従来どおり.js
はCommonJS形式で、これは"type"
が設定されていない場合のデフォルトの挙動でもあります。
この設定はパッケージ単位で有効なので、パッケージごとに.js
の挙動を変えることもできます。
拡張子の省略
However, the CommonJS-style automatic extension resolution behavior (‘./file’) can be enabled via a new flag, --es-module-specifier-resolution=node. (Its inverse, the default, is --es-module-specifier-resolution=explicit.)
$ node --experimental-modules foo.mjs # 拡張子は必須$ node --experimental-modules--es-module-specifier-resolution=node foo.mjs # 拡張子は省略可(従来どおり)
拡張子もいきなり問答無用で必須にされると困るので、コマンドラインオプションとして--es-module-specifier-resolution=node
を指定すると従来どおり拡張子やindex.mjs
を省略できます。
先程の.js
の扱いとは異なり、こちらはプロセス単位での指定かつ明示的にオプションを指定しないと拡張子を省略できないので注意してください。
明示的なオプションを必須にした背景としては、Node.jsのES Modulesは実験段階を抜けたばかり(一次ソースが公開された時点ではまだ実験段階だった)なので本格的に使っているユーザ数が少ないことと、デフォルトで拡張子を省略可能にしているといつまでたっても誰も拡張子をつけてくれず、新仕様が形骸化するのでユーザ数が少ない今のうちに多少強引にでも変えてしまったほうがいいと判断したのだろうと推測しています。
拡張子が必須になるのはES Modulesだけ
また、拡張子が必須になるのはES Modules形式だけで、CommonJS形式ではv12以降でも拡張子を省略できます。
この理由は間違いなく、上で書いたようにいきなり拡張子を必須にすると全世界が地獄に落ちるからでしょう。
Node.js v12に対応したコードの書き方
以上をふまえて、新しいES Modulesに対応したコードを書くには以下のようにします。
{"type":"module"}
import"./path/to/bar.js"// 拡張子を省略しない
特に難しいところはありませんね。
パッケージの作り方
Currently, it is not possible to create a package that can be used via both require(‘pkg’) and import ‘pkg’.
npmパッケージを作っている人であれば、新しい仕様でCommonJSとES Modulesの両方に対応したパッケージはどうやって作るの?という疑問が出てくるでしょう。
残念なことに無理だそうです。
ただ、記事中にはCurrentlyと書いてあるので、将来的に両方対応できる仕様になる可能性はワンチャン残されています。
Babelでの対応
現時点では、Node.jsで動くコードを手打ちしている人はあまり多くなく、最新の仕様で書いてBabelで変換したり、TypeScriptなどのAltJSを使っている人が多いのではないかと思います。
(新しいNode.jsは最新のECMAScriptの仕様をかなり取り込んでいますが)
そこで、Babelの新仕様対応状況についてちょっと調べてみましょう。
まずは以下のようなコードを…
for(constxof[1,2,3]){console.log(x);}
以下のような設定で変換してみましょう。
["@babel/preset-env",{"targets":{"node":"8.5.0"},"useBuiltIns":"usage","corejs":3,"modules":false}]
すると以下のようになります。
import"core-js/modules/es.array.iterator";for(constxof[1,2,3]){console.log(x);}
おわかりいただけただろうか。
Babelが自動的に埋め込むPolyfillには拡張子がついていないので、v12では動かないのです。
$ node --experimental-modules example.mjs
(node:5693) ExperimentalWarning: The ESM module loader is experimental.
internal/modules/esm/default_resolve.js:79
let url = moduleWrapResolve(specifier, parentURL);
^
Error: Cannot find module /path/to/example/node_modules/core-js/modules/es.array.iterator imported from /path/to/example/dist/example.mjs
at Loader.resolve [as _resolve] (internal/modules/esm/default_resolve.js:79:13)
at Loader.resolve (internal/modules/esm/loader.js:73:33)
at Loader.getModuleJob (internal/modules/esm/loader.js:152:40)
at ModuleWrap. (internal/modules/esm/module_job.js:43:40)
at link(internal/modules/esm/module_job.js:42:36){
code: 'ERR_MODULE_NOT_FOUND'}
これを解決するために、10月にPull Requestを送りました。
いろいろあって約2ヶ月後の12月6日(一昨々日!)に無事マージされ、Babel v7.7.5に取り込まれました🚀
これで大団円、めでたしめでたし…というわけではなく、ちょっとやらかしてしまったようです。申し訳ない。
v7.7.6で一旦取り消され、v7.8.0でオプションつきで復活するそうです。
TypeScriptでの対応
ではTypeScriptではどうかというと、tsconfig.json
に"module": "esnext"
を指定すると、import
構文を変換せずにES Modulesのままで出力してくれます。
ただ問題は拡張子。
import"bar.ts";import"bar";
上記のどちらの書き方でも、新しいES Modulesでは取り込んでくれません。
tsc
で変換時に拡張子を補完してくれるオプションもなく色々調べた結果、すでにIssueが作られていました。
それによると、
TypeScript always emits JavaScript code as written, and import statements are JavaScript code so aren't changed on emit.
「JSのコード部分は変更しないから無理だよ」とバッサリ。TypeScriptのポリシーなんでしょうね。
CommonJS形式で使っていくしかなさそうです。
ところで、この記事の最初にある「はじめに」の
Node.js v12で変更されるES Modulesの挙動についてと、Babelでの対応方法についての記事です。
という部分や、「この記事のゴール」の
- Babelを使っている場合は適切な設定を行えるようになる
という部分でBabelにしか触れていない(TypeScriptはスルーしている)ことに気づいた方はいるでしょうか。
TypeScriptでは無理という伏線です。気づいた人はえらい。
Babelにはbabel-plugin-extension-resolver
という拡張子を自動的につけてくれるプラグインがあるので、tsc
ではなく@babel/preset-typescript
とこのプラグインを組み合わせるとうまくいくかもしれません(未検証)。
まとめ
- ES Modulesの構文を使うとき、拡張子が必須になるよ
- CommonJSでは、これまで通り拡張子を省略できるよ
- ES Modulesでも拡張子を省略したい場合は、
node
のコマンドラインオプションに--es-module-specifier-resolution=node
をつけてね - これはプロセス単位で有効だよ
- ES Modules用の拡張子は
.mjs
だけじゃなく.js
も追加されるよ- CommonJS形式の拡張子は
.cjs
だよ - これはデフォルトの挙動じゃないよ
- この挙動を有効にするには
package.json
に"type": "module"
を追加してね - これはパッケージ単位で有効だよ
- CommonJS形式の拡張子は
- 新しい仕様でCommonJSとES Modulesの両方に対応したパッケージは作れないよ
- Babelはv7.7.5でPolyfillにも拡張子がつくよ
- でもv7.7.6で一旦消えるよ。続きはv7.8.0で。
- TypeScriptはES Modules形式での出力は諦めてね
- これまで通り、CommonJS形式を使ってね
- でも、
tsc
じゃなくて@babel/preset-typescript
とbabel-plugin-extension-resolver
を組み合わせるとうまくいくかもよ
おまけ: Babel for Windowsの謎の挙動
上で出てきたbabel-plugin-extension-resolver
ですが、Windows環境でハマりました。
このプラグインが出力するimport
/require()
のモジュールパスは、Windows環境ではパスの区切り文字を/
から\\
に変更します。
(実際に変換しているのはこのプラグインではなく、これが依存しているresolve
、さらにいうならresolve
の中で使っているpath
コアモジュールです)
それ自体はWindowsのパス名の仕様なので問題ないのですが、\\
が含まれるパスをNode.jsで取り込もうとするとよくわからない結果になりました。
require('..\\sub\\sub');// ①OKrequire('../sub\\sub');// ②OKrequire('.\\sub');// ③エラーrequire('./sub');// ④OKimport'..\\sub\\sub.mjs';// ⑤エラーimport'../sub\\sub.mjs';// ⑥OKimport'.\\sub.mjs';// ⑦エラーimport'./sub.mjs';// ⑧OK
とくにわからないのが①と⑤。同じ'..\\sub\\sub'
というパスなのにrequire()
ではOKでimport
ではエラーという謎の挙動です。
そして、先頭に./
を追加するとエラーは全て消えました。
require('./..\\sub\\sub');// ①OKrequire('./../sub\\sub');// ②OKrequire('./.\\sub');// ③OKrequire('././sub');// ④OKimport'./..\\sub\\sub.mjs';// ⑤OKimport'./../sub\\sub.mjs';// ⑥OKimport'./.\\sub.mjs';// ⑦OKimport'././sub.mjs';// ⑧OK
これは仕様…ということはないと思いますが、Node.js側の問題なのでしょうか。それともV8?
きっとNode.jsに強い子がアドベントカレンダー経由でこの記事を見ていると思うので、何かご存知でしたらコメントおねがいします!