Node.jsのECMAScript Modulesの紹介

2019 / 04 / 27

Edit
🚨 This article hasn't been updated in over a year
💁‍♀️ This post was copied from Hatena Blog

アイルランドのイベントで話したことの日本語版です。

Login to Meetup | Meetup Find groups that host online or in person events and meet people in your local community who share y...

初めて海外で登壇してきました - 技術探し ダブリンのNode.js meetupで登壇してきた話

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

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

Node.js Package Mode について - 技術探し Node.js Package Mode について

Node.jsの新しいモジュール方式の実験的導入 - 技術探し Node.jsの新しいモジュール方式の実験的導入の解説

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

Rename configuration options to avoid "type" term · Issue #312 · nodejs/modules We have seen this in a few places, but it seems we should discuss why to use the term &quot;type&quo...

この解決方法は、すでに 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 を呼べるようにするプロポーザルが進んでいるからです。

JSON modules · Issue #4315 · whatwg/html Hey All, I&#39;d like to explore support for importing json, similar to how Node.js currently suppor...

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
// 🙆‍♀️ESM -> CJS
// 🙅‍♀️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
Build software better, together GitHub is where people build software. More than 94 million people use GitHub to discover, fork, and...

サマリー

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

サンプルコード

GitHub - hiroppy/node-esm-example Contribute to hiroppy/node-esm-example development by creating an account on GitHub.

全文

Introduce ECMAScript Modules of Node.js I talked about ECMAScript Modules at Dubin Node.js meetup. I made comments to deepen your understand...