👯

Vite 3 が採用した CJS Proxy による Dual Package 構成

2022/07/20に公開

2022 年現在、Node.js 界隈は ESM (ES Module) への移行の過渡期であり、特に既存プロジェクトにおいては CJS (CommonJS) 構成であることがまだまだ多い。また、Pure ESM な npm パッケージは、CSJ プロジェクトからの利便性が悪く、dynamic import が必須で非同期化を強いられたり TypeScript の設定変更が必要だったりと面倒が増える。

そんな状況のため、広く使われたい npm パッケージを公開する場合、importでもrequireでも利用できるよう dual package 構成を取りたくなるが、定番手法が確立しているとは言えず、意外と面倒が多い。よく見るのは、tscesbuild等で ESM 用と CJS 用の設定ファイルを用意してそれぞれ別ディレクトリに出力し、パッケージに両方同梱して conditional exports で出し分ける手法。しかし、依存関係に Pure ESM な npm パッケージを含む場合、CJS 系でrequire(esm)に変換してしまうと動作しない。元のソースで dynamic import を使うなど工夫が必要になってしまう。

どうしたものかなと思っていたら、最近 Vite 3 のリリースノートで「CJS Proxy を使って ESM/CJS 両対応したよ」と書いてあった。覗いてみたらこの問題の解決のヒントがありそうだったの紹介してみる。該当プルリクは以下。

https://github.com/vitejs/vite/pull/8178

CJS Proxy とは

CJS Proxy とは、npm の dual package 構成の実現方法の一つで、export された非同期関数に対して、CJS から dynamic import 経由で ESM 実装を参照させる手法。同期的な export に対しては、従来と変わらず esbuild 等で CJS に変換してバンドルする。

Pros

  • 依存関係に Pure ESM パッケージがあっても CJS からrequire()で読める
  • ESM で書いたオリジナルのソースを変更する必要がない(dynamic import への書き換えなどが不要)
  • ESM/CJS 両方のソースをバンドルする dual package 構成に比べて npm パッケージのサイズを多少削減できる

Cons

  • 同期的な export には使えない
  • 専用のエントリーポイント が必要
  • exports の管理が煩雑

実現方法

以下にサンプルプロジェクトを置いたので詳細はこちらを参照。
https://github.com/teppeis/dual-package-cjs-proxy

type:moduleなプロジェクトで、CJS からのエントリポイントとして以下のようなindex.cjsを用意する。

index.cjs
// Bind sync exports
Object.assign(module.exports, require("./dist/bundleSync.cjs"));

// Proxy for exported async functions
const asyncFunctions = ["asyncFunc1", "asyncFunc2"];
asyncFunctions.forEach((name) => {
  module.exports[name] = (...args) =>
    import("./dist/index.js").then((ns) => ns[name](...args));
});

1 行目のObject.assignでは、esbuild 等で変換した CJS ファイルbundleSync.cjs(後述)を読み込み、同期的な exports を露出させる。

2 行目以降が CJS Proxy の本体。export したい非同期関数に対して、 dynamic import で ESM 用のエントリポイントを読んでから関数の実体を呼び出すような Proxy として CJS に露出する。元々が非同期関数であるため、dynamic import を挟んでも(多少実行は遅くなるかもしれないが)インターフェイスに影響はない。

bundleSync.cjsは、同期的な exports だけを列挙したエントリポイントを用意し、CJS 化とバンドルを行なって生成する。サンプルプロジェクトでは esbuild を使った。ツールは適当に選べばよくて、Vite 3 では rollup を叩いていた。

exportSyncForCJS.ts
// Export only synchronous functions or values for CommonJS
export * from "./sync1.js";
export * from "./sync2.js";

あとはpackage.jsonで conditional exports を普通に構成すればよい。

package.json
{
  "type": "module",
  "main": "./index.cjs",
  "types": "./dist/index.d.ts",
  "exports": {
    "types": "./dist/index.d.ts",
    "import": "./dist/index.js",
    "default": "./index.cjs"
  },
}

これは.cjsや conditional exports をサポートする Node.js v12 以降で動作する。また、TypeScript についても、typesを指定することで(index.cjsが動的な exports であるにも関わらず)型チェックや VS Code での補完がちゃんと効く。ESM や conditional exports に対応していない TypeScript 4.7 未満でも、フォールバックがあるため期待通りに動作する。

雑感

あんまり積極的に運用したいとは思わないかな。手法として知っておくといつか役立つかも系。気になったのは以下。

exports の管理めんどい。自動化できる?

見てわかる通り、exports のリストを手動管理する必要があって面倒だし更新漏れも発生しそう。TypeScript AST を使えばうまく自動化できるだろうか?

依存先が Pure ESM かどうか判別するのが面倒

この CJS Proxy に限らず、CJS 構成や dual package 構成では依存先が Pure ESM かどうかを常にチェックする必要がある。ESM で書く利点として、参照先が ESM か CJS かを気にせずimportできる点があるはずなのに、dual package 構成ではそれがスポイルされてしまう。どうせそれを調べる必要があるなら CJS Proxy なんて面倒な仕組みより、最初から dynamic import で書いてしまった方が楽なのでは?という気がしないでもない。負けた気はするけど。

そもそもなんでこんなに頑張って dual package にしようとしてたんだ?Pure ESM にしてしまえば楽なのでは?そうか、こうして人は Pure ESM に染まっていくのか、という気持ちが分かってくる。

Discussion