これは、なにをしたくて書いたもの?
JavaScriptのモジュールシステム…というかNode.jsで使うモジュールシステムはCommonJS Modulesをずっと使っていて、
ECMAScript Modulesについては触れてこなかったので1度情報を見ておこうかなと。
最近はJavaScriptを書く時はTypeScriptで書くようにしているのですが、いきなりトランスパイルを挟むとわからなくなる気がしたので、
今回はNode.js上でふつうにJavaScriptで扱うことにします。
TypeScript版のエントリーはこちらです。
TypeScript × Node.jsでECMAScript Modulesを試す - CLOVER🍀
Node.jsのモジュールシステム
Node.jsではCommonJS ModulesとECMAScript Modulesの2つのモジュールを扱います。
Modules: Packages | Node.js v20.16.0 Documentation
ECMAScript仕様では、以下にモジュールについて書かれています。
ECMAScript 2025 Language Specification / ECMAScript Language: Scripts and Modules / Modules
ドキュメントを見ると、import
を行った時にどちらのモジュールであるかを以下のように判断するようです。
- ECMAScript Modulesとして扱う場合
- 拡張子が
.mjs
である - 拡張子が
.js
の場合、最も近い親ディレクトリ階層にあるpackage.json
内のトップレベルのtype
フィールドの値がmodule
である --input-type=module
フラグ付きで、文字列が--eval
引数として渡された、または標準入力からnode
にパイプで渡された場合- 補足
--experimental-detect-module
フラグを使った場合でECMAScript Modulesとして正しい構文のみを含むコードの場合、それがどのように解釈されるべきか明示的なマーカーを持たない。動的import
の場合は、ECMAScript Modulesとして解釈されることはない
- 拡張子が
- CommonJS Modulesとして扱う場合
これらは明示的に判断できるケースで、他にも--experimental-default-type
フラグの値をもとにNode.jsがどちらのモジュールシステムを
デフォルトにするか決定するケースがあるようです。
.js
拡張子または拡張子のないファイルで、同じディレクトリまたは親ディレクトリにpackage.json
が存在しない.js
拡張子または拡張子のないファイルで、最も近い親ディレクトリ階層にあるpackage.json
にtype
フィールドが存在しない。ただし、ディレクトリがnode_moduels
内にある場合を除く- 下位互換性のため、
node_moduels
内でpackage.json
にtype
フィールドがないパッケージは--experimental-default-type
の値に関係なくCommonJS Modulesとして扱う
- 下位互換性のため、
--input-type
が指定されていない場合に、文字列が--eval
引数として渡された、または標準入力からnode
にパイプで渡された場合
Modules: Packages / Determining module system
ちなみに、--experimental-detect-module
と--experimental-default-type
の意味はそれぞれ以下です。
--experimental-detect-module
… あいまいな入力に対してECMAScript Modulesの構文が含まれているか判断し、検出されるとECMAScript Modulesとして扱う--experimental-default-type
… 拡張子が.js
、入力自体が文字列、package.json
がない、あってもtype
フィールドがない場合にECMAScript ModulesとCommonJS Modulesのどちらとして扱うか
各モジュールシステムのロードの動きは以下のようです。
- ECMAScript Modules
- 非同期
import
文およびimport()
式の処理を扱える- モンキーパッチは使えないが、ローダーフックを使ってカスタマイズが可能
- ディレクトリをモジュールとしてサポートしていないので、ディレクトリインデックス(例
./startup/index.js
)を完全に指定する必要がある - 拡張子の検索は行わず、相対または絶対URLで指定された場合でも拡張子を指定する必要がある
- JSONモジュールのロードはできるが、インポート時にアサーションが必要
- JavaScriptテキストファイルとしては、
.js
、.mjs
、.cjs
のみを受け付ける - CommonJS Modulesのロードが可能
- このようなモジュールは
cjs-module-lexer
により解析され、モジュールのURLを絶対パスに変換した後にCommonJSモジュールローダーによってロードされる
- このようなモジュールは
- CommonJS Modules
- 同期
require()
の呼び出しで処理を行う- モンキーパッチが可能
- ディレクトリをモジュールとして扱える
- 指定された値を解決する際に完全一致するものが見つからない場合は、拡張子(
.js
、.json
、最後に.node
)を追加してからディレクトリをモジュールとして解決しようとする .json
をJSONテキストファイルとして扱う.node
拡張子のファイルはprocess.dlopen()
でロードされたコンパイル済みアドオンモジュールとして扱われる.json
または.node
拡張子ではないファイルは、JavaScriptテキストファイルとして扱われる- ECMAScript Modulesをロードするために使用することはできない
- CommonJS ModulesからECMAScript Modulesをロードすることは可能
- ECMAScript ModulesではないJavaScriptファイルをロードすると、CommonJS Modulesとして扱われる
Modules: Packages / Modules loaders
動きとしての際はこんな感じのようです。
あとは各モジュールのNode.jsのドキュメントですね。
ECMAScript Modulesのドキュメントはこちら。
Modules: ECMAScript modules | Node.js v20.16.0 Documentation
CommonJS Modulesの方はこちらです。
Modules: CommonJS modules | Node.js v20.16.0 Documentation
ちなみに、Node.jsでECMAScript ModulesをサポートしたのはNode.js 12からのようです。
※それまでは実験的サポート
とりあえず、ECMAScript Modulesとして書いて使ってみましょう。まずは簡単にということで、追うとだいぶ深くなりそうなので…。
環境
今回の環境はこちら。
$ node --version v20.16.0 $ npm --version 10.8.1
Node.jsプロジェクトをECMAScript Modulesとして作成する
Node.jsプロジェクトを作成。
$ npm init -y Wrote to /path/to/hello-esm/package.json: { "name": "hello-esm", "version": "1.0.0", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "description": "" }
package.json
のtype
をmodule
にしておきます。
package.json
{ "name": "hello-esm", "version": "1.0.0", "main": "index.js", "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "description": "" }
Prettierくらいは入れておきます。
$ npm i -D prettier
"scripts": { "test": "echo \"Error: no test specified\" && exit 1", "format": "prettier --write src test" }, ... "devDependencies": { "prettier": "^3.3.3" }
設定。
.prettierrc.json
{ "singleQuote": true, "printWidth": 120 }
いくつかソースコードを書いてみましょう。
拡張子を.mjs
にして、明示的にECMAScript Modulesとして書いたもの。
src/calc.mjs
export function plus(a, b) { return a + b; } export function minus(a, b) { return a - b; }
拡張子は.js
にして、モジュールシステムの解決はpackage.json
のtype
に任せたもの。今回はtype
がmodule
なのでECMAScript Modules
として扱われることになります。
calc-resolve-type.js
export function plusAsResolveModuleType(a, b) { return a + b; } export function minusAsResolveModuleType(a, b) { return a - b; }
拡張子を.cjs
にして、明示的にCommonJS Modulesとして書いたもの。
src/calc-cjs.cjs
exports.plusAsCjs = (a, b) => { return a + b; }; exports.minusAsCjs = (a, b) => { return a - b; };
これらを呼び出すスクリプト。
src/run.js
import { plus, minus } from './calc.mjs'; import { plusAsResolveModuleType, minusAsResolveModuleType } from './calc-resolve-type.js'; import { plusAsCjs, minusAsCjs } from './calc-cjs.cjs'; console.log('use ECMAScript Modules(mjs) source'); console.log(` plus(1, 2) = ${plus(1, 2)}`); console.log(` minus(5, 2) = ${minus(5, 3)}`); const calc = await import("./calc.mjs"); console.log('use ECMAScript Modules(mjs) source as Dynamic import'); console.log(` plus(1, 2) = ${calc.plus(1, 2)}`); console.log(` minus(5, 2) = ${calc.minus(5, 3)}`); console.log('use ECMAScript Modules(js type module) source'); console.log(` plusAsResolveModuleType(1, 2) = ${plusAsResolveModuleType(1, 2)}`); console.log(` minusAsResolveModuleType(5, 2) = ${minusAsResolveModuleType(5, 3)}`); console.log('use CommonJS Modules(cjs) source'); console.log(` plusAsCjs(1, 2) = ${plusAsCjs(1, 2)}`); console.log(` minusAdCjs(5, 2) = ${minusAsCjs(5, 3)}`);
実行結果。
$ node src/run.js use ECMAScript Modules(mjs) source plus(1, 2) = 3 minus(5, 2) = 2 use ECMAScript Modules(mjs) source as Dynamic import plus(1, 2) = 3 minus(5, 2) = 2 use ECMAScript Modules(js type module) source plusAsResolveModuleType(1, 2) = 3 minusAsResolveModuleType(5, 2) = 2 use CommonJS Modules(cjs) source plusAsCjs(1, 2) = 3 minusAdCjs(5, 2) = 2
こちらで使っているのはimport
式で、Dynamic importと呼ばれているものですね。
const calc = await import("./calc.mjs"); console.log('use ECMAScript Modules(mjs) source as Dynamic import'); console.log(` plus(1, 2) = ${calc.plus(1, 2)}`); console.log(` minus(5, 2) = ${calc.minus(5, 3)}`);
モジュールを動的にロードすることができます。
CommonJS Modulesもimport
でロードしているのですが、
import { plusAsCjs, minusAsCjs } from './calc-cjs.cjs';
これはNode.jsのECMAScript Modulesのページにそのように書いてありました。
Modules: ECMAScript modules / Interoperability with CommonJS / CommonJS Namespaces
ちなみに、どのモジュール・ロード形態でもファイルの拡張子の指定は必須です。
import { plus, minus } from './calc.mjs'; import { plusAsResolveModuleType, minusAsResolveModuleType } from './calc-resolve-type.js'; import { plusAsCjs, minusAsCjs } from './calc-cjs.cjs'; const calc = await import("./calc.mjs");
Jestでテストを書いておく
最後にJestでテストを書いておきます。
Jestをインストール。
$ npm i -D jest
"scripts": { "test": "jest", "format": "prettier --write src test" }, ... "devDependencies": { "jest": "^29.7.0", "prettier": "^3.3.3" }
Jestの設定ファイルを作成。
$ npm init jest
拡張子が.mjs
で生成されました。ちょっと驚きました。
jest.config.mjs
/** * For a detailed explanation regarding each configuration property, visit: */ /** @type {import('jest').Config} */ const config = { clearMocks: true, collectCoverage: true, coverageDirectory: "coverage", coverageProvider: "v8", }; export default config;
作成したテストコード。.mjs
拡張子にしておきました。
test/calc.test.mjs
import { plus, minus } from '../src/calc.mjs'; import { plusAsResolveModuleType, minusAsResolveModuleType } from '../src/calc-resolve-type.js'; import { plusAsCjs, minusAsCjs } from '../src/calc-cjs.cjs'; test('use ECMAScript Modules(mjs) source', async () => { expect(plus(1, 2)).toStrictEqual(3); expect(minus(5, 2)).toStrictEqual(3); }); test('use ECMAScript Modules(mjs) source as Dynamic import', async () => { const calc = await import('../src/calc.mjs'); expect(calc.plus(1, 2)).toStrictEqual(3); expect(calc.minus(5, 2)).toStrictEqual(3); }); test('use ECMAScript Modules(js type module) source', async () => { expect(plusAsResolveModuleType(1, 2)).toStrictEqual(3); expect(minusAsResolveModuleType(5, 2)).toStrictEqual(3); }); test('use CommonJS Modules(cjs) source', async () => { expect(plusAsCjs(1, 2)).toStrictEqual(3); expect(minusAsCjs(5, 2)).toStrictEqual(3); });
テストを実行。
$ npm test > hello-esm@1.0.0 test > jest No tests found, exiting with code 1 Run with `--passWithNoTests` to exit with code 0 In /path/to/hello-esm 13 files checked. testMatch: **/__tests__/**/*.[jt]s?(x), **/?(*.)+(spec|test).[tj]s?(x) - 0 matches testPathIgnorePatterns: /node_modules/ - 13 matches testRegex: - 0 matches Pattern: - 0 matches
すると、テストとして認識されません…。
jest.config.mjs
に.mjs
や.cjs
も入るようにして、
// The glob patterns Jest uses to detect test files testMatch: [ "**/__tests__/**/*.?([cm])[jt]s?(x)", "**/?(*.)+(spec|test).?([cm])[tj]s?(x)" ],
再度実行。
$ npm test > hello-esm@1.0.0 test > jest FAIL test/calc.test.mjs ● Test suite failed to run Jest encountered an unexpected token Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax. Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration. By default "node_modules" folder is ignored by transformers. Here's what you can do: • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it. • If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config. • If you need a custom transformation specify a "transform" option in your config. • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option. You'll find more details and examples of these config options in the docs: https://jestjs.io/docs/configuration For information about custom transformations, see: https://jestjs.io/docs/code-transformation Details: /path/to/hello-esm/test/calc.test.mjs:1 ({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,jest){import { plus, minus } from '../src/calc.mjs' ^^^^^^ SyntaxError: Cannot use import statement outside a module at Runtime.createScriptFromCode (node_modules/jest-runtime/build/index.js:1505:14) ----------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s ----------|---------|----------|---------|---------|------------------- All files | 0 | 0 | 0 | 0 | ----------|---------|----------|---------|---------|------------------- Test Suites: 1 failed, 1 total Tests: 0 total Snapshots: 0 total Time: 0.265 s Ran all test suites.
すると、今度はJestがファイルをパースできないと言ってきます。
Jest encountered an unexpected token Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.
ECMAScript Modulesを使っている場合のURLが出ているので、こちらを見てみましょう。
Here's what you can do: • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
JestではECMAScript Modulesのサポートはまだ実験的段階みたいです。
Jest ships with experimental support for ECMAScript Modules (ESM).
対処としては、Transformerを構成するかnode
コマンドでフラグを指定して実行するようです。
今回はnode
コマンドに--experimental-vm-modules
フラグを指定して実行することにします。
"scripts": { "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "format": "prettier --write src test" },
実行。
$ npm test > hello-esm@1.0.0 test > node --experimental-vm-modules node_modules/jest/bin/jest.js (node:23623) ExperimentalWarning: VM Modules is an experimental feature and might change at any time (Use `node --trace-warnings ...` to show where the warning was created) PASS test/calc.test.mjs ✓ use ECMAScript Modules(mjs) source (4 ms) ✓ use ECMAScript Modules(mjs) source as Dynamic import (1 ms) ✓ use ECMAScript Modules(js type module) source ✓ use CommonJS Modules(cjs) source ----------------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s ----------------------|---------|----------|---------|---------|------------------- All files | 100 | 100 | 100 | 100 | calc-cjs.cjs | 100 | 100 | 100 | 100 | calc-resolve-type.js | 100 | 100 | 100 | 100 | calc.mjs | 100 | 100 | 100 | 100 | ----------------------|---------|----------|---------|---------|------------------- Test Suites: 1 passed, 1 total Tests: 4 passed, 4 total Snapshots: 0 total Time: 0.297 s, estimated 1 s Ran all test suites.
Node.jsから実験的機能を使ったことによる警告が出ているものの、とりあえず実行はできました。
(node:23623) ExperimentalWarning: VM Modules is an experimental feature and might change at any time (Use `node --trace-warnings ...` to show where the warning was created)
今回はここまでにしておきましょう。
おわりに
Node.jsでECMAScript Modulesを試してみました。
まずはCommonJS ModulesとECMAScript Modulesの違いを少し見るところからでしたが、実際に使ってみたり周辺のエコシステムの
状況を気にしつつ使うかどうか決めていく感じなんでしょうね。
拡張子をどうするかも困り物で、package.json
のtype
指定に任せて拡張子自体は.js
にした方がいいのかな?とか思うのですが
どうなのでしょう。
いずれECMAScript Modulesが主流になっていくと思うのですが、様子を見つつ使っていきましょう。
次はTypeScript越しに使ってみたいと思います。
結果はこちらです。