CLOVER🍀

That was when it all began.

Node.jsでECMAScript Modulesを試す

これは、なにをしたくて書いたもの?

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として扱う場合
    • 拡張子が.cjsである
    • 拡張子が.jsの場合、最も近い親ディレクトリ階層にあるpackage.json内のトップレベルのtypeフィールドの値がcommonjsである
    • --input-type=commonjsフラグ付きで、文字列が--eval--printの引数として渡された、または標準入力からnodeにパイプで渡された場合

これらは明示的に判断できるケースで、他にも--experimental-default-typeフラグの値をもとにNode.jsがどちらのモジュールシステムを
デフォルトにするか決定するケースがあるようです。

  • .js拡張子または拡張子のないファイルで、同じディレクトリまたは親ディレクトリにpackage.jsonが存在しない
  • .js拡張子または拡張子のないファイルで、最も近い親ディレクトリ階層にあるpackage.jsontypeフィールドが存在しない。ただし、ディレクトリがnode_moduels内にある場合を除く
    • 下位互換性のため、node_moduels内でpackage.jsontypeフィールドがないパッケージは--experimental-default-typeの値に関係なくCommonJS Modulesとして扱う
  • --input-typeが指定されていない場合に、文字列が--eval引数として渡された、または標準入力からnodeにパイプで渡された場合

Modules: Packages / Determining module system

ちなみに、--experimental-detect-module--experimental-default-typeの意味はそれぞれ以下です。

各モジュールシステムのロードの動きは以下のようです。

  • 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)を追加してからディレクトリをモジュールとして解決しようとする
    • .jsonJSONテキストファイルとして扱う
    • .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.jsontypemoduleにしておきます。

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.jsontypeに任せたもの。今回はtypemoduleなので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)}`);

import() - JavaScript | MDN

モジュールを動的にロードすることができます。

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.

ECMAScript Modules · Jest

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.jsontype指定に任せて拡張子自体は.jsにした方がいいのかな?とか思うのですが
どうなのでしょう。

いずれECMAScript Modulesが主流になっていくと思うのですが、様子を見つつ使っていきましょう。

次はTypeScript越しに使ってみたいと思います。

結果はこちらです。

TypeScript × Node.jsでECMAScript Modulesを試す - CLOVER🍀