こんにちは。フロントエンドエキスパートの平野(@shisama_)です。
フロントエンドエキスパートチームでは業務時間の 30 % の時間で技術探究を行っています。
今回は探究した技術の中から Node.js の ES Modules(以下 ESM)についてと Dual Package (CommonJS/ES Modules) に対応した npm パッケージの開発について紹介します。
ES Modules の特徴
まず ESM の概要を簡単に紹介します。
ESM は ES2015 から仕様に入ったモジュールシステムです。 仕様は ECMAScript の 15.2 Modules に記載されています。
ESM では次のようにモジュールを読み込むことができます。
import { KintoneAPIClient } from "@kintone/rest-api-client";
しかし、Node.js では古くから require
関数を使ってモジュールを読み込む CommonJS Modules(以下 CJS) を採用していました。
const { KintoneAPIClient } = require("@kintone/rest-api-client");
CJS と ESM はモジュールを読み込むという面で似ていますが、比べると ESM には次のような特徴があります。
ESM (import) | CJS (require) |
---|---|
ブラウザ互換 | Node.js でしか動かない |
厳格(Strict モード) | 厳格ではない |
非同期 | 同期 |
静的解析可能 | 静的解析が不可能 |
ESM はブラウザ互換
ESM はブラウザでも使うことができます。
https://caniuse.com/es6-module
ESM に限らず Node.js のコアモジュールはブラウザとの互換性を重視しています。(互換性のないものもいくつかありますが...)
Node.js の ESM の仕様も ECMAScript の仕様に合わせるようにすることで、ブラウザとの互換性を保つようにしています。
ESM は Strict モード
JavaScript はファイルの先頭に 'use strict';
と記載することで Strict モードになりますが、ESM では Strict モードがデフォルトで有効になります。
Strict モード に関しては MDN のページをご確認ください。
Strict モード - JavaScript | MDN
ESM は非同期
ESM では各モジュールのローディングとパースの処理が非同期に並列で行われます。
require
は同期関数なので、1 つのモジュールの処理が終わるまで他のモジュールの処理は開始されません。ローディングは実行時に順次行われます。
ESM は静的解析可能
require
関数は実行するまで解析できないことがあります。これは require
関数がファイルのローディングを実行時に行うことに起因しています。
一方、ESM は V8 など JS エンジンがパースするときに import
するモジュールを解析します。また、import
するモジュールがさらに import
しているモジュールの解析まで非同期で行います。なので、import
するファイルのパスに誤りがあった場合、パースの時点でエラーになるので早めに気づくことができます。
Node.js の ESM 対応について
実は Node.js v8 から ESM を使うことが出来ます。v8 ~ v12 までは実行時に --experimental-modules
フラグをつける必要がありましたが、v12.17.0 からはフラグなしでも実行できます。
https://nodejs.org/en/blog/release/v12.17.0/
まだドキュメント上は Experimental となっていますが、Node.js v14 がメンテナンスされている間に警告も消せるように Node.js のモジュールチーム主体で取り組んでいます。
https://github.com/nodejs/modules/blob/master/doc/meetings/2020-03-11.md#stability-and-flags
また、著名な npm パッケージも ESM に関する対応や議論が行われています。 以下はその一例です。
- webpack: https://github.com/webpack/webpack/pull/10953
- Rollup: https://github.com/rollup/rollup/pull/3391
- Preact: https://github.com/preactjs/preact/pull/2283
- Jest: https://github.com/facebook/jest/issues/9430
今後、ESM に対応したパッケージは増え続けると予想しています。
Dual Package(CJS/ESM)に対応した npm パッケージの開発
ここからは Dual Package に対応した npm パッケージの開発方法について解説します。 ESM と CJS に対応したパッケージのことを Dual Package と呼びます。
Conditional Exports によるファイルの指定
古いバージョンの Node.js は ESM のファイルを実行できません。CJS からも使えるように互換性を維持しつつ ESM としもパッケージ配布する仕組みが用意されています。
例えば、以下のディレクトリ構成のパッケージがあったとします。
node_modules/sample-package ├── package.json └── lib ├── cjs.js ├── esm.mjs └── utils
上記の例では CJS のエントリポイントとして cjs.js
を、ESM のエントリポイントとして esm.mjs
を用意しています。
ユーザーは以下のようにエントリポイントのファイルを直接指定することで読み込むことができます。
// ESM import { run } from "sample-package/lib/esm.mjs"; // CJS const { run } = require("sample-package/lib/cjs.js");
しかし、ユーザーにパッケージ内部のファイルを参照してもらう必要があり、ユーザーにとっては使いにくいです。
Conditional Exports という機能を使うことで、内部のファイルを参照せずに ESM、CJS 両方からパッケージを使うことができるようになります。
https://nodejs.org/api/packages.html#packages_conditional_exports
配布するパッケージの package.json に "exports"
フィールドを追加します。"exports"
内に "import"
や "require"
フィールドを定義し ESM と CJS のエントリポイントを指定します。
フォールバック先として "default"
、CJS と ESM のどちらのファイルでも設定できる Node.js 用の "node"
もあります。
この "exports"
に書くフィールドの順序には注意しなければいけません。条件のマッチングでは先に書かれたファイルが優先されます。また、"node"
は "import"
と "require"
の後に書き、"default"
は最後に書かなければいけません。
{ "exports": { "import": "./lib/esm.mjs", "require": "./lib/cjs.js", "node": "./lib/esm.mjs", "default": "./lib/cjs.js" } }
上記のようにパッケージ側が "exports"
を定義しておけばユーザー側は ESM と CJS の両形式で使用できます。
// CJS から読み込む場合は ./node_modules/sample-package/lib/cjs.js が読み込まれる const foo = require("foo"); // ESM から読み込む場合は ./node_modules/sample-package/lib/esm.mjs が読み込まれる import foo from "foo";
この Conditional Exports は Dual Package 対応以外にも次のようにパスのエイリアスを指定することも可能です。
また、ブラウザや Electron など実行環境ごとに読み込むファイルを変更するために使うことも考えられています。
https://nodejs.org/api/esm.html#esm_conditional_exports
{ "main": "./main.js", "exports": { ".": { "node": "./index-node.js", "browser": "./index-browser.js", "default": "./index.js" }, "./feature": { "node": "./feature-node.js", "browser": "./feature-browser.js", "default": "./feature.js" } } }
webpack も v5 からこの "exports"
フィールドを参照するようになります。
.mjs と .cjs
Node.js では ESM と CJS のファイルの判定を拡張子で行っています。
拡張子を .mjs
とした場合 ESM のファイルとして読み込まれます。.js
はこれまで通り CJS として読み込まれます。また、.cjs
という拡張子も CJS として読み込まれます。
しかし、ESM のファイルの拡張子に .mjs
よりも .js
を使いたいという声は多く Node.js のメンテナー間でも議論されました。結果としては package.json に "type": "module"
を指定することで .js
拡張子のファイルを ESM として実行にすることができようになりました。ただし CJS は拡張子を .cjs
にする必要があります。
反対に .js
ファイルを CJS として読み込ませることを明示するために "type": "commonjs"
とすることもできます。
{ "type": "module", "exports": { "import": "./lib/esm.js", "require": "./lib/index.cjs" } }
もう 1 点注意すべきなのは、TypeScript や Babel などを使って開発しているとトランスパイル後のファイルの拡張子が .js
になることです。
前述の通り、ESM として import
する場合 .mjs
の拡張子にする必要があります。ですが、TypeScript は .mjs
での出力には現在対応していません。
issue は作られているので将来的には対応されるかもしれません。
そのため、TypeScript を使った開発の場合、.js
拡張子を ESM として読み込む必要があるので、package.json の "type"
フィールドを "module"
にするか、拡張子を .mjs
にする変換処理などの対応が必要になります。
これについては後述の方法でも解決できます。
require など CJS 特有の機能を使う
ESM では require
、exports
、module.exports
, __filename
, __dirname
といった Node.js 特有のグローバル変数や関数は ESM の仕様上使えません。
しかし、互換性のためにも Node.js ではこれらを使うための API を提供しています。
次のように module.createRequire
関数を使うことで require
関数を生成できます。
ただし、CJS の require
関数と同じく同期関数なので、ESM 内で使われていても require
の処理は終わるまで待ちます。
import module from "module"; const require = module.createRequire(import.meta.url); const packageJson = require("../package.json");
__filename
と __dirname
は以下のように fileURLToPath
関数や dirname
関数を使って生成できます。
import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);
https://nodejs.org/api/esm.html#esm_no_require_exports_module_exports_filename_dirname
ESMから CJS ファイルを require する
前述のとおり ESM でも require
関数を生成できるので、 CJS で書いたコードをそのまま利用できます。
なので少しずつ CJS から ESM へ移行することも可能です。
// foo.cjs module.exports.getBar = (id) => {...};
// index.mjs import module from "module"; const require = module.createRequire(import.meta.url); const { getBar } = require("./foo");
拡張子の問題
TypeScript や Babel で CJS にトランスパイルする場合は以下のように import
のファイルパスの拡張子を省略していても正常に動作します。
// トランスパイル前 import foo from "./foo"; // トランスパイル後 (CJS) const foo = require("./foo");
しかし、ESM ではファイルパスを明記する仕様になっているため拡張子を省くことはできません。Node.js もこの仕様に従っているため CJS のように拡張子を省略することはできません。
import foo from "./foo"; // 実行時にエラーになる
ファイルパスまで明記する仕様により読み込むファイルを必ず一意に決めることができます。
もし、 ESM でも拡張子が省略できた場合、拡張子が違う同じ名前のファイルが存在したらどちらが優先されて読み込まれるかはコードからは読み取れません。どのファイルが読み込まれるかはランタイムによって変わる可能性があります。ESM ではファイルパスを拡張子まで書く仕様になっているためこういった問題は起きません。
// もし ESM でも拡張子を省略できたら... import foo from "foo"; // どちらが読み込まれるかはわからない(ランタイム次第) ├── foo.js └── foo.json
CJS では Node.js の仕様により読み込まれるファイルの優先順は決まっています。Node.js 特有の仕様のためブラウザとの互換性はありません。
https://nodejs.org/api/modules.html#modules_all_together
ここまでファイルの拡張子まで正確に書く必要があると説明してきましたが、例外があります。
Node.js では ESM でも node_modules
配下のパッケージや fs
や http
など Node.js のコアモジュールについては拡張子や相対パスの指定は不要です。
// Node.js のコアモジュールの読み込み import fs from "fs";
// node_modules 内のパッケージの読み込み import _ from "lodash";
既存の CJS を require して拡張子解決する
前述の「ESM 内で require 関数を使う方法」で紹介したように ESM でも require
関数を使うことができます。
require
関数の引数のファイルパスは拡張子を省略できるので、その仕組みを利用して既存の CJS ファイルを読み込んで ESM で export するファイルを作成します。
たとえば次のような構造のパッケージにするとします。
node_modules/sample-package ├── package.json └── lib ├── cjs.js // 既存のCJSファイル ├── esm.mjs // ESM で書かれたファイル └── api.js // cjs.js から読み込まれるファイル
esm.mjs
は次のように ./cjs.js
を require
して export
するだけの ESM ファイルを作り、Conditional Exports で ESM として配布します。
// esm.mjs import module from "module"; const require = module.createRequire(import.meta.url); export const { Foo } = require("./cjs");
{ "exports": { "import": "./lib/esm.mjs", "require": "./lib/cjs.js" } }
この方法は既存の CJS をそのまま活用できるため非常に簡単に ESM 対応ができます。
モジュールバンドラーによる拡張子解決
import
しているモジュールのコードを 1 つのファイルにバンドルしてしまえば、実行ファイルから import
文を削除できます。
次に Rollup を使って esm.mjs
というファイルにバンドルする例を載せます。
export default { input: "./src/main.js", output: { file: "./lib/esm.mjs", format: "esm", }, plugins: [resolve(), commonjs()], };
このような設定で 2 ファイルをバンドルしてみます。
// main.js import { random } from './maths.js'; export const randomSelect = (arr) => { return arr[random(arr.length)]; }
// maths.js export const random = (size) => { return Math.floor((Math.random() * size)); }
Rollup で ESM 形式でバンドルすると次のように出力されます。
// esm.mjs const random = (size) => { return Math.floor((Math.random() * size)); }; const randomSelect = (members) => { return members[random(members.length)]; }; export { randomSelect };
バンドルしたファイルを Conditional Export を使って ESM として配布することでユーザーは ESM からパッケージを import
することができます。
{ "exports": { "import": "./lib/esm.mjs", "require": "./lib/cjs.js" } }
Preact も同様に developit/microbundle を使って 1 つの .mjs
ファイルにバンドルして ESM ファイルを配布しています。
preact/package.json at master · preactjs/preact · GitHub
CJS を直接 import する
実はこれまで紹介した ESM 対応の方法を使わなくても CJS で配布されているパッケージを ESM から直接 import
することができます。
import mod from "cjs-module"; const { someFunc } = mod; // named exports されている場合
Node.js v14.13 からは named exports も直接 import
できるようになるので、すべての CJS を ESM から直接 import
可能になります。
import { someFunc } from "cjs-module";
ESM から使われることを Node.js v14.13 以上に限定するのであれば、今回の記事で紹介した ESM 対応は行う必要はありません。
今後の動向
ESM に関して Node.js のメンテナー間でも日々議論されており、ユーザーにとってもっと使いやすい仕様を考えたり実装しています。
今後の動向については Node.js コアの ES Modules
ラベルやモジュールチームのリポジトリをウォッチするとキャッチアップできます。
まとめ
今回は Node.js で使えるようになった ESM の特徴や Dual Package(ESM/CJS)に対応する npm パッケージの作り方を紹介しました。
サイボウズでは OSS として npm パッケージをいくつか公開しています。今回紹介した ESM での配布も考えています。
サイボウズではプロダクトの開発だけではなく、プロダクトを支えるツールの開発も行っています。OSS の開発やプラットフォームのエコシステム開発にご興味ある方はぜひ以下の採用ページからご応募ください。