Node.jsのECMAScript Modulesの紹介
ここで話したことの日本語版です。
ECMAScript Modulesとは?
JavaScriptには、AMDやUMD、CJSのような多くのモジュールシステムがあります。
ECMAScript Modulesは当初ES2015に入る予定でした。
さて、ESMの仕様はWHATWGとTC39が管理しますが、役割が違います。
TC39はESMのシンタックスやJSのルールを管理します。
例えば、モジュールはstrict modeになるとか、this
の扱いとか。
しかし、モジュールの読み込みに関しては、WHATWGが管理します。
理由は、ブラウザとNode.jsの間でこれは処理系依存になり、異なるからです。
HTML
<!-- ESMをサポートしているブラウザ --> <script type="module" src="esm.js"></script> <script nomodule src="fallback.js"></script> <!-- ESMをサポートしていないブラウザの解釈 --> <!-- <script type="module" src="esm.js"></script> --> <!-- type:moduleは存在しないため無視 --> <script src="fallback.js"></script> <!-- `nomodule`属性だけ無視して実行(type:text/javascript) -->
script
タグにtype="module"
を指定することにより、ブラウザにそのファイルがESMだということを伝えます。しかし、ESMをサポートしていないブラウザはその属性を識別できないため実行しません。
なので、nomodule
を使うことにより、ESMをサポートしていないブラウザに対応します。この場合、type
自体は変更していないため、サポートしてないブラウザはnomodule
属性を無視してただのscript
として実行します。また、ESMに対応しているブラウザは、この属性がある場合、この行を無視します。
実装状況
IE以外はサポートされています。
ただ、現状はパフォーマンス的にもバンドルするべきです。
ESM
import defaultExport from 'module-name'; import * as name from 'module-name1'; import { name } from 'module-name2'; import { export as alias } from 'module-name'; import 'module-name'; export { name as name2 }; export let name1 = '1', name2 = '2'; export function FunctionName() {} export class ClassName {} (async () => { const { default: foo } = await import('module-name3'); })();
多くの人がすでに使っていると思います。
特徴
import
/export
はトップレベルのみでしか宣言できない- これにより実行前にエラーを発見することが可能です
- もし非同期で取得したい場合は、dynamic importを使ってください
import
はhoistingされる- どこに書いても宣言がモジュールの最初で行われます
- これは関数と同じ挙動です
- トップレベルの
this
はundefined
になる - モジュールはstrict modeになる
ESM in Node.js
現在は、stability:1(実験的)のフェイズに存在します。
なぜ時間がかかったのか?
Node.jsには2つのブラウザにはない大きな問題がありました。
- 読み込むときに
type
みたいな属性がつけれないので、読み込まれるファイルがESMなのかCJSなのかわからない - すでにCJSというモジュールシステムがNode.jsには存在する
- 互換を維持しなければならない
どのようにNode.jsではESMとCJSを判断し解決するか?
.mjs
?
多くの人は過去にこのファイル名を聞いたことがあるでしょう。
たしかに、拡張子で判断することは簡単です。.mjs
であればそのファイルはESMで書かれているということです。
しかし、今後、ESMがデファクトスタンダードになることは容易に想像でき、.mjs
という拡張子にしていくことが本当にいいかというと疑問です。
我々はできれば、.js
という拡張子を変えたくありません。(また、この問題はフロントエンドにも影響します。)
そこで我々は別の解決策を模索しました。
Package Mode
詳しくは以下の記事をみてください。
一言で言うと、一番近くの親のpackage.jsonによってファイルのモジュールシステムが確定します。
/** ├── esm │ ├── cjs │ │ ├── index.js │ │ └── package.json (commonjs is used because type is not specified) │ └── index.js ├── package.json (type: module) └── root.js */ // ./root.js ----------------------------------------------------------------- 1 import './esm/index.js'; import './esm/cjs/index.js'; console.log('root.js :', typeof module !== 'undefined' ? 'cjs' : 'esm'); // ./esm/index.js ------------------------------------------------------------ 2 // Refers to the closest parent's package.json. console.log('esm/index.js :', typeof module !== 'undefined' ? 'cjs' : 'esm'); // ./esm/cjs/index.js -------------------------------------------------------- 3 console.log('esm/cjs/index.js:', typeof module !== 'undefined' ? 'cjs' : 'esm');
$ node --experimental-modules root.js esm/index.js : esm # 2 esm/cjs/index.js: cjs # 3 root.js : esm # 1
package.json
{ "type": "module" // or `commonjs`, the default is `commonjs` }
破壊的変更になるため、デフォルトはcommonjsとなります。
ESMとして動かしたい場合は、module
を指定する必要があります。
type
というキー名は変わる可能性があり、現在議論中です。
この解決方法は、すでにNode.jsのコアに入っているため変わることはないと思います。
しかし、プロパティ名等は変わる可能性が高いです。
.mjs
と .cjs
さて、このルールはすべてのファイルに適応されます。
しかし、特定ファイルだけこのルールの対象にしたくない場合があります。
その時は、拡張子をしてしてください。(.js
はこのルールに準拠します)
// always read as CJS import './file.cjs'; // always read as ESM import './file.mjs';
ルール
WHATWG URLに準拠する
import './foo.js'; import 'file:///xxxx/foo.js'; // dynamic import (async () => { const baseURL = new URL('file://'); baseURL.pathname = `${process.cwd()}/foo.js`; const foo = await import(baseURL); console.log(foo); // [Module] { default: 'hello' } })();
相対パス、絶対パス、パッケージ名、file
プロトコルの指定が可能です。
使用できない変数
以下の変数は、CJSでは使えますが、ESMでは使えません。
// The following variables don't exist in ESM. console.log(typeof require); console.log(typeof module); console.log(typeof exports); console.log(typeof __dirname); console.log(typeof __filename);
そのかわりにESMでは以下の値で代用します。
// Get a path info like __dirname and __filename. console.log(import.meta); // [Object: null prototype] { // url: 'file:///Users/xxxx/index.js' // } // Create `require` function. import { createRequireFromPath } from 'module'; import { fileURLToPath } from 'url'; // ./ const require = createRequireFromPath(fileURLToPath(import.meta.url)); // ./cjs/index.js require('./cjs/index.js');
import.meta
は現在、tc39のstage-3となっています。
createRequireFromPath
がmodule
の中に存在しており、ESM内でもrequire
関数を生成することができます。
この2つにより、CJSで行えたことをESMでも行えるようにします。
明示的
CJSでは、ファイル名のindex
と拡張子の.js
, .node
, .json
を省略することができます。
しかし、ESMではこの仕様は存在せず、ブラウザと共通コードで動くことをNode.js側も望んでいるため、今後は省略できなくなります。
フラグは、--es-module-specifier-resolution
で、explcit
とnode
を持ち、デフォルトはexplicit
です。
しかし、多くの存在するファイルは省略していると思うので、node
を明示的に指定することでしょう。
// strict/index.js import './foo/index.js'; // --es-module-specifier-resolution=explicit import './foo'; // --es-module-specifier-resolution=node
$ node --experimental-modules --es-module-specifier-resolution=node ./strict/index.js $ node --experimental-modules ./strict/index.js # default is `explicit`
JavaScriptのみ
ESMはJavaScriptのみの実行を許可します。
CJSでは、JSON(.json
)とnative modules(.node
)が実行できましたが、ESMでは実行できません。
もし、実行したいのであれば、ESM内でmodule.createRequireFromPath()
を使い、require
関数を作ることができます。
しかし、JSONだけは、--experimental-json-modules
フラグを持っています。
今現在、ブラウザのESMでもJSONを呼べるようにするプロポーザルが進んでいるからです。
CJSからESMへの呼び出しはできない
// // Reading ESM at top-level is prohibited. // import foo from './esm/foo.js'; // invalid // // An error occurs because the read file is written as ESM. // // `require` expects read file as CJS // require('./esm/foo'); // // // export default typeof module !== 'undefined' ? 'cjs' : 'esm'; // // ^^^^^^ // // SyntaxError: Unexpected token export console.log('root.js:', typeof module !== 'undefined' ? 'cjs' : 'esm'); // cjs (async () => { const { default: foo } = await import('./esm/foo.js'); console.log('foo.js :', foo); // esm })(); // Conclusion // 🙆<200d>♀️ESM -> CJS // 🙅<200d>♀️CJS -> ESM (excluding dynamic import)
このファイルはCJSで書かれています。
トップレベルでimport
を呼んでも、CJSにはそのシンタックスが存在しないため、エラーとなります。
しかし、dynamic importのみは許可されています。
結論として、CJSはESMをトップレベルでは呼べないが、dynamic importを使えば、ESMを呼び出せ、ESMはCJSもESMも呼べます。
ロードマップ
- CJS/ESMの両パッケージ対応(現在は、typeで一つしか絞れないため)
require
の簡潔さ(module.createRequireFromPath
めんどい)- package path maps
- automatic entry point module type detection
サマリー
- 近くの親のpackage.jsonの
type:module
に依存して、ファイルはESMかCJSになる - トップレベルではCJSはESMを呼べない
- CJSで使えたいくつかの変数がESMでは使えない
- フラグが外れるゴールはNode12のLTSがリリースされる2019/10の予定