技術探し

JavaScriptを中心に記事を書いていきます :;(∩´﹏`∩);:

Node.jsのECMAScript Modulesの紹介

www.meetup.com

ここで話したことの日本語版です。

blog.hiroppy.me

ECMAScript Modulesとは?

JavaScriptには、AMDUMD、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に対応しているブラウザは、この属性がある場合、この行を無視します。

実装状況

f:id:about_hiroppy:20190427192502p:plain

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される
    • どこに書いても宣言がモジュールの最初で行われます
    • これは関数と同じ挙動です
  • トップレベルのthisundefinedになる
  • モジュールは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

詳しくは以下の記事をみてください。

blog.hiroppy.me

blog.hiroppy.me

f:id:about_hiroppy:20190427185658p:plain

一言で言うと、一番近くの親の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というキー名は変わる可能性があり、現在議論中です。

github.com

この解決方法は、すでに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となっています。
createRequireFromPathmoduleの中に存在しており、ESM内でもrequire関数を生成することができます。

この2つにより、CJSで行えたことをESMでも行えるようにします。

明示的

CJSでは、ファイル名のindexと拡張子の.js, .node, .jsonを省略することができます。
しかし、ESMではこの仕様は存在せず、ブラウザと共通コードで動くことをNode.js側も望んでいるため、今後は省略できなくなります。

フラグは、--es-module-specifier-resolutionで、explcitnodeを持ち、デフォルトは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を呼べるようにするプロポーザルが進んでいるからです。

github.com

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

github.com

サマリー

  • 近くの親のpackage.jsontype:moduleに依存して、ファイルはESMかCJSになる
  • トップレベルではCJSはESMを呼べない
  • CJSで使えたいくつかの変数がESMでは使えない
  • フラグが外れるゴールはNode12のLTSがリリースされる2019/10の予定

サンプルコード

github.com

全文

medium.com