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

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

# はじめに

Node.js v12で変更されるES Modulesの挙動についてと、Babelでの対応方法についての記事です。

10月に開催された[関西Node学園 8時限目](https://nodejs.connpass.com/event/147459/)で発表した内容＋α（後日談含む）です。

## 対象者

- 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](https://medium.com/@nodejs/announcing-a-new-experimental-modules-1be8d2d6c2ff)が一次ソースです。
以下の記事では、この内容を引用している箇所がいくつかあります。

## 変更点の概要

大きくこの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’. 

```javascript: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.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.

```javascript
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.

```json: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.)

```bash
$ 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に対応したコードを書くには以下のようにします。

```json:package.json
{
  "type": "module"
}
```

```javascript: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の新仕様対応状況についてちょっと調べてみましょう。

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

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

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

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

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

```javascript:example.mjs（変換後）
import "core-js/modules/es.array.iterator";

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

**おわかりいただけただろうか。**

Babelが自動的に埋め込むPolyfillには**拡張子がついていない**ので、v12では動かないのです。

```bash
$ 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を送りました](https://github.com/babel/babel/pull/10549)。

いろいろあって約2ヶ月後の12月6日（一昨々日！）に無事マージされ、[**Babel v7.7.5に取り込まれました**](https://github.com/babel/babel/blob/master/CHANGELOG.md#v775-2019-12-06)🚀

これで大団円、めでたしめでたし…というわけではなく、ちょっと[やらかしてしまった](https://github.com/babel/babel/pull/10549#issuecomment-562733716)ようです。申し訳ない。
[v7.7.6で一旦取り消され、v7.8.0でオプションつきで復活する](https://github.com/babel/babel/pull/10549#issuecomment-562868853)そうです。

# TypeScriptでの対応

ではTypeScriptではどうかというと、`tsconfig.json`に`"module": "esnext"`を指定すると、`import`構文を変換せずにES Modulesのままで出力してくれます。
ただ問題は**拡張子**。

```typescript:foo.ts
import "bar.ts";
import "bar";
```

上記のどちらの書き方でも、新しいES Modulesでは取り込んでくれません。

`tsc`で変換時に拡張子を補完してくれるオプションもなく色々調べた結果、[すでにIssueが作られていました](https://github.com/microsoft/TypeScript/issues/33588)。
それによると、

> 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`](https://www.npmjs.com/package/babel-plugin-extension-resolver)という拡張子を自動的につけてくれるプラグインがあるので、`tsc`ではなく[`@babel/preset-typescript`](https://babeljs.io/docs/en/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形式での出力は諦めてね
    - これまで通り、CommonJS形式を使ってね
    - でも、`tsc`じゃなくて[`@babel/preset-typescript`](https://babeljs.io/docs/en/babel-preset-typescript)と[`babel-plugin-extension-resolver`](https://www.npmjs.com/package/babel-plugin-extension-resolver)を組み合わせるとうまくいくかもよ


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

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

このプラグインが出力する`import`/`require()`のモジュールパスは、Windows環境では**パスの区切り文字を`/`から`\\`に変更します**。
（実際に変換しているのはこのプラグインではなく、これが依存している[`resolve`](https://www.npmjs.com/package/resolve)、さらにいうなら`resolve`の中で使っている[`path`](https://nodejs.org/api/path.html)コアモジュールです）

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

```javascript
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`ではエラー**という謎の挙動です。

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

```javascript
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に強い子が[アドベントカレンダー](https://qiita.com/advent-calendar/2019/nodejs)経由でこの記事を見ている**と思うので、何かご存知でしたらコメントおねがいします！