最終更新日: 2023年08月15日

ESモジュール

このガイドは、ESモジュールとは何か、またNuxtアプリ(またはアップストリームライブラリ)をESモジュールと互換性があるようにする方法について説明します。

バックグラウンド

CommonJS モジュール

CommonJS (CJS)は、Node.jsによって導入されたフォーマットであり、孤立したJavaScriptモジュール間で機能を共有することを可能にします(詳細はこちらを参照)。おそらくこの構文には既におなじみかもしれません:

  • ts
const a = require("./a");
module.exports.a = a;

WebpackやRollupなどのバンドラーは、この構文をサポートしており、CommonJSで書かれたモジュールをブラウザで使用することができます。

ESMシンタックス

ほとんどの場合、ESM(ECMAScript Modules)とCJS(CommonJS)について話す際は、異なるモジュール記述の構文について言及しています。

  • ts
import a from "./a";
export { a };

ECMAScript Modules(ESM)が標準となる前(それには10年以上かかりました!)、webpackなどのツールやTypeScriptなどの言語は、いわゆるESM構文をサポートし始めました。しかし、実際の仕様といくつかの重要な違いがあります。詳細な説明はこちらを参照してください。

ネイティブESMとは?

長い間、ESM構文を使用してアプリを書いているかもしれません。なぜなら、ブラウザでネイティブにサポートされており、またNuxt 2では書いたコードを適切な形式にコンパイルしています(サーバー向けにはCJS、ブラウザ向けにはESM)。

モジュールをパッケージにインストールする場合、事情は少し異なります。サンプルライブラリの場合、CJSとESMの両方のバージョンを公開し、どちらを選択するかを選ぶことができます。

  • ts
{
  "name": "sample-library",
  "main": "dist/sample-library.cjs.js",
  "module": "dist/sample-library.esm.js"
}

そうです、Nuxt 2では、バンドラー(webpack)はサーバービルドにCJSファイル('main')を取り込み、クライアントビルドにはESMファイル('module')を使用します。

最近のNode.jsのLTSリリースでは、ネイティブESMモジュールをNode.js内で使用することが可能になりました。つまり、Node.js自体がESM構文を使ってJavaScriptを処理できるようになりましたが、デフォルトではそのように動作しません。ESM構文を有効にする最も一般的な方法は2つあります:

  • package.json内で"type:module"を設定し、ESMファイルの拡張子として.jsを引き続き使用します。
  • .mjsファイル拡張子を使用することもできます(推奨)。

Nuxt Nitroでは、.output/server/index.mjsファイルを出力します。これにより、Node.jsはこのファイルをネイティブのESモジュールとして扱うようになります。

Node.jsのコンテキストで有効なインポートは何ですか?

モジュールをrequireではなくimportでインポートする場合、Node.jsは異なる方法でそれを解決します。例えば、sample-libraryをimportする場合、Node.jsはそのライブラリのpackage.json内のexportsまたはmoduleエントリーを参照します。

動的インポート(const b = await import('sample-library')のような)についても同様です。

Nodeは次の種類のインポートをサポートしています(ドキュメントを参照):

  • .mjsで終わるファイル - これらはESM構文を使用することが期待されています。
  • .cjsで終わるファイル - これらはCJS構文を使用することが期待されています。
  • .jsで終わるファイル - これらは、package.jsonに"type": "module"が設定されていない限り、CJS構文を使用することが期待されています。ただし、"type": "module"が設定されている場合はESM構文を使用します。

どのような問題が発生する可能性がありますか?

長い間、モジュールの作成者はESM構文のビルドを生成してきましたが、.esm.jsや.es.jsなどの規約を使用して、これらをpackage.jsonのmoduleフィールドに追加していました。これまで、これらのファイル拡張子は特に問題にならなかったのは、webpackなどのバンドラーが使用しており、ファイル拡張子に特別な関心を持っていないためです。

しかし、Node.jsのESMコンテキストで.esm.jsファイルを持つパッケージをインポートしようとすると、うまく動作せず、次のようなエラーが発生します:

  • ts
file:///path/to/index.mjs:5
import { named } from 'sample-library'
         ^^^^^
SyntaxError:
Named export 'named' not found.
The requested module 'sample-library' is a CommonJS module,
which may not support all module.exports as named exports.

CommonJS modules can always be imported via the default export, for example using:

import pkg from 'sample-library';
const { named } = pkg;

    at ModuleJob._instantiate (internal/modules/esm/module_job.js:120:21)
    at async ModuleJob.run (internal/modules/esm/module_job.js:165:5)
    at async Loader.import (internal/modules/esm/loader.js:177:24)
    at async Object.loadESM (internal/process/esm_loader.js:68:5)

もしNode.jsがESM構文としてではなくCJSと認識してしまったESM構文ビルドからの名前付きインポートを行うと、同様のエラーが発生することがあります。

  • ts
(node:22145) Warning: To load an ES module,
set "type": "module" in the package.json or use the .mjs extension.

/path/to/index.js:1

export default {}
^^^^^^

SyntaxError: Unexpected token 'export'
    at wrapSafe (internal/modules/cjs/loader.js:1001:16)
    at Module._compile (internal/modules/cjs/loader.js:1049:27)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
    ....
    at async Object.loadESM (internal/process/esm_loader.js:68:5)

ESMのトラブルシューティング

もし上記のようなエラーが発生した場合、問題はほとんど確実にアップストリームのライブラリにあります。そのライブラリをNodeでのインポートに対応するように修正する必要があります。

ライブラリのトランスパイル

その間、これらのライブラリをNuxtがインポートしようとしないように、build.transpileにそれらを追加することで指定できます。

  • ts
export default defineNuxtConfig({
  build: {
    transpile: ["sample-library"],
  },
});

はい、これらのライブラリによってインポートされている他のパッケージも追加する必要があるかもしれません。

ライブラリのエイリアス化

場合によっては、ライブラリをCJSバージョンに手動でエイリアスする必要があるかもしれません。例えば:

  • ts
export default defineNuxtConfig({
  alias: {
    "sample-library": "sample-library/dist/sample-library.cjs.js",
  },
});

デフォルトエクスポート

CommonJS形式の依存関係は、module.exportsまたはexportsを使用してデフォルトエクスポートを提供することができます。

  • node_modules/cjs-pkg/index.js
  • ts
module.exports = { test: 123 };
// または
exports.test = 123;

通常、このような依存関係をrequireする場合はうまく動作します。

  • test.cjs
  • ts
const pkg = require("cjs-pkg");
console.log(pkg); // { test: 123 }

Node.jsのネイティブESMモード、esModuleInteropを有効にしたTypeScript、およびWebpackなどのバンドラーは、「interop require default」としてよく知られる互換性メカニズムを提供し、このようなライブラリをデフォルトでインポートできるようにしています。

  • ts
import pkg from "cjs-pkg";
console.log(pkg); // { test: 123 }

しかし、構文の検出と異なるバンドル形式の複雑さのため、常にinterop defaultが失敗する可能性があり、次のような問題に直面することがあります:

  • ts
import pkg from "cjs-pkg";
console.log(pkg); // { test: 123 }

動的インポート構文を使用する場合(CJSとESMの両方のファイルで)、常にこのような状況に直面します。

  • ts
import("cjs-pkg").then(console.log);
// [Module: null prototype] { default: { test: '123' } }

この場合、デフォルトエクスポートを手動でInteropする必要があります。

  • ts
// 静的なインポート
import { default as pkg } from "cjs-pkg";

// 動的なインポート
import("cjs-pkg").then((m) => m.default || m).then(console.log);

より複雑な状況を処理し、より安全に対応するために、Nuxt 3ではmllyを推奨しており、内部で使用しています。mllyは名前付きエクスポートを保持することができます。

  • ts
import { interopDefault } from "mlly";

// 与えられた形式が { default: { foo: 'bar' }, baz: 'qux' } であると仮定します。
import myModule from "my-module";

console.log(interopDefault(myModule)); // { foo: 'bar', baz: 'qux' }

ライブラリ作成者ガイド

良いニュースは、ESMの互換性の問題を修正するのは比較的簡単だということです。主なオプションは2つあります:

  • ESMファイルの拡張子を.mjsで終わるようにリネームできる
    これは推奨されている最も簡単なアプローチです。ライブラリの依存関係やビルドシステムの問題を整理する必要があるかもしれませんが、ほとんどの場合、これで問題が解決するでしょう。また、CJSファイルの拡張子も.cjsで終わるようにリネームすることを推奨します。これにより、最も明確な表現になります。
  • ライブラリ全体をESMのみにすることも選択肢としてあります。
    その場合、package.jsonに"type": "module"を設定し、ビルドされたライブラリがESM構文を使用するようにする必要があります。ただし、依存関係に問題が発生する可能性があります。また、このアプローチではライブラリはESMコンテキストでのみ利用可能になります。

移行

CJSからESMへの最初のステップは、requireの使用をimportに更新することです。

  • ts
// before
module.exports = ...
exports.hello = ...

// after
export default ...
export const hello = ...
  • ts
// before
const myLib = require("my-lib");

// after
import myLib from "my-lib";
// または
const myLib = await import("my-lib").then((lib) => lib.default || lib);

ESMモジュールでは、CJSとは異なり、require、require.resolve、__filename、__dirnameのグローバル変数は使用できず、代わりにimport()とglobalThis._importMeta_.filenameに置き換える必要があります。

  • ts
// before
import { join } from "path";
const newDir = join(__dirname, "new-dir");

// after
import { fileURLToPath } from "node:url";
const newDir = fileURLToPath(new URL("./new-dir", import.meta.url));
  • ts
// before
const someFile = require.resolve("./lib/foo.js");

// after
import { resolvePath } from "mlly";
const someFile = await resolvePath("my-lib", { url: import.meta.url });

ベストプラクティス

  • デフォルトエクスポートよりも名前付きエクスポートを優先してください。これにより、CJSの競合が減少します。(デフォルトエクスポートのセクションを参照してください)
  • できる限り、Node.jsのビルトインやCommonJS、Node.js専用の依存関係に依存しないようにしてください。これにより、Nitroのポリフィルを必要とせずに、ライブラリをブラウザやEdge Workersで使用できるようになります。
  • 新しいexportsフィールドを使用して条件付きエクスポートを行ってください。(詳細はこちらを参照
  • ts
{
  "exports": {
    ".": {
      "import": "./dist/mymodule.mjs"
    }
  }
}