🪢

es-module-lexerで読み解く依存関係解析

に公開

はじめに

es-module-lexerとはなんでしょうか。es-module-lexerはビルドツールやプラグインでよく使われます。私はプライベートでAstroを愛用し、業務ではKnipを組み込んでいたりします。それらは、当然es-module-lexerを使用しているのです。

小さなライブラリであるes-module-lexerが大規模ソースコードを瞬時に解析するのを目の当たりにしたとき、私は大きな衝撃を受けました。本稿ではその実装をひも解きつつ、KnipとAstro+Viteにおける活用例を整理した備忘録としてまとめています。

依存解析の 2 つのアプローチ: Parser と Lexer

全部読むFull Parser型と場所だけ見るLexer型という2つの流派があります。理解するためにFull Parser型の代表としてBabel、AcornとLexer型の代表としてes-module-lexerで対比してみましょう。

Babel、Acornは、本を頭から読み込み、章や節を整理し、どの場面で誰が何をしているかまで細かく把握して目次を作る全文読破型です。すべてを理解するため、時間や手間もかかります。その結果できあがる目次は物語の流れや背景まで丸ごとわかる立派なものになります。

一方、es-module-lexerは、とにかくページをめくるスピード勝負の索引作成型です。めくりながら”import”や”export”といった単語を見つけた瞬間、そこへ付箋を貼り “何ページのどこにあるか” だけをメモして次のページへ進みます。文章の内容や登場人物の関係は一切読まないのです。大事なのは”場所”だけ。だから膨大なページでもあっという間に仕事が終わります。

For an example of the performance, Angular 1 (720 KiB) is fully parsed in 5 ms, in comparison to the fastest JS parser, Acorn which takes over 100 ms.

Acornは70万行を超えるAngular 1のバンドルを約100ms秒で走査します。ですが、es-module-lexerは70万行を超えるAngular 1のバンドルを約5ms秒で走査するほどです。

内部 — 状態遷移と実装ポイント

UTF-16文字列を先頭から順に走査します。コメント、文字列、テンプレートリテラルに入れば終端までジャンプ、通常コードに戻った瞬間だけ i m p o r te x p o r t との照合をします。これを状態遷移テーブルで機械的にこなすのです。

結果として得られるのは以下の通りです。

[
  { s: 1024, e: 1047, t: "static" },
  { s: 2096, e: 2122, t: "dynamic" },]

という“開始オフセットs(:start)・終了オフセットe(:end)・静的/動的フラグt”だけの配列。メモリ確保は最小で、Angular 1の70万行バンドルでも約5 msで走査を終えるのも納得です。

先述したAstroやKnipがes-module-lexerを採用するのは、“座標だけ測量する” という哲学がビルド体験を劇的に短縮するからにほかなりません。

では、その5 msスキャナはどのように組み込まれているのか、実際の開発現場で何を変えたのでしょうか。KnipとAstro+Viteの2つの事例を見ていきます。

Knip — 依存関係の枝管を閉じる

Knipはプロジェクト内の使われていないコードやライブラリを見つけて警告するツールです。使わないものを早めに捨てることで、リポジトリが散らからず、ビルドが速く終わります。

モノレポを長く運用していると、削除したはずの機能が置き土産に残していった import 行や、参照先を失った依存パッケージが静かに増殖します。CIでは pnpm install が毎回余計なアーカイブを解凍し、開発サーバーではViteの事前バンドル対象が膨れ上がって再起動が遅くなることはあります。そんな詰まりがいつのまにか日常になるのがよくある悩みです。

Knipが担うのは、この隠れた負債を機械的に洗い出し、mainブランチに混入させないための最終関門としてCIパイプラインに常駐してくれます。

仕組みはいたってシンプルです。Knipはes-module-lexerを呼び出して全ソースを数ミリ秒でスキャンし、importexport の位置だけを付箋のように集めます。そこから依存グラフを逆方向にたどり、いずれのノードからも一度も参照されていないファイルやパッケージを抽出します。解析対象を「本番バンドルに含まれるファイル」に限定すれば、テスト用コードや実験ディレクトリは自然に外れるので誤検知も最小限に抑えられます。

CIでは未使用項目が一件でも見つかった瞬間にジョブを失敗させる設定にしておくと、プルリクエストの段階で警告が返り、不要な依存がメインブランチへ滑り込む前に開発者が対処できます。

Knipの解析コストは巨大リポジトリでもせいぜい数ミリ秒しかかかりませんが、その効果は継続的に現れます。余計なパッケージがロックファイルから排除され、node_modules のサイズが膨らまないため、CIのインストール時間とキャッシュサイズがじわじわ減っていきます。同時にViteの optimizeDeps キャッシュがクリーンなまま保たれ、開発サーバーの初回起動やホットリロードも軽くなります。つまりKnipはes-module-lexerで得た座標情報を武器に、モノレポの伸び放題の枝管を定期的に閉栓し、長期的な開発速度を守るガードレールとして機能するわけです。

Astro が実現する “最小 JS”

巨大なコンポーネントをまとめてブラウザへ送る従来のSPA方式では、最初の1ページを表示するだけでも数MByteのJavaScriptをダウンロードして解析や実行をする必要があります。
ページの大半は静的HTMLで済むにもかかわらず"初回からJSを抱えて"配信します。そのため、開発サーバーの起動に時間がかかります。ユーザーも白い画面を長く待つことになるでしょう。Astroが掲げるIsland Architectureは、動く部分だけを小さな島として後からハイドレートする設計です。このアプローチを実現する上で、どのファイルをすぐに送るのか、どれをあとで送るかをビルド時点で瞬時に判断します。不要なJavaScriptを初めから荷物へ加えないことが不可欠です。

Astroはビルド開始直後、es-module-lexerにソース全体を数ミリ秒で走査させ、importが現れる"座標"だけを取得します。得られた配列はそのままViteのプラグインへ渡され、Viteはタグ付けされた行を参照しながら、機械的に仕分けます。

  • static import → Viteの事前バンドルであるoptimizeDepsに登録
  • dynamic import → コード分割

この二段階処理により、島に関連する依存ファイルだけがまとまってひとつのバンドルに入り、残りは動的チャンクとして分割されます。

仕分けが終わった時点で、Astroはページを静的HTMLとして出力しつつ「島」に必要な最小限のJSだけを最初の応答に同梱します。開発サーバーはoptimizeDepsに無駄な依存を抱えず、コールドスタートが体感で1/3程度に短縮されます。ブラウザはまずHTMLとスタイルだけで描画を完了し、ユーザーがスクロールやタブ切り替えで島を“起動”した瞬間にだけ動的チャンクをネットワーク越しに取得するため、メインスレッドの負荷も抑えられます。こうして初回表示は軽く、後続操作も必要なときに必要な分だけJSが届きます。
これこそAstroが実現する “最小JS” であり、その裏側ではes-module-lexerとViteが役割を担っています。

まとめ ――“場所だけを見る”という設計がもたらしたもの

es-module-lexerは「import / exportの座標だけ抜く」という割り切りで、数十万行を数ミリ秒で走査できる軽量スキャナを実現しました。

その座標情報は、KnipとAstro+Viteの2つの事例で以下のように活用されています。

  • Knipでは未使用ファイルや依存パッケージをCI段階で弾くフィルターになり、 “モノレポが太る”という慢性的な悩みをほぼゼロコストで抑え込む。
  • Astro × Viteでは静的・動的インポートを即座に仕分けます。その結果、“島”が動くために必要な数KBのJavaScriptだけを初回ロードに含めます。これにより、開発サーバーのコールドスタートと本番表示の両方が驚くほど軽くなります。

つまり"木を育てず、幹の位置だけ測量する"という哲学は、ビルドフェーズで待ち時間を劇的に短縮する鍵です。また、実行¥フェーズにおいても同様に機能します。

GitHubで編集を提案

Discussion