Node.jsのES Modulesサポートの現状確認と備え

この話を今日のNode学園で話すので、ご興味あればどうぞ。

nodejs.connpass.com

(今日いくつか加筆修正しました)


ECMAScript 2015で待望のES Modules(ESM)の仕様が策定されたものの実装がなかなか進まない、という話を1年前に発表した

その後、ブラウザでのES Modules仕様が策定完了し、フラグ付きながら全主要モダンブラウザで初期実装が揃った (caniuse)。(dynamitterさんkijtraさんからのコメントを受けて修正)

そしてついに、揉めに揉めていたNode.jsのES Modulesネイティブサポートにも7月に入って動きがあり、draftというステータスながらも仕様がマージされ、初期実装のプルリクが投げられた。

ということで、現在進められているNode.jsのES Modules仕様と実装を眺めてみたので紹介する。


実はのんびり書いている間に下記のブログが公開されました。

重複部分は省略してるので、上の記事と前回のスライドを合わせてご参照ください。

背景: なぜそんなに揉めていたの?

ES2015の言語仕様では、ES Modulesのシンタックスは決めたが、それが実際どう動くかはホスト側が決めることになっていた。 つまり、ブラウザであればWHATWG, サーバーであればNode.jsの各陣営が個別にES Modulesの周辺仕様を策定していく必要があるため、実装方針だけ決めれば良い他の機能に比べて時間がかかっていた。

特に論点になったのは以下3点。

  • そのファイルがES Modulesかどうかを判定する方法
  • 識別子の解決方法
  • 既存のCommonJS Modulesとの相互互換性

3点目について、ブラウザとは違って、既にCommonJS Modules (以下CJS Modulesまたは単にCJS) というモジュール機構を持っているNode.jsでは相互互換性という面倒な要素を考える必要があった。

結果的にブラウザとNode.jsでは以下のようになった

Browser Node.js
ES Modules判定 <script type="module"> .mjs 拡張子
識別子の解決 URL (絶対 or 相対) URL (Node.js方式の探索)
CJS Modules互換性 N/A 制限付きで互換

ブラウザについては前述のスライドや記事を参照してもらうとして、以下ではNode.jsについて見ていく。

TL;DR: 今どうすればいいの?

長くなるので、つべこべ言わずに今どんなコードを書けば良いのか教えて!という人のために、先に今押さえておくべきことだけピックアップ。

安定派はまだCommonJS Modulesを

多くの人にとって、まだ仕様が不安定なESMでコードを今書くメリットは「かっこいい」「キマル」ということ以外にほとんどない。 世間に踊らされずrequire()を書いていれば、あとでNode.jsにESMが来たときでも互換性が確保される予定なので、これまで書いたパッケージをESMからimportできる。安心。

それでもES Modulesを書きたい人は

以下2点に気をつける。

  • ESMからCJSをimportする場合、 named importを使わない。default importだけを使う。
  • ESMの中でrequire, module, __dirname, __filenameは利用できなくなるので、できるだけ使わない、または後で変更する覚悟をしておく。

依然として議論が分かれている.mjs関連に比べて、この辺りはコンセンサスが取れているようなのでそのまま通りそう。今から気をつけておくと修正リスクが減らせる。

特に1点目については、現状のBabelを使ったES Modulesもどき (Babel Modulesと仮称) の世界では普通に利用されているので要注意。 つまりCJSをこのようにnamed importするコードは、Babelでは動くがネイティブなES ModulesがサポートされたNode.jsではエラーになる。

import {readFile} from 'fs';
// SyntaxError: The requested module does not provide an export named 'readFile'

以下のようにdefault importで書き直す必要がある。

import fs from 'fs';
fs.readFile(...);
// or 
import fs from 'fs';
const {readFile} = fs;
readFile(...);

拡張子.mjsについては議論も実装もまだ揺れているし、機械的にリネームするのも難しくないので、今はとりあえずスルーしておくのが良さそう。

ではここから詳細の話。

ES Modulesの判定方法: .mjs

現行の仕様では、Node.jsにファイルをES Modulesとして認識させるには、ファイル拡張子を.mjsにする必要がある。 .jsファイルは従来のCJS扱いになるため、.jsの中でimportexportを使うとシンタックスエラーになる。

.mjsは新しい拡張子のため、ローカル開発環境のエディタ、IDE、ビルドツールやlintツールなどの設定変更が必要になる。

一方、ブラウザは拡張子は全くチェックしない。その代わりcontent-typeヘッダがJavaScriptとして適切かはチェックする(<script type=module>はデフォルトでX-Content-Type-Options: nosniffと同等にcontent-typeをチェックすることになったため、application/octet-stream等ではJavaScriptとして実行してくれない)。よって、.mjsファイルをブラウザでもJSとして実行させたい場合、配信時にcontent-typeヘッダをapplication/javascriptなどJavaScriptとして適切なものにする必要がある。そのために、例えばnginx/apacheなどのサーバー設定で.mjsファイルに対する設定を変更しなければいけない。

識別子の解決: URL (Node.js方式で探索)

ES Modulesの識別子(下記でいう'./foo'の部分)をどう扱うかについて。

import foo from './foo';

まず、ブラウザでのES Modules仕様では識別子はURL (WHATWG URL) として扱われる。 Node.jsのESMでもこれを追随して、URLとして扱うことになった。 そのためURLで特殊扱いされる区切り文字、例えば:,?,#, %などの扱いが従来のCJSの識別子とは異なる。 通常の用途ではまあ問題ないと思う。(詳細はHTML Standard | 8.1.3.8 Integration with the JavaScript module systemを参照)

探索方法は基本的には旧来のNode.jsの方式を継承している。従来と異なるのは、自動で付与される拡張子に.mjsが追加されていること。優先順位は.jsより高い。

  1. 識別子がfile://から始まる絶対URLである場合、そのままURLとして扱う
  2. 識別子が/, ./, ../で始まる場合、それを相対URLとして扱ってパスサーチを行う
    1. 識別子をファイル名として探索
    2. 拡張子を順に付与して探索
      • [".mjs", ".js", ".json", ".node"]
    3. ディレクトリとして探索
      1. ${specifier}/package.jsonmainを読む
      2. ${specifier}/indexを読む
  3. 識別子がそれ以外の場合、モジュールサーチを行う
    • ./node_modules/${specifier}を親ディレクトリをさかのぼって探索

つまり、

import foo from './foo';

と書かれた場合、以下の優先順位になる。

  1. ./foo
  2. ./foo.mjs
  3. ./foo.js
  4. ./foo.json
  5. ./foo.node
  6. ./foo/package.jsonmain を探索
  7. ./foo/index(拡張子を変えて探索)

「6. package.jsonの探索」では、例えばmain./barだった場合、

  1. ./foo/bar
  2. ./foo/bar.mjs
  3. ./foo/bar.js
  4. ./foo/bar.json
  5. ./foo/bar.node
  6. ./foo/bar/index(拡張子を変えて探索)

のように探索される。これを利用して、npmパッケージをESM/CJS両対応でリリースすることができる(後述)。

これを踏まえて、Node.jsでESMからESMを呼び出すのは以下のようになる。(普通です)

// foo.mjs
export default 'foo!';
// main.mjs
import foo from './foo';
console.log(foo); // 'foo!'

上述の通り、識別子の拡張子は自動で補われるため省略して./fooと書ける。

しかし、このままブラウザに配信しても動作しない。拡張子自動付与はNode.js独自の仕様であるため。 変更/変換なしでそのままブラウザでも動かしたい場合、以下のように自分で拡張子を書いてあげる必要がある。

// main.mjs
import foo from './foo.mjs';

ただ、この書き方をすると後述するESM/CJS両対応パッケージを作りにくくなるのが悩ましいところ。

CJS Modulesとの互換性

ES Modulesで使えなくなる変数たち

まずECMAScriptレベルで、ES Modulesでは従来のscriptに比べて以下のような違いがある。

  • 強制的にStrictモードに
  • thisundefined
  • global scopeではなくtop level scopeに
  • top level await予約語

それに加えて、Node.jsのES Modulesでは、従来のCJSでは使えた以下のようなグローバル変数が削除されている。

  • require
  • module
  • exports
  • __filename
  • __dirname
  • arguments

最初の3つが意味することは、ESMの中でrequire()を使ってCJSを読み込んだり、ESMとCJSの両方の方式でexportしたりすることはできないということ(CJSを呼び出す方法は後述)。

__filename, __dirnameについては、URLベースになったため単純な置き換えが不可能として削除された。将来的にはES proposal import.meta (現在Stage 2) によってファイルのURLが取得可能にすることが検討されている。詳細は未定だが、こんな感じになりそう。

const url = import.meta.url;
// file:///foo/bar.mjs

import.metaは、言語仕様に入らないホスト依存のメタ情報をモジュール内部に渡す役割をする特殊なプロパティ。他にはブラウザではモジュール内部からscript要素の各種属性へのアクセスなんかもこれを使うことが検討されている。

この仕様が間に合わない場合、__dirnameについては同ディレクトリのCJSからimportするというwork aroundはある。

// expose.js
module.exports = {__dirname};
// use.mjs
import expose from './expose';
const {__dirname} = expose;

ES ModulesからCJS Modulesを読み込む

ESMからは、CJSを直接importで読み込むことができる。

// cjs.js
module.exports = function two() {
  return 2;
};
// es.mjs
import foo from './cjs';
// foo = two;
foo(); // 2

import * as bar from './cjs';
// bar = {default: two};
bar.default(); // 2
bar(); // throws, bar is not a function

CJSはimportされると、評価されたあとに{default: module.exports}のようなESMとして扱われる。

注意点としては、CJSがESMに変換されるとmodule.exportsのオブジェクトがそのままdefault exportになるため、先述した通りnamed exportは利用できない。繰り返しになるが、以下のように書くとエラーになる。

import {readFile} from 'fs';
// SyntaxError: The requested module does not provide an export named 'readFile'

つまり、インポート先のパッケージがESMなのかCJSなのか把握しておく必要がある。

従来のrequireように、宣言的ではなく命令的に、かつ同期的にCJSを読み込みたい場合はどうするの?という要望に対しては、import.meta.requireとして提供するということも検討されているようだ。

CJS ModulesからES Modulesを読み込む

前提として、CJSからESMを読み込むには、ES proposal Dynamic Import (現在Stage 3) を使う必要がある。 つまり、将来Dynamic Importが実装されたNode.jsのバージョン(現時点ではまだV8に実装中 (ついさっき実装完了した模様)でだけ使えるもの。 現行のNode 8等で実行したCJSファイルからESMを読み込めるというわけではない(もちろんpolyfill等は出てくるだろうけど)。

Dynamic Import

import()はPromiseを返すので、そのまま.then()でつなぐか、awaitで受ける。

// es.mjs
let foo = {bar:'my-default'};
export default foo;
export function f() {};
export class c {};
// cjs.js
(async () => {
const es_namespace = await import('./es');
// es_namespace ~= {
//   get default() {
//     return result_from_evaluating_foo;
//   }
//   get f() {return f;}
//   get c() {return c;}
// }
console.log(es_namespace.default);
// {bar:'my-default'}
})();

Dynamic Import自体は、CJSとESMのどちらの中でも使える。ESMでは条件別ロードや遅延ロードで利用される想定。

注意点として、前述した通りDynamic Importではnamed importしか扱えない。default importに対するshort handは用意されていないため、上述のように.defaultにプロパティアクセスするか、以下のようにDestructuringを使うしかない。

const {default as bar} = await import('./es');
console.log(bar);
// {bar:'my-default'}

ESM/CJS両対応パッケージの作り方

前述のように、package.jsonmainプロパティで拡張子を省略すると、

{
  "name": "esm-cjs-compat-package",
  "version": "1.0.0",
  "main": "index",
}
  • importされた場合: index.mjs
  • require()された場合: index.js

という挙動になるので、Babel等でimport/export文をCJSにトランスパイルすると同時に拡張子を.mjsから.jsに変換し、両方をnpmパッケージにバンドルすると、ESM/CJS両対応パッケージとなる。

サンプルリポジトリを用意したので詳細はこちらを参照。 https://github.com/teppeis-sandbox/esm-cjs-compat-package

FAQ

なんで.mjsなの?

ESM判定方法について、.mjsの対抗案は以下2つ

どれもpros/cons、トレードオフがあって、完璧な解決策はない。 module関連のissueはbikeshedなため数百のレスがついてしまいもはやオープンな議論はほぼ不可能なんじゃないかという印象なので、個人的にはとにかく最初の仕様と実装を進めることが大事だと思う。その最初の実装として.mjsは仕様も実装もシンプルなので進めやすい。 ES Modulesの議論をリードしているbmeckも、.mjs以外の案について排他ではなくadditionalな仕様として検討したいと発言している。 そうじゃないと、正式なES Modulesが出る前にBabel Modulesがどんどん広がって本当に収拾がつかなくなってしまうので。

ちなみに"use module";については、先月のTC39でECMAScript仕様に入れるか議論されたが、ネガティブな意見が多く進まなかった。ES仕様に入れるためにはNodeだけでなくブラウザでも有益であることを示すことが求められた。ES仕様に入れなくてもNodeの独自仕様として"use module";を採用することもできる(ECMAScript仕様に準拠した拡張として可能)が、Node陣営としてはそれは行わない方針らしいので、このまま行くとこの提案はお蔵入りになりそう。

.mjsの不安なところは、マネージドサービスのcontent-typeが対応してくれるかどうか。 例えば現状でGitHubは対応していないため、GitHub Pagesから.mjsファイルをscript要素で読み込んでも実行できずエラーになる。 .mjsというNode.js特有仕様なので(Web/ブラウザの仕様ではない)、それを各種サービスのWebサーバー設定にどれだけ反映してくれるのだろうか。

現状、IANAへのmedia type登録や、nginx, apacheでのデフォルト設定の改善などに向けて動いているとのこと。h2oにもプルリクが来ている。VS Codeの対応はマージ済み

NodeのCLIで標準入力や-eから実行するときはどうするの?

拡張子を指定するためのフラグが検討中。

$ node --entry-extension=mjs < app.mjs

github.com

なんで__filenameなどのグローバル変数が削除されたの?

CJS Modulesでは以下の様なモジュールラッパーによって特殊な変数を提供していた。

(function(exports, require, module, __filename, __dirname) {
// Module code actually lives in here
});

https://nodejs.org/api/modules.html#modules_the_module_wrapper

ESMの提供というタイミングで、識別子がURLベースになったこと、非標準なグローバル変数を外したい、といった要因で削除された

今後、こういったホストから提供される情報はimport.metaを経由するのが標準になっていくはず。その方がbrowserify的な変換もしやすいし。

いつ使えるようになるの?

現時点でプルリクはまだレビュー中。大きな問題があるというより細かな修正がメインをしながらレビュアーも徐々にES Modulesを学んでいるという印象。

プルリクでは--harmony-modulesフラグ付きで実行することになっている(フラグの名称は議論中なので変わるかも)。 ついさっき--experimental-modulesに変更された。 Node v9のtracking issueによると、v9は9月中にfeature freezeしてbetaリリース、10月にRCをリリース予定としている。 おそらくこのNode v9でES Modulesのフラグ付き実装がリリースされそう。 かなり影響が大きい変更なので、次期LTS Node v10でフラグを外すのは難しいと予想する。 順調に行ってもNode v11でフラグを外してNode v12でLTSデビューという流れか。

今、Node.js版ES Modulesの仕様を試す方法は?

node-epsのプルリクのブランチをビルドするか、ちょうど先日 @std/esm というポリフィルパッケージがリリースされたので試せる。

medium.com

ランタイムでacornでパースしてESMとして各種AST変換やdynamic importのポリフィルを当てるライブラリ。 パフォーマンスへの配慮か、tar.gzで固めるなどパッケージングの仕方もおもしろい。

コンパイル無しでUniversal JavaScriptは実現しますか?

識別子の仕様の違いから、素の静的なHTTPサーバーだけで実現することは難しい。

  • 識別子の拡張子を省略するとブラウザは解釈できない。
  • node_modules以下のnpmパッケージを参照する方法がない。

1点目は、import from './foo'では動かないので./foo.mjsにする必要があるが、そうするとNode.js側でのフォールバックが素直にはできなくなって辛い。

2点目は、ブラウザの仕様ではimport from 'foo'のような、絶対URLでもなく、.または/から始まらない文字列は認めていないのでエラーになる。

いずれの問題も、現状では、webpack的なものでプリコンパイルするか、ランタイムで解決する専用アプリサーバーで配信するか、ブラウザ側でService Workerで変換を挟むなどして頑張る必要がある。

Babelは分かったが、TypeScriptはどうなの?

まだちゃんと議論は始まってないけど、おそらくちゃんとコードを書いてればTypeScript側のトランスパイルで吸収してくれそう。 TypeScriptには二つのモジュール構文がある。DefinitelyTypedなどの型付では、元のソースがCJS Modulesの場合は旧形式 import/require、ESMに対しては新形式 import/from を使うというルールがある(と思う)。なので、それが徹底されている限りはTypeScriptのトランスパイル側で吸収してくれるはず。

この辺りは、型の整合性を保つ必要があるので、Babelに比べると厳格に検討されていたことが結果的に良かった。(逆に二つのシンタックスを使い分ける必要があったり、defaultの扱いがBabelに比べると面倒だったりするのだけど)