Help us understand the problem. What is going on with this article?

Node.js v12のES Modulesと、Babel/TypeScriptの対応について

本日は誕生日です。みなさんプレゼントありがとうございます。まだの方は急いでください。
あと年齢は聞かないでください。

はじめに

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’.

foo/index.mjsというファイルをインポートしたい場合
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.jsfoo/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.

package.json
{
  ...
  "type": "module",   // .jsをES Modulesとして扱いたい場合
  "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に対応したコードを書くには以下のようにします。

package.json
{
  "type": "module"
}
foo.js
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の新仕様対応状況についてちょっと調べてみましょう。

まずは以下のようなコードを…

example.mjs
for (const x of [1, 2, 3]) {
  console.log(x);
}

以下のような設定で変換してみましょう。

.babelrc
[
  "@babel/preset-env",
  {
    "targets": {
      "node": "8.5.0"
    },
    "useBuiltIns": "usage",
    "corejs": 3,
    "modules": false
  }
]

すると以下のようになります。

example.mjs(変換後)
import "core-js/modules/es.array.iterator";

for (const x of [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のままで出力してくれます。
ただ問題は拡張子

foo.ts
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とES Modulesの両方に対応したパッケージは作れないよ
  • Babelはv7.7.5でPolyfillにも拡張子がつくよ
    • でもv7.7.6で一旦消えるよ。続きはv7.8.0で。
  • TypeScriptはES Modules形式での出力は諦めてね

おまけ: Babel for Windowsの謎の挙動

上で出てきたbabel-plugin-extension-resolverですが、Windows環境でハマりました。

このプラグインが出力するimport/require()のモジュールパスは、Windows環境ではパスの区切り文字を/から\\に変更します
(実際に変換しているのはこのプラグインではなく、これが依存しているresolve、さらにいうならresolveの中で使っているpathコアモジュールです)

それ自体はWindowsのパス名の仕様なので問題ないのですが、\\が含まれるパスをNode.jsで取り込もうとするとよくわからない結果になりました

require('..\\sub\\sub');   // ①OK
require('../sub\\sub');    // ②OK
require('.\\sub');         // ③エラー
require('./sub');          // ④OK
import '..\\sub\\sub.mjs'; // ⑤エラー
import '../sub\\sub.mjs';  // ⑥OK
import '.\\sub.mjs';       // ⑦エラー
import './sub.mjs';        // ⑧OK

とくにわからないのが①と⑤。同じ'..\\sub\\sub'というパスなのにrequire()ではOKでimportではエラーという謎の挙動です。

そして、先頭に./を追加するとエラーは全て消えました

require('./..\\sub\\sub');   // ①OK
require('./../sub\\sub');    // ②OK
require('./.\\sub');         // ③OK
require('././sub');          // ④OK
import './..\\sub\\sub.mjs'; // ⑤OK
import './../sub\\sub.mjs';  // ⑥OK
import './.\\sub.mjs';       // ⑦OK
import '././sub.mjs';        // ⑧OK

これは仕様…ということはないと思いますが、Node.js側の問題なのでしょうか。それともV8?

きっとNode.jsに強い子がアドベントカレンダー経由でこの記事を見ていると思うので、何かご存知でしたらコメントおねがいします!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away