ES モジュール

Nuxt はネイティブの ES モジュールを使用します。

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

背景

CommonJS モジュール

CommonJS (CJS) は、Node.js によって導入された形式で、分離された JavaScript モジュール間で機能を共有できます (詳細はこちら)。この構文にはすでに慣れているかもしれません

const a = require('./a')

module.exports.a = a

webpack や Rollup のようなバンドラーはこの構文をサポートしており、CommonJS で記述されたモジュールをブラウザで使用できます。

ESM 構文

ほとんどの場合、ESM と CJS について話すとき、それは記述するための異なる構文について話しています。modules.

import a from './a'

export { a }

ECMAScript Modules (ESM) が標準になる前 (10 年以上かかりました!)、webpackや TypeScript のような言語でさえ、いわゆる ESM 構文のサポートを開始しました。しかし、実際の仕様とはいくつかの重要な違いがあります。ここに役立つ説明があります.

「ネイティブ」ESM とは何か?

ESM 構文を使用してアプリを長時間記述しているかもしれません。結局のところ、それはブラウザによってネイティブにサポートされており、Nuxt 2 では、サーバー用に CJS、ブラウザ用に ESM という適切な形式にすべてのコードをコンパイルしていました。

パッケージにモジュールを追加する場合、状況は少し異なりました。サンプルライブラリは、CJS と ESM の両方のバージョンを公開し、どちらを使用するかを選択させてくれました。

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

そのため、Nuxt 2 では、バンドラー (webpack) がサーバービルド用に CJS ファイル ('main') をプルインし、クライアントビルド用に ESM ファイル ('module') を使用していました。

module フィールドは webpack や Rollup のようなバンドラーが使用する慣例ですが、Node.js 自体には認識されません。Node.js はモジュール解決にexportsGlobalComponentsmainフィールドのみを使用します。

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

  • package.json 内で "type": "module" を設定し、.js 拡張子を使い続ける
  • .mjs ファイル拡張子を使用する (推奨)

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

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

モジュールを require するのではなく import すると、Node.js は異なる方法で解決します。たとえば、sample-library をインポートすると、Node.js はそのライブラリの package.jsonexports エントリを探します。 exports が定義されていない場合は、main エントリにフォールバックします。

これは、const b = await import('sample-library') のような動的インポートにも当てはまります。

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

  1. .mjs で終わるファイル - ESM 構文を使用することが期待されます
  2. .cjs で終わるファイル - CJS 構文を使用することが期待されます
  3. .js で終わるファイル - package.json"type": "module" がない限り、CJS 構文を使用することが期待されます。

どのような問題が発生しうるか?

長い間、モジュール作成者は ESM 構文ビルドを作成してきましたが、.esm.js.es.js のような慣例を使用し、それらを package.jsonmodule フィールドに追加してきました。これはこれまで、ファイル拡張子を特に気にしない webpack のようなバンドラーによってのみ使用されていたため、問題ではありませんでした。

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

ターミナル
(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)

Node.js が CJS だと考える ESM 構文ビルドから名前付きインポートがある場合も、このエラーが発生することがあります。

ターミナル
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)

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

これらのエラーに遭遇した場合、問題はほぼ確実にアップストリームのライブラリにあります。彼らは Node によるインポートをサポートするために、ライブラリを修正する必要があります。

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

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

export default defineNuxtConfig({
  build: {
    transpile: ['sample-library'],
  },
})

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

ライブラリのエイリアス

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

export default defineNuxtConfig({
  alias: {
    'sample-library': 'sample-library/dist/sample-library.cjs.js',
  },
})

デフォルトエクスポート

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

node_modules/cjs-pkg/index.js
module.exports = { test: 123 }
// or
exports.test = 123

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

test.cjs
const pkg = require('cjs-pkg')

console.log(pkg) // { test: 123 }

ネイティブ ESM モードの Node.js, esModuleInterop が有効な TypeScriptそして webpack のようなバンドラーは、そのようなライブラリをデフォルトでインポートできるようにするための互換性メカニズムを提供します。このメカニズムは「interop require default」と呼ばれることがよくあります。

import pkg from 'cjs-pkg'

console.log(pkg) // { test: 123 }

しかし、構文検出の複雑さと異なるバンドル形式のため、interop default が失敗し、次のような結果になる可能性があります。

import pkg from 'cjs-pkg'

console.log(pkg) // { default: { test: 123 } }

また、動的インポート構文 (CJS および ESM ファイルの両方で) を使用する場合も、常にこの状況になります。

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

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

// Static import
import { default as pkg } from 'cjs-pkg'

// Dynamic import
import('cjs-pkg').then(m => m.default || m).then(console.log)

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

import { interopDefault } from 'mlly'

// Assuming the shape is { default: { foo: 'bar' }, baz: 'qux' }
import myModule from 'my-module'

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

ライブラリ作成者ガイド

朗報は、ESM の互換性の問題を修正するのは比較的簡単だということです。主な選択肢は 2 つあります。

  1. ESM ファイルを .mjs で終わるように名前変更できます。
    これが推奨される最も簡単なアプローチです。 ライブラリの依存関係やビルドシステムに関する問題を解決する必要があるかもしれませんが、ほとんどの場合、これで問題が解決するはずです。最大限の明示性のために、CJS ファイルも .cjs で終わるように名前変更することをお勧めします。
  2. ライブラリ全体を ESM 専用にすることもできます。.
    これは、package.json"type": "module" を設定し、ビルドされたライブラリが ESM 構文を使用していることを確認することを意味します。しかし、依存関係に問題が生じる可能性があり、このアプローチはライブラリが ESM コンテキストでのみ消費されることを意味します。

移行

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

module.exports = function () { /* ... */ }

exports.hello = 'world'
const myLib = require('my-lib')

ESM モジュールでは、CJS とは異なり、requirerequire.resolve__filename__dirname グローバルは利用できず、import() および import.meta.filename に置き換える必要があります。

const { join } = require('node:path')

const newDir = join(__dirname, 'new-dir')
const someFile = require.resolve('./lib/foo.js')

ベストプラクティス

  • デフォルトエクスポートではなく、名前付きエクスポートを優先してください。これにより、CJS の競合を減らすことができます。(「デフォルトエクスポート」セクションを参照)
  • ライブラリをブラウザや Edge Workers で Nitro ポリフィルなしで使用できるようにするため、Node.js の組み込み機能や CommonJS または Node.js 専用の依存関係への依存を可能な限り避けてください。
  • 条件付きエクスポートを含む新しい exports フィールドを使用してください。(詳細はこちら).
{
  "exports": {
    ".": {
      "import": "./dist/mymodule.mjs"
    }
  }
}