ES Modules と Node.js について

書こう書こうと思いながらこのタイミングまでのがしてしまいました。 今一番 Node.js の中で hot な discussion の一つと言えるでしょう、『ES Modules が Node.js の中でどうなるか』です。

ES Modules 現況

ES2015 が発刊されてそろそろ一年です。 ES2015 にある機能は Node.js v6でも 93% 程度カバーされています。モダンブラウザでも大体が90%を超えています。しかし、 ES Modules だけはまだどのブラウザも実装しきれていません(kangax compat table は ES Modules は省かれてます)。

f:id:yosuke_furukawa:20160509010917p:plain

そもそも ECMAScript 2015 自身で定義されたのは構文だけなので、構文はともかく、どうやってモジュールを取ってくるかという Loader の部分がまだ決まりきっていません。

https://whatwg.github.io/loader/

現時点はいくつも決めなきゃいけないポイントがあって

  • 参照解決処理
  • 取得処理
  • script タグでどう書くのか
  • メモ化処理(所謂caching)

の全てを決めて一旦ロードマップ上のMilestone 0 が達成されるような状況です。

https://github.com/whatwg/loader/blob/master/roadmap.md

scriptタグでどう書くのか、参照解決処理など、ある程度決まっている処理はありますが、どの項目もまだ議論中です(少なくとも github 上ではまだ milestone 0 を discussion している最中に見える)。各種ブラウザでも、実装が始まっているところはありますが、仕様の方針待ちなところが多いです。

なんで Node.js に ES Modules が必要なのか

ES Modules の仕様が定義されるよりも前に Node.js は CommonJS と呼ばれるモジュールシステムを採用しました*1。それと npm というパッケージマネージャの組み合わせでエコシステムを作っています。結果として npm のエコシステムは Node.js にとどまらず、Browserify や webpack を組み合わせてフロントエンドにとっても大きなエコシステムになっています。

『CommonJS で既に育ってしまった生態系の中で ES Modules という標準仕様とどうやって相互運用性(interoperability)を取るのか』 これが ES Modules が定義され始めた最初からずっと Node.js / npm で語られてる事でした。

相互運用性がないとこれまでのエコシステムと乖離(friction)ができてしまいます。せっかく Browserify や webpack で埋めたfrontend browserとNode.js との乖離がこれでまた起きることになります。

Node.js ではじゃあどうしようとしているのか

これではイカン、という事で Bradley Meck 氏が interop を取ろうと Proposal を書き起こしました。最初に Proposal を書いた時は議論がいくつもあったので ものすごくたくさんの話が巻き起こってまとまらなかった のですが、何度も何度も議論を重ねて今やっと DRAFT というステータスになっています。

node-eps/002-es6-modules.md at master · nodejs/node-eps · GitHub

一応言っておくと DRAFT というのはやると決めた訳じゃないです。議論のテーブルに乗った状態DRAFT です。ステータスとしては DRAFT => ACCEPTDRAFT => REJECT の2通りがあるので REJECT されて無かったことになる可能性もあります。

ES Modules on Node.js 概要

おおまかな解決アルゴリズムを記述します。

まず、 DynamicModuleRecord と呼ばれる CommonJS 用の module 置き場を定義します。これは ES Modules から CommonJS の module.exports で定義されたものを import で読み込めるようにするためのレジストリです。

その上で下記のアルゴリズムで読み込みます。

1.  読み込もうとしているファイルが CommonJS で定義されているのか ES Modules で定義されているのかを確認する(※)
2. もし CommonJS なら
  2-1. ファイルを即時評価する(今まで通り)
  2-2. DynamicModuleRecord に `module.exports` で読み込んだものを入れる
3. もし ES Modules なら
  3-1. ファイルをパースする(import/export でファイルを取得して、bindingを作るため)
  3-2. 再帰的に全ての依存関係のあるファイルを持ってくる
  3-3. 全ての依存関係のファイルから `import`  の binding を作る
  3-4. 評価する

簡易フローチャートで書くとこうですね。

f:id:yosuke_furukawa:20160509230807p:plain

この後さらにケースとしてはファイルが循環参照されてたらどうするかとかの話がありますが、一旦そこは置いておきます。

読み込もうとしているファイルが CommonJS で定義されているのか ES Modules で定義されているのかを確認する ここが今のところ最大の議論のポイントです。

CommonJS なのか ES Modules なのかの確認方法ですが、読み込もうとしているファイルが .mjs拡張子だったら ES Modules、それ以外の .js 等であれば普通に CommonJS として判断しようとしています。

つまり、 CommonJS でも ES Modules でも両方共読み込ませたいモジュールを作る場合、 package.json に下記のように記述し、

{
  "name": "test",
  "version": "0.0.1",
  "description": "",
  "main": "./index", // 拡張子なしで定義する
}

index.mjsindex.js を定義します、片方には ES Modules 形式で書きます。

// index.mjs
export default class foo {
  //..
}

もう片方には CommonJS 形式で書きます。

// index.js
class foo {
  // ...
}
module.exports = foo;

こうすると読み込む側が .mjs 形式に対応している Node.js であれば、 先に .mjs で解決しに行きます。見つからなければ .js の方を解決する、という動きになります。

import で書く場合の path 解決方式

本筋からはそれますが、 import の重要なポイントなので記載しておきます。 ES Modules では Node.js が暗黙的にやっているようなスマートなパスの解決をしてくれない(現時点のローダーでは)ので気をつけましょう。

例えば、 require で書いた場合、 require('./foo') のように .js を削除して記載することが可能でした。

// ./foo.js を解決する
require('./foo');

import でモジュール参照解決をする場合、ES Modules の仕様としては暗黙的に .js を保管してくれたりしないので気をつけましょう。

// ./foo だけ参照解決する
// ./foo.js や ./foo.mjs や ./foo/index.js は参照解決しない
import './foo';

./foo のようにローカルパス付きで読み込む場合は必ず .js もしくは .mjs のように拡張子を付けて参照解決させる事になるでしょう。

import './foo.js';
import './bar.mjs';

ES Modules => CJS

これまでの説明だけでも分かりにくいと思うので例を上げて説明していきます。ここでは ES Modules から CommonJS で定義されたモジュールを読み込む場合です。ES Modules から CJS を「名前付きで」読み込んだ場合、 default というプロパティが入ります(これめっちゃ分かりにくい)。

// cjs.js
module.exports = {
  default:'my-default',
  thing:'stuff'
};
// es.mjs

// 名前付き(as baz)で ./cjs.js のファイルを import する
// bindings なので中の値は外から書き換えられない。
import * as baz from './cjs.js';
// baz = {
//   get default() {return module.exports;},
//   get thing() {return this.default.thing}.bind(baz)
// }
// console.log(baz.default.default); // my-default

// default のオブジェクトを import して foo にアサインする
import foo from './cjs.js';
// foo = {default:'my-default', thing:'stuff'};

// default プロパティを明示して読み込む
import {default as bar} from './cjs.js';
// bar = {default:'my-default', thing:'stuff'};

値を export して、 default の値としてアサインされる例:

// cjs.js
module.exports = null;
// es.mjs
import foo from './cjs.js';
// foo = null;

import * as bar from './cjs.js';
// bar = {default:null};

関数を export する例:

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

import * as bar from './cjs.js';
bar.name; // 'two' (関数名が取れる)
bar.default(); // 2 (default 関数に assign される)
bar(); // throws, bar is not a function

CJS => ES Modules

反対に CommonJS から ES Modules を読み込む時は下記のようになります。こちらは export default で export した場合は .default プロパティにアサインされます。

export default を利用する例:

// es.mjs
let foo = {bar:'my-default'};
// note:
export default foo;
foo = null; // これは import 側に影響しない、 export した時点の値が返る、何故なら binding じゃなくて値だから
// cjs.js
const es_namespace = require('./es');
// es_namespace ~= {
//   get default() {
//     return result_from_evaluating_foo;
//   }
// }
console.log(es_namespace.default);
// {bar:'my-default'}

export を利用する例:

// es.mjs
export let foo = {bar:'my-default'};
export {foo as bar};
export function f() {};
export class c {};
// cjs.js
const es_namespace = require('./es');
// es_namespace ~= {
//   get foo() {return foo;}
//   get bar() {return foo;}
//   get f() {return f;}
//   get c() {return c;}
// }

今のところの議論

ちょうど今盛り上がってるのには理由があって、仕様が DRAFT になって議論が始まった後に新しく Counter Proposal (反対提案) が書かれました。それが defense of dot js という Proposal です。

これは .mjs という拡張子で解決するのではなく、 package.json にフィールドを足すだけで解決させるようにしたいという Proposal です。

defense of dot js の内容

こちらの仕様では、基本的に完全な互換性を取るのは諦め、 ES Modules で読み込むことをベースとします。 package.jsonmain フィールドがある時だけはすべてのファイルが CommonJS で読み込まれます。これが基本的な仕様です。

明示的に ES Modules で読み込ませたければ package.jsonmodule フィールドでエントリーポイントを書きます。

// package.json
{
  "main": "index.js",
  "module": "module.js"
}

こうすると、 module フィールドがあれば Node.js は ES Module としてエントリポイントを見に行きます。古いバージョンの Node.js は main フィールドのエントリポイントを見るだけなので古いバージョンにも対応されます。

しかし、このやり方には問題が1つあります。 module 以外のエントリポイントを require から指定できません。例えば、 lodash とかでよく見る require('lodash/array') みたいな読み込み方ができません。そこでこれを解決するために modules.root というフィールドを利用します。

// package.json
{
  "main": "index.js",
  "module": "module.js",
  "modules.root": "lib"
}

上記のように"modules.root": "lib" フィールドがあると lib/* 以下を require から読み込めるようになります。つまり、 ES Modules で書いてあっても require('lodash/array') みたいな書き方ができるようになり、ある程度互換性を保てるようになります。

とはいえ、新しいバージョンで mainmodule も無い package.json では暗黙的には必ず ES Modules になってしまうので、かなり breaking changes です、その代わり拡張子での解決は要らないので、 .mjs などの拡張子を検討する必要はありません。なので、 "Defense of dot js" なわけです。

CommonJS と ES Modules 両方対応するなら?

基本的には ES Modules に傾けるのがこの仕様のポイントですが、人気のあるモジュールはそうはいきません。 ES Modules と CommonJS の両対応させる必要のあるモジュールは存在するでしょう。この時は諦めて transpile して、 ES Modules => CommonJS のファイルも用意しておきます。 transpile した JavaScript も一緒に package に入れておきます。 main フィールドと module フィールドを書いておけば古い Node.js と新しい Node.js で読み込み先を変えてくれます。

Bradley Meck仕様との違い

Defense of dot js 側は将来的に ES Modules に全て揃えよう という姿勢です。互換性をある程度損なっているし、それが起こす混乱はある程度受け入れる考えです。それに対して Bradley Meck 氏の仕様は互換性重視です。あくまで今のCommonJSと相互運用性を取るというのを主軸に添えて語られています。拡張子で ES ModulesなのかCommonJSなのかが変わるというのは言い換えれば一つ一つのファイル単位で ModuleなのかCommonJSなのかを切り替えられる柔軟な仕様です。過渡期は .mjs と .js が混じるかもしれませんが、将来的にユーザーの判断でどっちがデファクトになるかによっては .mjs だけ残る可能性はあります。

hard choice (.mjs vs package.json)

これに対してさらに Counter で Bradley Meck 氏はブログを書いています。

medium.com

なんで .mjs を選んだのか、という理由が書いてあります。 ブログの基本的な論調としてはみんな .js という拡張子に頼りすぎているという話です。

JavaScript には様々な"方言" があります。CommonJS, UMD, AMD、これらの全ての JavaScript は全て .js になってます。

ES Modules というのはもうそれ単体で評価可能な Script ではないし、パースして他のファイルをロードしてチェックするという処理が必要な以上、もういっそ拡張子ごと変えるというアイデアも分からなくはないです。しかも Module だと暗黙的に strict mode で動くので、普通のScript とは別物だと思ったほうがいいです。

他の言語の例を挙げるなら、 .plPerl スクリプト) と .pmPerl モジュール)のように拡張子を変える事を是とする文化もあります。また、ファイルを開く前に拡張子だけ見れば Module なのか Script なのかが分かるので、人間にとっても意味はあります。

この仕様はまだまだ議論中です。また今週の TSC ミーティングで議論があるでしょう。先週もありましたが、話はまとまりきらずに終わりました。今のうちに何かこうしたいという意志があれば issuePR に書くことをおすすめします。

まとめ

  • ES Modules の現時点の状況
  • ES Modules を Node.js はどうするのか
  • ES Modules on Node.js Proposalの詳細
  • Counter Proposal である Defense of dot js の話

*1:(正確には CommonJS の仕様からは大分外れてます。今やあれが CommonJS という事になっちゃってますが・・・)