sosukesuzuki.dev

2022年 sosukesuzuki 1人アドベントカレンダー

December, 15 2022

Prettierのあまりに行儀の悪い Pure ESM パッケージ対応

2022年の sosukesuzuki 1人アドベントカレンダー 15 日目です。

期末テストやらなんやらで忙しく、一週間くらいアドベントカレンダーをさぼってしまいました。この記事も電車の中で書いています。

さて、今日は Node.js ライブラリにおける Pure ESM 対応の話です。

前提

Prettier で採用していた方法を紹介する目的でこの記事を書いています。

Prettier は npm にパブリッシュするときには esbuild でバンドルしていますが、ソースコードは CommonJS Modules で記述されていて Node.js からそのまま実行できます。これは GitHub から直接 Prettier をインストールした場合でも Node.js で動作するようにするためです。

こういったケースはほとんどないと思いますので、ここで紹介する手法で有効である場合は少ないでしょうし、後述しますがデメリットが多いので普通にやらないほうがよいと思います。

世は ECMAScript Modules 時代に突入(?)

2021 年 4 月に、Node.js 10 が EoL を迎えました。これはつまり多くのライブラリが Node.js 12 以降のみをサポートするようになるということです。

そして Node.js 12 は ECMAScript Modules をネイティブにサポートしています。

これはつまり、世はまさに ESM 時代!!

というのは大げさかもしれませんが、少なくとも Sindre Sorhus 氏は ESM 時代に突入したようです。

Sindre 氏は数多くの Node.js のライブラリの作者として知られています。Sindre 氏は Node.js の ESM を強く推進していることでも知られていて、彼の作成したパッケージは徐々に ESM のみが提供されるようになっていきました(もちろん、セマンティックバージョニングに従う形で)。このような、ESM のみを提供し CJS としては提供されないパッケージのことを Pure ESM パッケージと呼んだりします。

Node.js の ESM というのは微妙に面倒くさくて、Pure ESM パッケージは、基本的には ESM からしか読み込むことができません(後述しますが、例外もある)。

つまり Sindre Sorhus 氏のライブラリを使いたい場合は、自分たちのライブラリも ESM へと移行する必要があります。

ESM へと移行するのはめんどくさい

しかし ESM への移行というのは面倒なものです。ざっと考えただけでも、次のような点で面倒です。

  • すべてのファイルの requiremodule.exportsimportexport へと適切に書き換える必要があり、コードサイズがでかいとだるい
  • 動的に呼び出される require をそのまま import 式へと書き換えることはできない(import 式は Promise を返すため)
  • パッケージの配布方法やビルドスクリプトを変更しなければならない場合がある

詳しい ESM への移行方法については、Wantedly Engineer Blog の 実践 Node.js Native ESM — Wantedly でのアプリケーション移行事例 に大変よくまとまっています。

残されたいくつかの選択肢

ここで我々にはいくつかの選択肢があります。

  • Sindre Sorhus 製のライブラリは CommonJS をサポートする最後のバージョンを使い続ける
  • Sindre Sorhus 製のライブラリと同じようなライブラリを探す or フォーク or 自作
  • 重い腰をあげて ESM へと移行する

どれも微妙に面倒くさいし、あまりおもしろくありません。

ということで、ここで第 4 の選択肢「気合で CommonJS から ESM を読みこむ」を考えました。

気合で CJS から ESM を読み込む

基本的には CommonJS から ESM を require することはできません。import 式を使ってモジュールを読み込むことはできますが、すべて Promise になってしまうのでやりたくありません。

ではどうするかのかというと、Pure ESM パッケージの方を CommonJS に変換します。

いろんな方法があると思いますが、Prettier では次の方法を採用しました。

  • https://github.com/prettier/prettier/blob/08a51db63f34895c58471857cd55740a8f85d8ab/scripts/vendors/vendors.mjs に Pure ESM パッケージの名前をリストしておく。
    • const vendors = ["pure-esm-package-1", "pure-esm-package-2", ..., "pure-esm-package-n"];
  • この vendors にリストされているパッケージを全部 esbuild で CommonJS に変換する!
    • require.resolve("pure-esm-package") をエントリポイントにして ESM で書かれたパッケージを CJS へと変換する
  • バンドルして生成された CJS ファイルを ./vendors に配置する
    • ついでに型定義ファイルも(存在すれば)いい感じに生成しておく
    • (ライセンス情報は残しておく)

というようなことをやってくれるスクリプトが https://github.com/prettier/prettier/tree/08a51db63f34895c58471857cd55740a8f85d8ab/scripts/vendors にあります。

図にするとこんな感じです。./node_modules には各ライブラリは ESM として存在していますが、それを CJS に変換したものが ./vendors に存在しています。

Pure ESM パッケージを esbuild を使って CJS へと変換する

ただ、depandabot 等によって package.json が更新されると ./node_modules./vendors の間でバージョンの整合性がとれなくなってしまいます。なのでそのようなことが発生したら CI が落ちるようにして気付けるようになっています。

この方法のダメな点

この方法は普通にダメです。

  • 謎のビルドレイヤーが挟まってデバッグしにくい
  • 謎のビルドレイヤーのメンテコストが発生する
  • ESM パッケージを全部 CJS にしてしまうので、Tree Shaking は期待できない
  • 標準の仕組みに乗っかれていないのはもにょる
  • ビルド職人みたいな人がいないと難しい

やめよう

ということで Prettier 3.0 ではソースコード自体が ESM で書かれるようになるので、この謎の仕組みは撤廃です。

この仕組を導入したのは筆者なので、撤廃できることになってうれしいです。