Node.jsに入る新しいCJSからESMへの読み込み方法の紹介

2024 / 03 / 17

Edit

新しくCJSとESMの間での解決方法が変わる提案が出てきました。 まだマージされてませんが、すでに複数の承認があり、この方針から変わることはないように見えるので紹介したいと思います。

module: support require()ing synchronous ESM graphs by joyeecheung · Pull Request #51977 · nodejs/node Summary This patch adds require() support for synchronous ESM graphs under the flag --experimental-r...

新しい提案

この仕組みを利用する場合、--experimental-require-moduleフラグが必要となります。

以下は、わかりやすいようにpackage typeを指定せずにデフォルトはCJSで行います。

// cjs.js (entry point)

"use strict";

// !!! 今までこれが行えなかった !!!
const esm = require("./esm.mjs");

console.log(esm);
// [Module: null prototype] {
//   default: 'default',
//   named: [AsyncFunction: named]
// }
// esm.mjs

export async function named() {
  return "named";
}

const defaultValue = "default";

export default defaultValue;

このように今までできなかったCJSからESMをrequire経由で入れれるようになり、後述する既存の仕組みを破壊せずにシンプルに解決することができます。

しかし、Top Level Awaitだけは引き続き、インポートできない点に注意です。 requireはあくまでも同期な関数なので、そのファイル自体を非同期に変えてしまうTLAはサポートされません。(requireに関わらずCJS自体がTLAをサポートしてません)

// esm.mjs
export async function named() {
  return "named";
}

await named();
node:internal/modules/esm/module_job:290
    this.module.instantiateSync();
                ^

Error: require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.
    at ModuleJobSync.runSync (node:internal/modules/esm/module_job:290:17)
    at ModuleLoader.importSyncForRequire (node:internal/modules/esm/loader:301:16)
    at Object.loadESMFromCJS [as .mjs] (node:internal/modules/cjs/loader:1289:32)
    at Module.load (node:internal/modules/cjs/loader:1238:32)
    at Module._load (node:internal/modules/cjs/loader:1054:12)
    at Module.require (node:internal/modules/cjs/loader:1263:19)
    at require (node:internal/modules/helpers:179:18)
    at Object.<anonymous> (/Users/cont-y-hiroto/node/a/index.js:3:13)
    at Module._compile (node:internal/modules/cjs/loader:1420:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1498:10) {
  code: 'ERR_REQUIRE_ASYNC_MODULE'
}

このように新しくエラーコードとして、ERR_REQUIRE_ASYNC_MODULEが追加されました。

PRでJoyeeが書いている通り、TLAは基本的にエントリーポイントでしか利用されず、importされるケースは殆ど無いため致命的な問題ではないと判断されました。 TLAの場合は、引き続きdynamic importを利用してください。

This PR tries to keep it simple - only load ESM synchronously when we know it’s synchronous (which is part of the design of ESM and is supported by the V8 API), and if it contains TLA, we throw. That should at least address the majority of use cases of ESM (TLA in a module that’s supposed to be import’ed is already not a great idea, they are more meant for entry points. If they are really needed, users can use import() to make that asynchronicity explicit).

一言でまとめると今後は、CJSを利用する際にimportされるファイルがESMかどうかをほぼ気にせずにrequireを書けるようになります。

現行の仕組み

今日まで、.jsファイルにおいてモジュールが変動的に変わる点、またCJSとESMの相互運用性が難しい点がありました。

モジュールの決定

以下の条件でそのファイルのモジュールは決定され、importする場合はそれに準拠した方法で行わないと動きません。

Package Type (旧 Package Mode) での決定

package.jsontypeを追加すると、一番近くの親のpackage.jsonによってファイルのモジュールシステムが確定します。 デフォルトは互換を保つため、CJSとなっており、ESMを利用したい場合は、type:moduleを追加する必要があります。

pacakge type flow

詳しくは以下の記事を参考にしてください。

Node.jsの新しいモジュール方式の実験的導入 - hiroppy's site Node.jsに新しく入るESMのアルゴリズムと仕組みを紹介します

ファイル拡張子でそのファイルのみモジュールを固定する

ESMにしたい場合は.mjs、CJSにしたい場合は.cjsを利用することで、そのファイルのモジュールシステムを固定することができます。

CJSとESM間の解決方法

今まで理解するのに難しかった問題は、CJSとESMの相互運用性です。 なぜ難しいかというと、ホストがCJSの場合にimportされるファイルがESMなのかCJSなのかによって書き方が変わってしまうからです。

import CJS from ESM

ESMからCJSをimportする場合は、特に難しくなく、そのままimportを利用することが可能です。

import ESM from CJS

CJSからESMをimportする場合は、requireを利用することができません。唯一インポートする方法は、dynamic importを利用することです。

// // 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)

もっと詳しく現行のNode.jsのモジュールについて詳しく知りたい場合は、以下の記事を参考にしてください。

Node.jsのECMAScript Modulesの紹介 - hiroppy's site Node.jsのESMのリゾルバの説明と特徴を紹介します

まとめ

このアイディア自体は2019年からあり、2018年から始まったModuleワーキンググループでも議論した結果、当時は入れれず今のような仕組みになりました。 2024年になっても今の複雑な仕組みは利用者にとって苦痛となっているので今回、シンプル化に再度倒れる形となりました。 最初から、このような仕組みにできたのではないか?という疑問は誰しもが思うかもしれませんが、当時から議論している経緯があったことだけは確かです。

podcastでもこの話について話しているのでぜひ聞いてみてください。

ep144 Monthly Ecosystem 202403 | mozaic.fm 第 143 回のテーマは 2024 年 3 月の Monthly Ecosystem です。

現段階の今後をまとめると

  • 今まで
    • CJSからESMを読み込むときは、requireを利用することができず、dynamic importを利用する必要があった
    • CJSからなにかファイルを読み込むときに、そのファイルがESMかCJSかを気にしなければならなかった
  • これから
    • CJSからESMを読み込むときは、requireを利用することができる
    • requireで読み込むときに、TLAを利用している場合はエラーが発生する
      • 引き続きdynamic importを利用する
    • CJSからなにかファイルを読み込むときに、そのファイルがCJSかESMかを気にする必要がほぼなくなった
    • ライブラリ作者はDual Packageを気にする必要がほぼなくなった