パッケージの公開

提供されているすべての package.json 設定(特に「動作しない」とマークされていないもの)は、Node.js 12.22.x(v12 の最新版、サポートされている最も古いライン)および執筆時点で最新の 17.2.0 で動作します1。また、念のため、webpack 5.53.0 と 5.63.0 でも動作します。これらは JakobJingleheimer/nodejs-module-config-examples で入手できます。

好奇心旺盛な方のために、ここに至るまでの経緯ウサギの穴の奥深くへでは、背景とより詳細な説明を提供しています。

あなたの解決策を選んでください

ほとんどすべてのユースケースをカバーする2つの主要なオプションがあります

  • ソースコードを CJS で記述して公開する(require() を使用)。CJS は CJS と ESM の両方で(すべての Node バージョンで)利用可能です。CJS のソースと配布に進んでください。
  • ソースコードを ESM で記述して公開する(import を使用し、トップレベルの await は使用しない)。ESM は ESM と CJS の両方で(Node 22.x および 23.x で)利用可能です。require() an ES Module を参照してください。ESM のソースと配布に進んでください。

一般的に、CJS か ESM のいずれか1つの形式のみを公開するのが最善です。両方ではありません。複数の形式を公開すると、デュアルパッケージハザードやその他の欠点を引き起こす可能性があります。

主に歴史的な目的のために、他にも利用可能なオプションがあります。

パッケージ作成者としてあなたが書くコードあなたのパッケージの利用者が書くコードの形式あなたの選択肢
require() を使用した CJS ソースコードESM:利用者があなたのパッケージを import するCJS ソースと ESM のみの配布
CJS と ESM:利用者があなたのパッケージを require() または import するCJS ソースと CJS と ESM 両方の配布
import を使用した ESM ソースコードCJS:利用者があなたのパッケージを require() する(そしてあなたはトップレベルの await を使用する)ESM ソースと CJS のみの配布
CJS と ESM:利用者があなたのパッケージを require() または import するESM ソースと CJS と ESM 両方の配布

CJS のソースと配布

最もミニマルな設定は "name" だけかもしれません。しかし、分かりにくいものは少ない方が良いです。基本的には、"exports" フィールド/フィールドセットを介してパッケージのエクスポートを宣言するだけです。

動作例: cjs-with-cjs-distro

{
  "name": "cjs-source-and-distribution"
  // "main": "./index.js"
}

packageJson.exports["."] = filepathpackageJson.exports["."].default = filepath の短縮形であることに注意してください

ESM のソースと配布

シンプルで、実績があり、確実です。

Node.js v23.0.0 以降、静的な ESM(トップレベルの await を使用しないコード)を require することが可能になったことに注意してください。詳細は Loading ECMAScript modules using require() を参照してください。

これは、上記の CJS-CJS 設定とほぼ同じですが、1つの小さな違いがあります。"type" フィールドです。

動作例: esm-with-esm-distro

{
  "name": "esm-source-and-distribution",
  "type": "module"
  // "main": "./index.js"
}

ESM は今や CJS と「後方」互換性があることに注意してください。CJS モジュールは、23.0.0 および 22.12.0 以降、フラグなしで ES モジュールを require() できます。

CJS ソースと ESM のみの配布

これには少し工夫が必要ですが、これもかなり簡単です。これは、新しい標準をターゲットとする古いプロジェクトや、単に CJS を好むが異なる環境向けに公開している作成者にとっての選択肢かもしれません。

動作例: cjs-with-esm-distro

{
  "name": "cjs-source-with-esm-distribution",
  "main": "./dist/index.mjs"
}

.mjs ファイル拡張子は切り札です。他のどの設定よりも優先され、ファイルは ESM として扱われます。このファイル拡張子を使用する必要があるのは、packageJson.exports.import が(一般的、あるいは普遍的な誤解に反して)ファイルが ESM であることを意味するのではなく、パッケージがインポートされたときに使用されるファイルであることを意味するだけだからです(ESM は CJS をインポートできます。以下の注意点を参照)。

CJS ソースと CJS と ESM 両方の配布

両方のオーディエンスに直接対応するため(つまり、配布物がどちらの環境でも「ネイティブ」に動作するようにするため)、いくつかのオプションがあります。

名前付きエクスポートを exports に直接アタッチする

古典的ですが、ある程度の洗練と工夫が必要です。これは、(module.exports 全体を再割り当てするのではなく)既存の module.exports にプロパティを追加することを意味します。

動作例: cjs-with-dual-distro (properties)

{
  "name": "cjs-source-with-esm-via-properties-distribution",
  "main": "./dist/cjs/index.js"
}

長所

  • パッケージサイズの縮小
  • 簡単でシンプル(わずかな構文上の制約を気にしなければ、おそらく最も手間がかからない)
  • デュアルパッケージハザードを回避

短所

  • 非常に特定の構文が必要(ソースコードおよび/またはバンドラーの工夫)。

時々、CJS モジュールは module.exports を他の何か(オブジェクトや関数など)に再割り当てすることがあります。このようにです。

const  = {
  () {},
  () {},
  () {},
};

. = ;

Node.js は、特定のパターンを探す静的解析によって CJS の名前付きエクスポートを検出しますが、上記の例ではそれを回避してしまいます。名前付きエクスポートを検出可能にするには、次のようにします。

.. = function () {};
.. = function () {};
.. = function () {};

シンプルな ESM ラッパーを使用する

セットアップが複雑で、バランスを取るのが難しいです。

動作例: cjs-with-dual-distro (wrapper)

{
  "name": "cjs-with-wrapper-dual-distro",
  "exports": {
    ".": {
      "import": "./dist/esm/wrapper.mjs",
      "require": "./dist/cjs/index.js",
      "default": "./dist/cjs/index.js"
    }
  }
}

長所

  • パッケージサイズの縮小

短所

  • おそらく複雑なバンドラーの工夫が必要です(Webpack でこれを自動化するための既存のオプションは見つかりませんでした)。

バンドラーからの CJS 出力が Node.js の名前付きエクスポート検出を回避する場合、ESM ラッパーを使用して、ESM 利用者のために既知の名前付きエクスポートを明示的に再エクスポートすることができます。

CJS がオブジェクトをエクスポートする(これは ESM の default にエイリアスされる)場合、ラッパー内でそのオブジェクトのすべてのメンバーへの参照をローカルに保存し、それらを再エクスポートすることで、ESM の利用者はそれらすべてに名前でアクセスできるようになります。

import  from '../cjs/index.js';

const { , ,  /* … */ } = ;

export { , ,  /* … */ };

しかし、これはライブバインディングを壊します。cjs.a への再割り当ては esmWrapper.a には反映されません。

2つの完全な配布物

たくさんのものを放り込んで、最善を祈る。これはおそらく CJS から CJS と ESM へのオプションの中で最も一般的で簡単なものですが、その代償を払うことになります。これはめったに良いアイデアではありません。

動作例: cjs-with-dual-distro (double)

{
  "name": "cjs-with-full-dual-distro",
  "exports": {
    ".": {
      "import": "./dist/esm/index.mjs",
      "require": "./dist/cjs/index.js",
      "default": "./dist/cjs/index.js"
    }
  }
}

長所

  • シンプルなバンドラー設定

短所

代わりに、"default""node" キーを使用することもできます。これらは直感に反しにくいです。Node.js は常に "node" オプション(これは常に動作します)を選択し、Node.js 以外のツールは node 以外をターゲットに設定されている場合に "default" を選択します。これにより、デュアルパッケージハザードを回避できます。

{
  "name": "cjs-with-alt-full-dual-distro",
  "exports": {
    ".": {
      "node": "./dist/cjs/index.js",
      "default": "./dist/esm/index.mjs"
    }
  }
}

ESM ソースと CJS のみの配布

もうカンザスにはいないよ、トト。

設定(2つのオプションがあります)は、ESM ソースと CJS と ESM 両方の配布とほぼ同じですが、packageJson.exports.import を除外するだけです。

💡 "type": "module"2.cjs ファイル拡張子(commonjs ファイル用)を組み合わせることで、最良の結果が得られます。なぜそうなるのかについての詳細は、以下のウサギの穴の奥深くへ注意点を参照してください。

動作例: esm-with-cjs-distro

ESM ソースと CJS と ESM 両方の配布

ソースコードが非 JavaScript(例:TypeScript)で書かれている場合、その言語固有のファイル拡張子(例:.ts)を使用する必要があるため、オプションが制限されることがあります。また、.mjs に相当するものがない場合もあります。

CJS ソースと CJS と ESM 両方の配布と同様に、同じオプションがあります。

プロパティエクスポートを持つ CJS 配布のみを公開する

作成が難しく、良い材料が必要です。

このオプションは、上記のCJS ソースと CJS と ESM 配布のプロパティエクスポートとほぼ同じです。唯一の違いは package.json の "type": "module" です。

この出力を生成できるビルドツールは一部のみです。Rollup は、commonjs をターゲットにすると、すぐに互換性のある出力を生成します。Webpack は v5.66.0+ 以降、新しい commonjs-static 出力タイプで対応しています(これ以前は、どの commonjs オプションも互換性のある出力を生成しませんでした)。esbuild(非静的な exports を生成します)では現在不可能です。

以下の動作例は Webpack の最近のリリース前に作成されたものなので、Rollup を使用しています(いずれ Webpack のオプションも追加する予定です)。

これらの例では、内部の JavaScript ファイルが .js 拡張子を使用していると仮定しています。package.json"type" がそれらの解釈方法を制御します。

"type":"commonjs" + .jscjs
"type":"module" + .jsmjs

あなたのファイルがすべて明示的に .cjs および/または .mjs ファイル拡張子を使用している場合(.js を使用していない場合)、"type" は不要です。

動作例: esm-with-cjs-distro

{
  "name": "esm-with-cjs-distribution",
  "type": "module",
  "main": "./dist/index.cjs"
}

💡 "type": "module"2.cjs ファイル拡張子(commonjs ファイル用)を組み合わせることで、最良の結果が得られます。なぜそうなるのかについての詳細は、以下のウサギの穴の奥深くへ注意点を参照してください。

ESM ラッパーを持つ CJS 配布を公開する

ここでは多くのことが起こっており、通常これは最善ではありません。

これもまた、ESM ラッパーを使用した CJS ソースとデュアル配布とほぼ同じですが、"type": "module" や package.json 内のいくつかの .cjs ファイル拡張子など、微妙な違いがあります。

動作例: esm-with-dual-distro (wrapper)

{
  "name": "esm-with-cjs-and-esm-wrapper-distribution",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/esm/wrapper.js",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/cjs/index.cjs"
    }
  }
}

💡 "type": "module"2.cjs ファイル拡張子(commonjs ファイル用)を組み合わせることで、最良の結果が得られます。なぜそうなるのかについての詳細は、以下のウサギの穴の奥深くへ注意点を参照してください。

CJS と ESM 両方の完全な配布を公開する

たくさんのものを(サプライズ付きで)放り込んで、最善を祈る。これはおそらく ESM から CJS と ESM へのオプションの中で最も一般的で簡単なものですが、その代償を払うことになります。これはめったに良いアイデアではありません。

パッケージ設定に関しては、主に個人の好みの違いでいくつかのオプションがあります。

パッケージ全体を ESM としてマークし、CJS エクスポートを .cjs ファイル拡張子で明示的に CJS としてマークする

このオプションは、開発/開発者体験への負担が最も少ないです。

これはまた、ビルドツールが配布ファイルを .cjs ファイル拡張子で生成する必要があることを意味します。これには、複数のビルドツールを連鎖させるか、ファイルを移動/リネームして .cjs ファイル拡張子を持たせる後続のステップを追加する必要があるかもしれません(例:mv ./dist/index.js ./dist/index.cjs)。これは、出力されたファイルを移動/リネームする後続のステップを追加することで回避できます(例:Rollup簡単なシェルスクリプト)。

.cjs ファイル拡張子のサポートは 12.0.0 で追加され、これを使用すると ESM はファイルを commonjs として正しく認識します(import { foo } from './foo.cjs' は動作します)。しかし、require().js のように .cjs を自動解決しないため、commonjs で一般的なようにファイル拡張子を省略することはできません。require('./foo') は失敗しますが、require('./foo.cjs') は動作します。パッケージのエクスポートで使用することに欠点はありません。packageJson.exports(および packageJson.main)はファイル拡張子が必須であり、利用者は package.json の "name" フィールドでパッケージを参照するため(そのため彼らは blissful にも気づきません)。

動作例: esm-with-dual-distro

{
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

代わりに、"default""node" キーを使用することもできます。これらは直感に反しにくいです。Node.js は常に "node" オプション(これは常に動作します)を選択し、Node.js 以外のツールは node 以外をターゲットに設定されている場合に "default" を選択します。これにより、デュアルパッケージハザードを回避できます。

{
  "type": "module",
  "exports": {
    ".": {
      "node": "./dist/index.cjs",
      "default": "./dist/esm/index.js"
    }
  }
}

💡 "type": "module"2.cjs ファイル拡張子(commonjs ファイル用)を組み合わせることで、最良の結果が得られます。なぜそうなるのかについての詳細は、以下のウサギの穴の奥深くへ注意点を参照してください。

すべてのソースコードファイルに .mjs(または同等の)ファイル拡張子を使用する

この設定は、CJS ソースと CJS と ESM 両方の配布と同じです。

非 JavaScript のソースコード: 非 JavaScript 言語自体の設定で、入力ファイルが ESM であることを認識/指定する必要があります。

12.22.x より前の Node.js

🛑 これを行うべきではありません。12.x より前のバージョンの Node.js はサポート終了 (End of Life) であり、現在、深刻なセキュリティ脆弱性に対して脆弱です。

v12.22.x より前の Node.js を調査する必要があるセキュリティ研究者の方は、設定のヘルプについてお気軽にお問い合わせください。

一般的な注意点

構文検出は、適切なパッケージ設定の代替にはなりません。構文検出は完璧ではなく、重大なパフォーマンスコストがかかります。

package.json で "exports" を使用する場合、"./package.json": "./package.json" を含めるのが一般的に良いアイデアです。これにより、インポートできるようになります(module.findPackageJSON はこの制限の影響を受けませんが、import の方が便利な場合があります)。

"exports" は、内部コードへの外部アクセスを防ぐため(ユーザーが依存すべきでないものに依存していないことを比較的確信できるため)、"main" よりも推奨されることがあります。それが必要ない場合は、"main" の方がシンプルで、あなたにとってより良い選択肢かもしれません。

"engines" フィールドは、パッケージが互換性のある Node.js のバージョンを人間にも機械にも分かりやすく示します。使用するパッケージマネージャーによっては、利用者が互換性のないバージョンの Node.js を使用している場合に例外がスローされ、インストールが失敗することがあります(これは利用者にとって非常に役立ちます)。このフィールドを含めることで、古いバージョンの Node.js を使用していてパッケージを使用できない利用者の多くの頭痛の種を減らすことができます。

ウサギの穴の奥深くへ

特に Node.js に関連して、解決すべき4つの問題があります

  • ソースコードファイルのフォーマットを決定する(作者が自身のコードを実行する)

  • 配布ファイルのフォーマットを決定する(コードの利用者が受け取る)

  • require() される際の配布コードを公開する(利用者は CJS を期待)

  • import される際の配布コードを公開する(利用者は恐らく ESM を期待)

⚠️ 最初の2つは、最後の2つとは独立しています。

読み込み方法が、ファイルの解釈形式を決定するわけではありません

  • package.json の exports.require CJSrequire() はファイルを盲目的に CJS として解釈するわけでも、できません。例えば、require('foo.json') はファイルを正しく JSON として解釈し、CJS ではありません。もちろん、require() 呼び出しを含むモジュールは CJS である必要がありますが、読み込んでいるものが必ずしも CJS とは限りません。
  • package.json の exports.import ESMimport も同様に、ファイルを盲目的に ESM として解釈するわけでも、できません。import は CJS、JSON、WASM、そして ESM も読み込むことができます。もちろん、import 文を含むモジュールは ESM である必要がありますが、読み込んでいるものが必ずしも ESM とは限りません。

したがって、requireimport を引用したり、名前を付けたりしている設定オプションを見たときは、それらが CJS と ES モジュールを決定するためのものだと仮定する衝動を抑えてください。

⚠️ パッケージの設定に "exports" フィールド/フィールドセットを追加すると、exports のサブパスで明示的にリストされていないものに対して、パッケージへのディープパスが効果的にブロックされます。これは破壊的変更になる可能性があります。

⚠️ CJS と ESM の両方を配布するかどうかは慎重に検討してください。デュアルパッケージハザードの可能性があります(特に設定が間違っていて、利用者が賢いことをしようとした場合)。これは、利用しているプロジェクトで非常に紛らわしいバグにつながる可能性があり、特にあなたのパッケージが完璧に設定されていない場合はなおさらです。利用者は、あなたのパッケージの「もう一方」のフォーマットを使用する中間パッケージによって不意打ちを食らうことさえあります(例:利用者が ESM 配布を使用し、利用者が使用している他のパッケージが CJS 配布を使用している場合)。あなたのパッケージが何らかの形でステートフルである場合、CJS と ESM の両方の配布を消費すると、並行した状態が発生します(これはほぼ確実に意図しないものです)。

デュアルパッケージハザード

アプリケーションが CommonJS と ES モジュールの両方のソースを提供するパッケージを使用している場合、パッケージの両方のインスタンスがロードされると、特定のバグが発生するリスクがあります。この可能性は、const pkgInstance = require('pkg') によって作成された pkgInstance が、import pkgInstance from 'pkg'(または 'pkg/module' のような代替メインパス)によって作成された pkgInstance と同じではないという事実に由来します。これが「デュアルパッケージハザード」であり、同じパッケージの2つのインスタンスが同じランタイム環境内にロードされる可能性があります。アプリケーションやパッケージが意図的に両方のインスタンスを直接ロードすることはまずありませんが、アプリケーションが一方のコピーをロードし、アプリケーションの依存関係がもう一方のコピーをロードすることはよくあります。このハザードは、Node.js が CommonJS と ES モジュールの混在をサポートしているために発生する可能性があり、予期せぬ紛らわしい動作につながる可能性があります。

パッケージのメインエクスポートがコンストラクタの場合、2つのコピーによって作成されたインスタンスの instanceof 比較は false を返し、エクスポートがオブジェクトの場合、一方に追加されたプロパティ(pkgInstance.foo = 3 のような)はもう一方には存在しません。これは、すべて CommonJS またはすべて ES モジュールの環境での importrequire 文の動作とはそれぞれ異なるため、ユーザーを驚かせます。また、Babelesm のようなツールを介したトランスパイルを使用する際にユーザーが慣れ親しんでいる動作とも異なります。

ここに至るまでの経緯

CommonJS (CJS) は、ECMAScript Modules (ESM) が登場するずっと前、JavaScript がまだ黎明期だった頃に作られました。CJS と jQuery はわずか3年の差で作られました。CJS は公式の (TC39) 標準ではなく、ごく少数のプラットフォーム(最も有名なのは Node.js)でサポートされています。ESM は標準として数年前から登場しており、現在はすべての主要なプラットフォーム(ブラウザ、Deno、Node.js など)でサポートされており、ほぼどこでも実行できることを意味します。ESM が CJS(依然として非常に人気があり、広く普及しています)を事実上後継することが明らかになるにつれて、多くの人が早い段階で採用を試みましたが、それはしばしば ESM 仕様の特定の側面が最終決定される前でした。このため、それらの熱心な人々からの学びや経験に基づいて、より良い情報が利用可能になるにつれて、時間の経過とともに変化し、最善の推測から仕様に沿ったものへと変わっていきました。

さらなる複雑さはバンドラーです。これらは歴史的にこの領域の多くを管理してきました。しかし、以前はバンドラーに管理してもらう必要があったものの多くは、今ではネイティブ機能となっています。それでも、バンドラーはいくつかのことには依然として(そしておそらく常に)必要です。残念ながら、バンドラーがもはや提供する必要のない機能は、古いバンドラーの実装に深く根付いているため、時にはおせっかいになりすぎたり、場合によってはアンチパターンになることがあります(ライブラリをバンドルすることは、バンドラーの作者自身によって推奨されないことが多いです)。その方法と理由は、それ自体が一つの記事になります。

注意点

package.json"type" フィールドは、.js ファイル拡張子の意味を commonjs または ES module のいずれかに変更します。デュアル/混合パッケージ(CJS と ESM の両方を含む)では、このフィールドを誤って使用することが非常によくあります。

{
  "type": "module",
  "main": "./dist/CJS/index.js",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "default": "./dist/cjs/index.js"
    },
    "./package.json": "./package.json"
  }
}

これは動作しません。なぜなら、"type": "module"packageJson.mainpackageJson.exports["."].require、および packageJson.exports["."].default が ESMとして解釈される原因となります(しかし、実際には CJS です)。

"type": "module" を除外すると、逆の問題が発生します

{
  "main": "./dist/CJS/index.js",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "default": "./dist/cjs/index.js"
    },
    "./package.json": "./package.json"
  }
}

これは動作しません。なぜなら、packageJson.exports["."].import は CJSとして解釈されてしまうからです(しかし、実際には ESM です)。

脚注

  1. Node.js v13.0–13.6 には、packageJson.exports["."] が、最初の項目として詳細な設定オプションを持つオブジェクトを含む配列であり、2番目の項目として「デフォルト」を文字列として持つ必要があるというバグがありました。nodejs/modules#446 を参照してください。

  2. package.json の "type" フィールドは、HTML の script 要素の type 属性と同様に、.js ファイル拡張子が何を意味するかを変更します。 2 3 4