Quantcast
Channel: Cybozu Inside Out | サイボウズエンジニアのブログ
Viewing all articles
Browse latest Browse all 681

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

$
0
0

こんにちは。フロントエンドエキスパートの平野(@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を用意しています。 ユーザーは以下のようにエントリポイントのファイルを直接指定することで読み込むことができます。

// ESMimport{ run } from "sample-package/lib/esm.mjs";

// CJSconst{ 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": {"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.mjsimport 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.mjsimport module from "module";
const require = module.createRequire(import.meta.url);
exportconst{ Foo } = require("./cjs");
{"exports": {"import": "./lib/esm.mjs",
    "require": "./lib/cjs.js"
  }}

この方法は既存の CJS をそのまま活用できるため非常に簡単に ESM 対応ができます。

モジュールバンドラーによる拡張子解決

importしているモジュールのコードを 1 つのファイルにバンドルしてしまえば、実行ファイルから import文を削除できます。

次に Rollup を使って esm.mjsというファイルにバンドルする例を載せます。

exportdefault{
  input: "./src/main.js",
  output: {
    file: "./lib/esm.mjs",
    format: "esm",
  },
  plugins: [resolve(), commonjs()],
};

このような設定で 2 ファイルをバンドルしてみます。

// main.jsimport{ random } from './maths.js';

exportconst randomSelect = (arr) => {return arr[random(arr.length)];
}
// maths.jsexportconst random = (size) => {return Math.floor((Math.random() * size)); 
}

Rollup で ESM 形式でバンドルすると次のように出力されます。

// esm.mjsconst 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


Viewing all articles
Browse latest Browse all 681

Trending Articles