🧩

Node.jsのネイティブES Modulesサポートが抱える問題を解決するBabelプラグインを書いた

2021/12/26に公開

babel-plugin-node-cjs-interop というパッケージを作ったのでその紹介です。 (GitHub)

何が問題か

Node.jsのネイティブES ModulesサポートとBabelやTypeScriptのES Modulesサポートを併用したときに問題が起きます。

ESMとCJS

JavaScriptには標準のモジュールシステム (ES Modules, ESM) がありますが、ESMの策定前に先だっていくつかのコミュニティー定義のモジュールシステムが存在していました。そのうちNode.jsを中心として使われていたのがCommonJS Modules (CJS) です。そのNode.js界隈でもESMへの移行が進んでいます。

移行にあたって問題になることのひとつが、ESMとCJSのエクスポートモデルの違いです。

  • ESMでは、モジュールは0個以上の名前つきエクスポートを定義することができます。それぞれのエクスポートには任意の値を入れることができます。
    • デフォルトエクスポートは default という名前の名前つきエクスポートとして実現されます。
  • 一方CJSでは、モジュールは単一の名前のないエクスポート (デフォルトエクスポート) を定義します。エクスポートには任意の値を入れることができます。
    • 名前つきエクスポートはデフォルトエクスポートされたオブジェクトのプロパティとして実現されます。

(エクスポートされた値の更新の振舞いなど他にも違いはありますが、本稿では取り扱いません)

ESMとCJSの互換ルール

この違いを吸収するための互換ルールとして、多くの処理系 (Node.js, Babel, TypeScript, Webpackなど) が以下のルールを実装しています。

  • ESMからCJSをインポートするとき、
    • default という名前のインポートはCJSのエクスポートオブジェクトを指します。
    • それ以外のインポートはCJSのエクスポートオブジェクトのプロパティを指します。
  • CJSからESMをインポートするとき、
    • インポートはESMの名前空間オブジェクト (名前つきエクスポートを集めたオブジェクト) を指します。

これによりESMとCJSの意図をある程度対応づけることができますが、1つ問題があります。それは、ESMの default 名前つきエクスポートがround-tripしない (CJSを経由すると、元の値に戻ってこない) という点です。

BabelやTypeScriptなど、ESMからCJSへのトランスパイル機能を持つ処理系ではこのことが問題になります。ESM同士のインポートをCJSの枠組みに埋め込むのは、CJSを経由してインポートするのと同様の効果があるからです。上のルールをそのまま実装したのでは、ESMの意味論を忠実に埋め込むことができません。

__esModule

そこで、BabelやTypeScriptでは以下の追加ルールを実装しています。

  • ESMからCJSをインポートするとき、CJSのエクスポートオブジェクトが __esModule というプロパティを真値として定義している場合は default を特別扱いせず、他の名前つきインポートと同じように扱う。

そして、ESMからCJSにトランスパイルするときに __esModule を定義しておきます。これでESMの振舞いをCJSで意図通りシミュレートすることができます。

Node.js

ところが、Node.jsのESMサポートはこの追加ルールを実装していません。Node.jsはESMを直接読み込むので、CJSとESMを区別するための情報をはじめから持っています。ESMからCJSをインポートしたときの挙動とESMからESMをインポートしたときの挙動はじめから別のものであり、別途対応する必要はないわけです。そして、Node.jsのESMサポートはあくまでトランスパイラのものとは独立した機能ですから、原理的にはBabelに配慮する必要はありません。

もちろん、 __esModule にまつわる追加ルールを実装することが検討されなかったわけではないようです。しかし、 __esModule の実装には以下のようにいくつかの懸念点があり、ひとつのプラットフォームであるNode.jsで継続的にサポートする機能として導入するには至っていません。

  • __esModule がどのように振る舞うかの詳細はトランスパイラの実装ごとに少しずつ異なる。
  • トランスパイラの出力と手書きコードが混在するようなコードでは __esModule による判定が偽陽性になるケースもある。
  • 現在の挙動に依存するコードもあり、実効性のある移行パスを整備するのは容易ではない。

そのため、Node.jsのネイティブESMからBabel(等)によりESMからトランスパイルされたモジュールをインポートしたときにデフォルトインポートが意図した値にならないという問題が起きます。

// a.cjs

"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.default = greet;

function greet() {
  console.log("Hello, world!");
}

// これは以下のソースから生成される:
// export default function greet() {
//   console.log("Hello, world!");
// }
// b.mjs

import greet from "./a.cjs";

greet();
$ node ./b.mjs
./b.mjs:3
greet();
^

TypeError: greet is not a function
    at ./b.mjs:3:1
    at ModuleJob.run (node:internal/modules/esm/module_job:185:25)
    at async Promise.all (index 0)
    at async ESMLoader.import (node:internal/modules/esm/loader:281:24)
    at async loadESM (node:internal/process/esm_loader:88:5)
    at async handleMainPromise (node:internal/modules/run_main:65:12)

どう解決するか

もともとBabelがESMをCJSに変換するときに、以下のようにrequireのラッパーが生成されます。

"use strict";

var _mod = _interopRequireDefault(require("mod"));

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

console.log({
  foo: _mod.default
});

// import foo from "mod";
// console.log({ foo });

これと同様の処理を、CJSへの変換はせずに行うことを考えます。ただし、場合分けが2つではなく3つあります。

  • 純粋なCJSモジュールの場合は何もしない (Node.jsの挙動と期待する挙動は同じ)
  • __esModule を持つCJSモジュールの場合は、デフォルトインポートを読み替える
  • ネイティブのESMモジュールの場合は何もしない

そのためには以下のような変換をすればいいことになります。

import fooOrig from "mod";
const foo = _interopImportCJSDefault(fooOrig);

function _interopImportCJSDefault(d) {
  // d.__esModule が真のときはおそらくBabel由来なので、デフォルトインポートの変換が必要
  return d && d.__esModule ? d.default : d;
}

ただし、この方法は以下の問題があります。

  • せっかく変換をはさんでいるのに、エクスポート変数の更新を受け取れない。
  • 名前空間インポートの変換 (interopRequireWildcard 相当) を別途実装する必要がある。

そこで本プラグインでは別の解決方法を実装しています。まず、ESMでは名前つきインポートは名前空間インポートで置き換えることができます。そこであえて全てを名前空間インポートで置き換えてしまいます。 (メソッド呼び出しなど、注意して変換する必要があるパターンもあります)

import * as ns from "mod";
console.log({ foo: ns.default });

// import foo from "mod";
// console.log({ foo });

そして、この名前空間インポートして得られた変数自体に変換を挟みます。

import * as nsOrig from "mod";
const ns = _interopImportCJSNamespace(nsOrig);
console.log({ foo: ns.default });

function _interopImportCJSNamespace(ns) {
  return ns.__esModule && ns.default && ns.default.__esModule ? ns.default : ns;
}

このように名前空間インポートごと変えてしまうということは、当然 default 以外の名前つきインポートも異なる情報源から値を取得することになります。最初に掲げたCJSとESMの互換ルールを読むとわかりますが、 default 以外の名前つきインポートは ns.foons.default.foo のいずれからも取得できるため、実はこのようにしても問題ありません。実は、この変換によって「エクスポート変数があとから更新されたときの変更を観測できる」という利点 (ESMの意味論が復活する) もあります。

パッケージを検出する

上に挙げた方法により、基本的には問題のある場合だけ修正を適用することができます。しかし、変換を適用することには以下のような懸念もあります。

  • tree shakingが効きにくくなる。
  • 存在しない名前つきインポートを使おうとしてもエラーにならない。
  • ……

そのため、本プラグインはインポート対象のパッケージごとに変換を有効化する形で機能を提供しています。ユーザーは明示的にパッケージの一覧を与える必要があります。

とはいえ、どのパッケージに変換を適用する必要があるのかを一覧するのは面倒ですし、知識も必要です。そこでどのパッケージが対象となりうるか、ある程度推定してリストアップしてくれるコマンドも作りました。それがnode-cjs-interop-finderです。

npx node-cjs-interop-finder

仕組みは以下の通りです。

  • dependenciesとdevDependenciesの一覧を取得する。
  • 各パッケージのトップレベルモジュールをresolveを使って解決する。
  • 解決されたファイルをパースして、以下のようにモジュール種別を推定する。
    • import/exportがあればESM。
    • exports.__esModule への代入が検出されればBabelによるトランスパイル。 (→本プラグインの対象となりうる)
    • それ以外の場合は、純粋なCJS。

まとめ

  • Node.jsのESMサポートとBabelのESMサポートには互換性がなく、いくつかの場面で問題になる。
  • Native ESM化時に本Babelプラグインを適用することで、この互換性問題を解消することができる。

Discussion