Node.js におけるモジュールシステムの理解
Lukas Schneider
DevOps Engineer · Leapcell

はじめに
モジュールシステムは、現代の JavaScript 開発において不可欠なものであり、開発者がコードを再利用可能な単位に整理し、依存関係を効率的に管理することを可能にします。長らく、CommonJS (CJS) は Node.js におけるサーバーサイド JavaScript の事実上の標準として機能し、数多くのプロジェクトで堅牢かつ効果的であることが証明されてきました。しかし、ブラウザ、そして最終的には Node.js での ECMAScript Modules (ESM) の標準化により、モジュール管理の様相は大きく変化しました。この進化は、JavaScript エコシステム全体にわたる強力な新機能と統一されたモジュールシステムをもたらしましたが、開発者にとっては複雑さと意思決定のポイントも生じさせます。CJS と ESM のニュアンスを理解し、それらの相互運用性をナビゲートする方法を習得することは、今日の保守可能で高性能な Node.js アプリケーションを構築するために不可欠です。本稿では、これらの違いを掘り下げ、両方のシステムを扱うための戦略を探り、プロジェクトに役立つ実践的な洞察を提供します。
コアコンセプト
詳細に入る前に、Node.js のモジュールシステムに関連するコア用語を定義しましょう。
CommonJS (CJS)
CommonJS は、主に Node.js で使用されるモジュール仕様です。同期的なモジュールローディングシステムであり、モジュールが要求されたときに、実行が継続する前にランタイムがモジュールのロードと解析を待つことを意味します。
require()
: モジュールをインポートするために使用される関数です。モジュールパスを引数として取り、そのモジュールのexports
オブジェクトを返します。module.exports
: モジュールから値をエクスポートするために使用されるオブジェクトです。デフォルトでは、空のオブジェクト{}
です。module.exports
に代入すると、モジュール全体の export を設定することになります。exports
:module.exports
への参照です。exports
にプロパティを追加することで、複数の値を公開できます。
ES Modules (ESM)
ES Modules (JavaScript Modules または ES6 Modules とも呼ばれます) は、ECMAScript におけるモジュールの公式標準です。静的で非同期なローディングメカニズムを特徴とし、ブラウザと Node.js の両方で機能するように設計されています。
import
: モジュールをインポートするために使用されるステートメントです。名前付きインポート (import { name } from './module'
) とデフォルトインポート (import defaultExport from './module'
) に使用できます。export
: モジュールから値をエクスポートするために使用されるステートメントです。名前付きエクスポート (export const name = 'value'
) とデフォルトエクスポート (export default value
) に使用できます。- 静的解析: ESM のインポートとエクスポートは、解析時に静的に解析できるため、ツリーシェイキングとより良いツールサポートが可能になります。
- 非同期ローディング: ESM モジュールは非同期ローディングのために設計されており、Web ブラウザでのパフォーマンスに不可欠です。
package.json
の type
フィールド
Node.js は、パッケージ内のファイルを CJS または ESM として解釈すべきかどうかを判断するために、package.json
の type
フィールドを使用します。
"type": "commonjs"
: すべての.js
ファイル (拡張子のないファイルも含む) は CJS として扱われます。"type": "module"
: すべての.js
ファイル (拡張子のないファイルも含む) は ESM として扱われます。
type
フィールドに関わらず、.mjs
ファイルは常に ESM として、.cjs
ファイルは常に CJS として扱われます。これにより、モジュールの種類を明示的に区別する方法が提供されます。
違いと相互運用性
CJS と ESM の主な違いは、それらの設計思想と、ローディング、構文、スコープの処理方法に由来します。
主な違い
-
構文:
- CJS: インポートには
require()
、エクスポートにはmodule.exports
またはexports
を使用します。 - ESM: インポートには
import
、エクスポートにはexport
を使用します。
CJS の例:
// math.js function add(a, b) { return a + b; } module.exports = { add }; // app.js const { add } = require('./math'); console.log(add(2, 3)); // 5
ESM の例:
// math.mjs export function add(a, b) { return a + b; } // app.mjs import { add } from './math.mjs'; console.log(add(2, 3)); // 5
- CJS: インポートには
-
ローディングメカニズム:
- CJS: 同期ローディング。
require()
は、モジュールがロードされるまで実行をブロックします。これは、ファイル I/O が高速なサーバーサイド環境では問題ありません。 - ESM: 非同期ローディング。
import
ステートメントは、モジュールのコードが実行される前に最初に処理されます。これにより、並列ロードが可能になり、Web 環境に最適化されています。
- CJS: 同期ローディング。
-
バインディングと値:
- CJS: エクスポートは値のコピーです。エクスポートされた値がエクスポート元モジュール内で変更されても、インポート元モジュールはその更新された値を見ることができません。
- ESM: エクスポートはライブバインディングです。エクスポートされた値がエクスポート元モジュールで変更されると、インポート元モジュールはその更新された値を見ることができます。これは、変更される可能性のあるシングルトンクラスインスタンスや設定オブジェクトなどに特に便利です。
ESM ライブバインディングの例:
// counter.mjs export let count = 0; export function increment() { count++; } // app.mjs import { count, increment } from './counter.mjs'; console.log(count); // 0 increment(); console.log(count); // 1 (ライブバインディング)
CJS 値コピーの例:
// counter.js let count = 0; function increment() { count++; } module.exports = { count, increment }; // app.js const { count, increment } = require('./counter'); console.log(count); // 0 increment(); console.log(count); // 0 (コピーされた値であり、ライブバインディングではない)
-
this
コンテキスト:- CJS: CJS モジュールのトップレベルでは、
this
はmodule.exports
を参照します。 - ESM: ESM モジュールのトップレベルでは、
this
はundefined
です。
- CJS: CJS モジュールのトップレベルでは、
-
ファイル拡張子:
- CJS: 通常は
.js
を使用します (type: "module"
が設定されている場合を除く)。 - ESM: 明示的に
.mjs
を使用するか、package.json
でtype: "module"
が設定されている場合は.js
を使用できます。
- CJS: 通常は
-
__dirname
および__filename
:- CJS: これらのグローバル変数は、現在のディレクトリ名とファイル名を提供するために直接利用できます。
- ESM: これらは直接利用できません。
import.meta.url
を使用して構築する必要があります。
__dirname
および__filename
の ESM 相当:import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); console.log(__filename); console.log(__dirname);
相互運用性戦略
Node.js は、同じプロジェクト内で CJS と ESM を一緒に使用するためのメカニズムを提供します。
-
ESM から CJS をインポートする: ESM モジュールは、CJS モジュールを直接
import
できます。CJS モジュールのdefault
export がインポートされた値になります。CJS モジュールからの名前付き export は直接アクセスできず、モジュール全体をインポートしてからデストラクチャリングする必要があります。// cjs-module.js module.exports = { greet: 'Hello CJS!', sayHi: () => 'Hi CJS!' }; // esm-app.mjs (または "type": "module" を持つ .js) import cjsModule from './cjs-module.js'; // exports オブジェクト全体をインポート console.log(cjsModule.greet); // Hello CJS! console.log(cjsModule.sayHi()); // Hi CJS! // CJS の直接の名前付きインポートはサポートされていません // import { greet } from './cjs-module.js'; // これはエラーになります
-
CJS から ESM を要求する (実験的/間接的): CJS モジュールから ESM モジュールを直接
require()
することは、Node.js では標準ではサポートされていません。require()
は同期的ですが、ESM のロードは非同期です。これを達成するには、CJS モジュール内で動的な
import()
を使用する必要があります。これは Promise を返します。通常はasync
関数で行われます。// esm-module.mjs export const message = 'Hello ESM from CJS!'; // cjs-app.js async function run() { const esmModule = await import('./esm-module.mjs'); console.log(esmModule.message); // Hello ESM from CJS! // デフォルト export の場合は esmModule.default になります } run();
-
混合パッケージ (デュアルパッケージハザード): CJS と ESM の両方をサポートするパッケージを公開する場合、「デュアルパッケージハザード」に直面します。これは、アプリケーションの異なる部分がパッケージの異なるインスタンスをロードし、予期しない動作 (例: シングルトンクラスが複数のインスタンスになる) を引き起こす可能性があります。
これを軽減するために、ライブラリは
package.json
のexports
フィールドを使用して、別々のエントリーポイントを提供することがよくあります。{ "name": "my-package", "main": "./lib/cjs/index.js", "module": "./lib/esm/index.mjs", "exports": { ".": { "import": "./lib/esm/index.mjs", "require": "./lib/cjs/index.js" }, "./package.json": "./package.json" }, "type": "commonjs" }
この構成により:
- ESM モジュールが
my-package
をインポートすると、lib/esm/index.mjs
がロードされます。 - CJS モジュールが
my-package
を要求すると、lib/cjs/index.js
がロードされます。
これにより、コンテキストに基づいて正しいモジュールの種類がロードされることが保証されます。
- ESM モジュールが
アプリケーションシナリオとベストプラクティス
- 新規プロジェクト: 一般的に、新しい Node.js プロジェクトは ESM を優先すべきです。これは標準であり、静的解析の利点があり、より広範な JavaScript エコシステムと連携します。
package.json
に"type": "module"
を設定してください。 - 既存の CJS プロジェクト: 大規模な CJS プロジェクトを ESM に移行することは、かなりの作業になる可能性があります。相互運用機能を使用して段階的に採用することが、多くの場合最も現実的なアプローチです。特に新しい機能については、コードベースの一部を徐々に ESM に変換してください。
- ライブラリ開発: ライブラリを構築している場合は、
package.json
のexports
フィールドを使用して CJS と ESM の両方をサポートすることを強く検討してください。これにより、最も幅広いユーザーにリーチし、デュアルパッケージハザードを回避できます。 - ツール: 一部の古い Node.js ツールやテストフレームワークは、ESM よりも CJS の方がサポートが優れている場合があることに注意してください。ツールチェーンのドキュメントで ESM の互換性を確認してください。
結論
Node.js での CommonJS と ES Modules の共存は、課題と機会の両方をもたらします。CJS は長い歴史を持ち、依然として広く普及していますが、ESM は静的解析やライブバインディングなどの重要な利点を持つ、標準化されたアプローチを提供する JavaScript モジュール性の未来を表しています。それらの根本的な違いを理解し、Node.js の組み込み相互運用機能を利用することで、開発者はこのデュアルモジュール環境を効果的にナビゲートできます。新しいプロジェクトで ESM を採用し、既存のプロジェクトで相互運用性を慎重に管理することは、より堅牢で保守可能で将来性のある Node.js アプリケーションにつながります。
ESM への移行は、環境全体で JavaScript のモジュールのストーリーを統合し、コード編成をより一貫性があり強力なものにします。