Dual Package Hazard

この記事は Node.js Advent Calendar 2019 の 11 日目の記事です。

qiita.com

今回は全 Node.js で ES Modules を利用するユーザーが知っておくべき Dual Package Hazard について紹介します。

ESModules がフラグ無しでサポートに。

これまでは ES Modules は --experimental-modules フラグが無いと使えませんでしたが、 フラグ無しで Node.js v13.2.0 から使えるようになりました。ES Modules については CodeGrid の記事で詳しく書いているのでそれを一読していただけると理解がスムーズになると思います。逆に読んだ方は Conditional Exports / Dual Package Hazard の節まで飛ばしてもらって構いません。

www.codegrid.net

Node.js における ES Modules のおさらい

ES Modules と従来の CommonJS ベースの Script とは別なものです。 ES Modules は何も宣言しなくても Strict Mode になり、予約語も global に定義されている変数も異なります。Node.js ではこれらの従来の CommonJS と ES Modules を分けるためにいくつかの方法を提供しています。

  1. ファイル拡張子が .mjs になっていること
  2. ファイル拡張子が .js になっている、もしくは拡張子がない場合、最も直近の親モジュールの package.json に記載されている type フィールドが module になっていること
  3. --eval--print もしくは Node の標準入力から渡されるようなケースでは --input-type=module をつけること

特にこれまでと大きく異なるのは、 ファイル拡張子です。 これまで (Node v10まで) はデフォルトで 1. のファイル拡張子が .mjs しか認めていませんでしたが、これからは .js でも package.json のフィールドに type: “module” の記述があれば ES Module として読み込まれます。

また、 ES Modules として宣言するのと同様の宣言が CommonJS でも可能です。.cjs 拡張子をつける、 type フィールドに commonjs と入れるなどの対応をすれば CommonJS として宣言することが可能です。

以下にフローチャートを記述します。

f:id:yosuke_furukawa:20191209230938p:plain
ES Modulesかどうか

ES Modules の相互運用性に関して

現在の Node.js には ES Modules と CommonJS の2種類の module があります。これらの module が混在する場合はどうなるでしょうか。 Node.js はどちらであっても透過的に扱えるように互換性を取っています。この「透過的に扱えるように互換性を取ること」を “Interoperability(相互運用性)” と呼びます。

この相互運用性を保つため、 ES Modules からでも CommonJS をimportできるようになっています。CommonJS から ES Modules を呼ぶことも可能ですが、詳細は後述します。

import するとき

以下のサンプルは ES Modules から module を import する時の方法です。

// このファイルは foo.mjs として定義され、拡張子が `.mjs` になっている。

// すべての Node.js のモジュールは ES Modules としても import できるようになっている。
import fs from “fs”;

// 名前付きで import する場合は以下のようになる。
import { readFile } from “fs”;

// ES Modules はもちろん import できる。`.js` であっても `.mjs` であっても、特別な記述がなければ import 時は ES Modules としてロードされる。
import foo from “./foo.js”;

// npm module などのモジュールもロードできるが、type フィールドがmodules じゃない場合は commonjs としてロードされる。
import _ from “lodash”;

// 無理矢理呼んだ場合であっても、 package.json の type フィールドが `module` ではないため、 commonjs になる。
import _ from “./node_moduels/lodash/index.js”;

// commonjs としてロードしてほしい場合は拡張子を .cjs にする。
import bar from “bar.cjs”;

CommonJS から ES Modules を呼びたい場合は、Dynamic Import 構文( import() )を使います。

// このファイルは CommonJSとして定義されている。
const _ = requrie(“lodash”);

// CommonJS から ES Modules を使う時は Dynamic Import で構文として使う。
import(“./foo.js”).then((foo) => {
  // foo
});

相互運用性をサポートしているため、CommonJS から ES Modules を呼ぶことも、 ES Modules から CommonJS を呼び出すことも可能です

ES Modules と CommonJS 両方に対応した package を作る

ES Modules 形式と CommonJS 形式の両方に対応した package を作る場合は以下の処理が必要です。両方に対応すると、 importrequire の両方でロードするモジュールを作ることが可能です。

  • 標準のロード形式を ES Modules 形式にするか CommonJS 形式にするか選択する。ES Modules 形式にする場合は、 package.jsontype フィールドを module にする。 CommonJS 形式にする場合は type フィールドを commonjs にする。
// ./node_modules/es-module-package/package.json
{
  "type": "module", // ES Modules 形式
  "main": "./src/index.js"
}
// ./node_modules/es-module-package/package.json
{
  "type": "commonjs", // CommonJS 形式
  "main": "./src/index.js"
}
  • 標準のロード形式を CommonJS 形式にした場合、ES Modules から読み込むエントリポイントを作り、そこから読み込むようにドキュメント等で記載が必要です。 ES Modules 形式にしたとしても同様で、 CommonJS から読み込むエントリポイントを作る必要があります。
// ES Modulesで読み込む用の endpoint から読み込んでもらう
import foo from “foo/esm.js”;
  • もしも import 時の endpoint をカスタマイズして使わせたい場合は package exports の機能を使います。package exports はファイルパスに対して別名をつける機能です。
 // ./node_modules/es-module-package/package.json
{
  "exports": {
    "./submodule": "./src/submodule.js"
  }
}
// package.json に書いてある exports をパスとして使える。
import submodule from 'es-module-package/submodule';

これらの機能をうまく使えば、見た目上は同じく使えます。 import する際は import submodule from “es-module-package/submodule” で、 require する際は const submodule = require(“es-module-package”); で使えます。

ただしこれだと読み込み先のモジュールの名前をドキュメントなりで教える必要があります。そこで v13.2.0 からの experimental な機能として追加された Conditional Export という機能を使うとこの状況を改善できます。

Conditional Export

読み込み先のモジュールのエントリーポイントを用意しておき、条件に応じて切り分ける機能です。

{
  "name": "test",
  "type": "module",
  "main": "./main.mjs",
  "exports": {
    ".": {
      "require": "./main.cjs",
      "default": "./main.mjs"
    }
  }
}

こうしておくと、 require("test") で呼ばれたときは main.cjs が呼ばれ、 import "test" で呼ばれたときは main.mjs が呼ばれます。 まだ実験的な機能なので使うためには --experimental-conditional-exports フラグ付きで呼び出す必要があります。

$ node --experimental-conditional-exports foo.js

このように ES Modules でも CommonJS でも呼ばれるようにする package のことを Dual Package と呼びます。

Dual Package Hazard

前置き長かったですが、やっと本編です。

Dual Package には特定のバグのリスクが伴います。このリスクのことを Dual Package Hazard と呼んでいます。きちんと理解して Dual Package 対応を進めていきましょう。

Dual Package はES Modules と CommonJS で一見同じものをロードしているようで、実はファイルが違います。この『ファイルが違う』、という事が下手をするとインスタンスの違いを引き起こし、結果として予期しない問題になり得ます。

Dual Package Hazard
Dual Package Hazard

例を挙げて解説します。 test という Dual Package を用意します。これは import から呼ばれたときは main.mjs を呼び出し、 require から呼ばれた時は main.cjs を呼び出します。

// node_modules/test/package.json
{
  "name": "test",
  "type": "module",
  "main": "./main.mjs",
  "exports": {
    ".": {
      "require": "./main.cjs",
      "default": "./main.mjs"
    }
  }
}

package の 規定となる class を定義しておきます。これを core.cjs として定義します。

// node_modules/test/core.cjs

module.exports = class A{};

ESModules で定義されてる main.mjscore.cjs をロードし、 instance 化した後で export で公開します。

// node_modules/test/main.mjs
import A from "./core.cjs";
export default new A();

CommonJS で定義されてる main.cjs の方も同様に class A を instance 化した後で、 module.exports で設定します。

// node_modules/test/main.cjs
const A = require("./core.cjs");
module.exports = new A();

同じようなものを export してますが実体が異なります。これを同じノリで使おうとするとハマる可能性があります。

// foo.mjs
// node --experimental-conditional-exports foo.mjs
async function main() {
  const t = require("test");
  t.core = false;
  const t2 = await import("test");
  console.log(t); // A { core: false }
  console.log(t2.default); // A { } !! 変更が反映されていない !!
}

main();

この小さなパッケージでは間違えることは少なそうですが、例えば plugin 形式で context を引き継ぐような webpack, gulp, grunt のようなツールでは、ES Modules と CommonJS が同じインスタンスを指していない事で plugin 同士で context が引き継がれず思いも寄らない不具合を引き起こしかねません。

このように同じものをロードしているようで、実は実体が違うものがロードされた結果、思いも寄らない不具合を引き起こすリスクを Dual Package Hazard と呼んでいます。 Node.js の package は特に pluggable な作りのものが多いので、気をつけて使う必要があります。

Babel や esm などの transpile するやり方は CommonJS に変換して使っているものが多いため、実際にはこの問題は起きません。 Node.js が両方で読み込めるように相互運用性を高めた結果、気をつけて利用しないと、発生しうるのがこの不具合です。

Dual Package Hazard を局所化する

これに対応するのは実はそこまで難しくありません。同じインスタンスを指すようにしてしまえばよいのです。この例だけで言えば、 core.cjs からロードしたものを instance にして返却するのではなく、 instance 化済みの状態で export し、 singleton にするという手があります。 

// node_modules/test/core.cjs
class A{};
module.exports = new A(); // singleton にして公開する

このようになるべく状態を作るところを一箇所にまとめ、エントリーポイントごとに状態を共通化させる事が重要です。

もしくは、 Node.js からは CommonJS 形式でのロードに集中し、 browser などの実行環境で ES Modules 形式を使うという消極的な戦略にしてしまうことも可能です。

// node_modules/test/package.json
{
  "name": "test",
  "type": "commonjs",
  "main": "./main.cjs",
  "exports": {
    ".": {
      "require": "./main.cjs",
      "browser": "./main.mjs", // browserっていう実行環境でのみ main.mjs を読み込ませる(何らかのツールでこの情報を参照してもらう)
      "default": "./main.cjs" // その他の状況では commonjsにする
    }
  }
}

この場合は、Nodeとbrowserで同じスタイルで読み込まれますが、そもそも実行環境がブラウザとNodeで異なるため、 CommonJS と ES Modules が同じ環境からロードされることがありません。※ browser propertyはデフォルトではブラウザが見てくれるわけではないのでなんらかのツールのアシストが必要になります。

Dual Package 自体は便利ですが、このような混乱があることを忘れずに。色々な回避策はもちろんありますが、まだベストプラクティスと呼べるほどのものが無いのが現状です。

まとめ

  • Node.js ES Modules はv13.2.0からフラグ無しで使えるようになりました。
  • ES Modules からも CommonJS からも両方から使えるように相互運用性をサポートしています。
  • ただし、 ES Modules と CommonJS の両方に対応した Dual Package を作る時は注意が必要です。
  • お互いの instance が違うことで ES Modules と CommonJS のどちらかだけでは起き得なかった不具合、 Dual Package Hazard が発生します。
  • これを直そうとするといくつかのトレードオフが発生します(singletonにする、環境ごとで切り分けるなど)
  • まだベストプラクティスはない状況です。積極的に使ってフィードバックするもよし、消極的にまだ運用を控えるのも良いでしょう。

参考資料

nodejs.org

github.com