Nuxt Nationカンファレンスが開催されます。11月12日~13日にご参加ください。

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について話すときは、モジュールを記述するための異なる構文について話しています。

import a from './a'

export { a }

ECMAScriptモジュール(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」)を使用していました。

しかし、最近の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はmainではなく、そのライブラリのpackage.jsonにあるexportsまたはmoduleエントリを探します。

これは、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 }

Node.jsのネイティブESMモードesModuleInteropが有効なTypeScript、そしてwebpackのようなバンドラーは、ライブラリをデフォルトでインポートできるようにする互換性メカニズムを提供しています。このメカニズムはしばしば「インターオペリクワイヤデフォルト」と呼ばれます。

import pkg from 'cjs-pkg'

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

しかし、構文検出の複雑さやさまざまなバンドル形式のために、インターオペデフォルトが失敗し、次のような結果になる可能性があります。

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 = ...

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

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

import { join } from 'path'

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

ベストプラクティス

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