Node.js Dual Packages (CommonJS/ES Modules) に対応した npm パッケージの開発

こんにちは。フロントエンドエキスパートの平野(@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 はブラウザでも使うことができます。

caniuseのES Modulesの対応表

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 に関する対応や議論が行われています。 以下はその一例です。

今後、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" フィールドを参照するようになります。

github.com

.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 は作られているので将来的には対応されるかもしれません。

github.com

そのため、TypeScript を使った開発の場合、.js 拡張子を ESM として読み込む必要があるので、package.json の "type" フィールドを "module" にするか、拡張子を .mjs にする変換処理などの対応が必要になります。
これについては後述の方法でも解決できます。

require など CJS 特有の機能を使う

ESM では requireexportsmodule.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 配下のパッケージや fshttp など 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.jsrequire して 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";

github.com

ESM から使われることを Node.js v14.13 以上に限定するのであれば、今回の記事で紹介した ESM 対応は行う必要はありません。

今後の動向

ESM に関して Node.js のメンテナー間でも日々議論されており、ユーザーにとってもっと使いやすい仕様を考えたり実装しています。

今後の動向については Node.js コアの ES Modules ラベルやモジュールチームのリポジトリをウォッチするとキャッチアップできます。

github.com

github.com

まとめ

今回は Node.js で使えるようになった ESM の特徴や Dual Package(ESM/CJS)に対応する npm パッケージの作り方を紹介しました。

サイボウズでは OSS として npm パッケージをいくつか公開しています。今回紹介した ESM での配布も考えています。

github.com

サイボウズではプロダクトの開発だけではなく、プロダクトを支えるツールの開発も行っています。OSS の開発やプラットフォームのエコシステム開発にご興味ある方はぜひ以下の採用ページからご応募ください。

cybozu.co.jp