background blurbackground mobile blur

1/1/1970

Import Maps で連鎖的なハッシュ変更を解決した話

やあ!この問題に悩まされてもう5年以上になるんですが、最近ついに無視できないレベルまで悪化したので、本腰を入れて解決することにしました。1つのファイルでたった1文字変えただけなのに、ビルドに含まれる JavaScript ファイルの半分くらいが中身はまったく変わっていないのにハッシュ付きファイル名だけが変わる、という状態でした。

そのせいでキャッシュが無駄に無効化されるし、ビルドごとに「本当に変わったもの」が何なのか追いかけるのがほぼ不可能になっていました。しかも最悪なことに、Cloudflare Pages のファイル数制限に引っかかってビルドが壊れるようになってしまったんです。

この記事では、この問題が具体的にどういうものか、なぜ既存の解決策が自分のケースには合わなかったのか、そして最終的に Import Maps を使った自作の Vite プラグインでどうやって完全解決したのかを順番に紹介します。

問題: 連鎖するハッシュ変更

Vite の production build では、コンテンツベースのハッシュが使われます。アプリをビルドすると、各 JavaScript ファイルの中身にもとづいてファイル名にハッシュが付きます。たとえば button.tsxbutton-abc12345.js にコンパイルされていて、その中身が変わると、次は button-def45678.js みたいな別のファイル名になります。これはキャッシュバスティングにとても便利で、ファイルが変わったときだけユーザーに新しいファイルが届きます。

問題は、A のファイルが B のファイルを import しているときに起きます。たとえばこんなコードがあるとします。

// main.js
import { Button } from "./button-abc12345.js";

button.tsx が変更されると、Vite は新しく button-def45678.js を生成します。ところが main.js の中には "./button-abc12345.js" という文字列が埋め込まれているので、これも書き換わります。その結果、main.js 自身のロジックは一切変わっていないのに、main.js も新しいハッシュ付きファイル名になってしまいます。

こうして依存グラフ全体にどんどん波及していきます。ユーティリティ関数を1個変えただけで、気づいたら js ファイルの半分近くが新しいハッシュになっている、みたいなことが起きます。実際ぼくの環境では、useBackgroundMusic.ts のたった1文字を変えただけで、500 個以上のファイルが再ハッシュされていました。

現実世界への影響もかなり大きかったです。ぼくたちは、少し古いクライアントを使っているユーザーでも新バージョンを Cloudflare Pages にデプロイしたときに自分のバージョンをそのまま動かせるように、過去ビルドのアセットを最大 8 バージョン分まとめて保持しています。ところが Cloudflare Pages には 20,000 ファイルという上限があって、以前やった i18n 対応 で生成ファイル数が爆増したこともあり、その制限にぶつかり始めてしまいました。

この「連鎖するハッシュ問題」を解決できれば、ほとんどのファイルが毎回変わらなくなるので、ファイル数の上限に引っかからずにもっとたくさんの過去ビルドを保存できます。それに、古いビルドを使っているユーザーがエラーで落ちる確率もぐっと下がります。リクエストされるファイルの多くが「今はもう変わらないファイル」になり、そのファイルをちゃんと保持できている可能性が高くなるからです。

なぜ他の解決策ではダメだったのか

最初にこの問題をどうにかしようとしたとき、いくつかアプローチを検討しました。でもどれも、あと一歩しっくり来なかったんです。

ビルド後スクリプト

最初に思いついたのは、ビルド後に動くスクリプトを書いて、全部の import パスを正規化しつつ、ファイルを再ハッシュして参照を書き換える方法でした。考え方としてはシンプルで、ハッシュ付きファイル名を安定した名前に正規表現で置き換えてから、もう一度ハッシュを計算し直すだけです。

でもこの方法は「ハイゼンバグ」とキャッシュ汚染のリスクが怖くて却下しました。過去ビルドを Cloudflare Pages に保存しているとはいえ、キャッシュの整合性が崩れる可能性はあまりにもリスキーです。ビルドが終わったあとでファイルを書き換えるスクリプトは、本番でしか出ないような微妙なバグを仕込むかもしれませんし、それをデバッグするのは悪夢そのものです。

Vite の manualChunks

もう一つの選択肢は、Vite の manualChunks 設定を使って、安定しているコード(node_modules みたいなやつ)と、頻繁に変わるビジネスロジックのコードを分けることでした。ベンダーコードはあまり変わらないはずなので、その分カスケードするファイル数を減らせるんじゃないか、という発想です。

けれどこれは根本的な問題解決にはなりません。単に「被害をちょっと軽くする」だけです。ビジネスロジック側のチャンクの中では、やっぱり連鎖的なハッシュ変更が起き続けます。ぼくが欲しかったのは、問題の根っこに対処する方法であって、「前よりはマシ」くらいの妥協案ではありませんでした。

Import Maps: 今っぽい解決法

Import Maps はブラウザにネイティブで備わっている機能で(古いブラウザ向けにはポリフィルもあります)、モジュールの識別子と実際のファイルパスを切り離してくれます。File A が "./button-abc123.js" を import する代わりに、"button" という識別子を import するイメージです。ブラウザは import map を見て、"button" を実際のハッシュ付きファイル名に解決してくれます。

これがまさに自分が求めていた仕組みでした。File A は常に "button" を import するだけなので、ファイルの内容は変わりません。その結果、A のハッシュも変わらないままです。ハッシュが変わるのは、import map と中身が変わったファイルだけになります。この仕組みをそのまま使う良いプラグインが誰も作っていなかったのは、ちょっとびっくりしました。

実装までの道のり

そこで、次のことをやってくれる Vite プラグインを自作することにしました。

  1. すべての相対 import を安定したモジュール識別子に変換する
  2. その識別子と実際のハッシュ付きファイル名を対応付ける import map を生成する
  3. 生成された import map を HTML に埋め込む

このプラグインは GitHub で公開中です: @foony/vite-plugin-import-map

最初のアプローチ

最初は、Vite の generateBundle フックを使うプラグインとして書き始めました。第一案では、正規表現で import パスを探して置き換える方式を試しました。コードとしてはすごく簡単で、小さなチームの Foony の中だけで使うぶんにはちゃんと動いていました。でも壊れやすくて、誤検出した文字列まで書き換えてしまう可能性がある以上、一般公開するプラグインとしてはとても出せない代物でした。

正規表現方式には明らかな問題があります。たとえば、たまたまコード中の文字列リテラルがファイル名っぽい見た目だったらどうするのか、とか、dynamic import はどう扱うのか、とか、export 文はどうするのか、などなど。人に使ってもらうプラグインにするつもりなら、もっと堅牢なやり方が必要でした。

AST を使ったパース

そこで、JavaScript コードをちゃんとパースして import 文を全部拾う必要が出てきました。最初は ES Modules を解析するための専用ツールである es-module-lexer を試しました。ところが残念ながら、Vite のモジュール解析フェーズでネイティブのパニックが発生してしまいました。asm.js ビルド版を試してみても、パニックは止まりませんでした。

最終的には、速くて軽量な純 JavaScript 製パーサである Acorn に落ち着きました。AST の走査には acorn-walk を組み合わせています。これならネイティブ依存の問題もなく、必要なことは全部できました。

解決した主な課題

いろいろな import の書き方に対応する

`import` の書き方はいろいろあって、AST 上でも別々のノードとして扱われます。ぼくが対応しなきゃいけなかったのは次のパターンです。

  • 静的 import: import x from "./file.js"
  • Dynamic import: import("./file.js")
  • 名前付き再エクスポート: export { x } from "./file.js"(最初これを見落としていました!)
  • すべて再エクスポート: export * from "./file.js"

とくに再エクスポートのケースがやっかいでした。変換されていないファイルを見つけるまで、そもそも対応し忘れていたんです。そのファイルには export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" というコードが書かれていたのに、ぼくのプラグインは ImportDeclarationImportExpression のノードしか見ていなかったので、完全にスルーしていました。

今はこんな感じで全部のパターンを拾うようにしています。

walk(ast, {
  ImportDeclaration(node: any) {
    // 静的 import: import x from "spec"
    const specifier = node.source.value;
    // ... 変換ロジック
  },
  ExportNamedDeclaration(node: any) {
    // ソース付きの名前付き export: export { x, y } from "spec"
    if (!node.source?.value) return;
    // ... 変換ロジック
  },
  ExportAllDeclaration(node: any) {
    // すべて再エクスポート: export * from "spec"
    if (!node.source?.value) return;
    // ... 変換ロジック
  },
  ImportExpression(node: any) {
    // Dynamic import: import("spec")
    // ... 変換ロジック
  },
});

衝突を安定して解決する方法

複数のファイルが同じベース名(たとえば、別々のディレクトリにある複数の index.tsx)を持っている場合、それぞれを区別できないと困ります。全部を単に "index" という識別子で扱うわけにはいきません。

そこで取った方法は、衝突が起きたときだけ、元のソースパスとベース名をくっつけてハッシュするやり方です。たとえば src/client/games/chess/index.tsx:index をハッシュして index-abc123 というモジュール識別子を作る、という感じです。これなら、同じファイルならビルドをまたいでも必ず同じモジュール識別子になり、同名のファイルが増えたり減ったりしても影響を受けません。

chunk.facadeModuleId(エントリーポイントのパス)を優先的な識別子として使い、もしそれが無ければ chunk.moduleIds[0] をフォールバックにしています。こうすることで、ハッシュ計算に使うソースパスを安定させています。

ソースマップのチェーンを保つ

コードを書き換えるということは、ソースマップのチェーンを途中で切ってしまうということでもあります。もともとのソースマップは、元の TypeScript ソースから Babel や minify を経て、今のコードにたどり着くまでの対応関係を持っています。そこに自分の変換がもう一段入るので、そのチェーンをちゃんとつなぎ直してあげる必要があります。

MagicString を使って書き換え内容を追跡し、新しいソースマップを生成しています。そのあと、元の sourcessourcesContent 配列を維持する形で既存のマップとマージします。これで「元のソース →(既存のマップ)→ 変換後コード」という完全なチェーンを保てます。

const existingMap = typeof chunk.map === 'string' ? JSON.parse(chunk.map) : chunk.map;
const newMap = magicString.generateMap({
  source: fileName,
  file: newFileName,
  includeContent: true,
  hires: true,
});

// マージ: 新しいマップの mapping を使いつつ、元の sources を維持する
chunk.map = {
  ...newMap,
  sources: existingMap.sources || newMap.sources,
  sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
  file: newFileName,
};

変換後のコンテンツを再ハッシュする

ファイルの中身を安定させる必要があります。そのために、まず import を変換して(Vite が埋め込んだハッシュ付き import を、自分の安定した import に置き換えます)、それからハッシュ計算の前にソースマップ用コメントを削除しています(そこには古いファイル名が含まれているからです)。

そのうえで新しいハッシュを計算し、ファイル名と import map のエントリの両方を更新します。

最終的な実装

このプラグインでは、4 回のパスを使う戦略を取っています。

  1. Count パス: 各ベース名を共有しているファイルの数を数え、名前の衝突を検出する
  2. Map パス: チャンクのマッピング(ハッシュ付きファイル名 → モジュール識別子)と、最初の import map を作る
  3. Transform パス: コード中の import パスを書き換え、ハッシュを再計算し、ソースマップを更新する
  4. Rename パス: バンドルされたファイル名を更新し、import map を確定させる

これが変換処理の中核となるロジックです。

import {simple as walk} from 'acorn-walk';

// コードをパースして AST を作る
const ast = Parser.parse(chunk.code, {
  ecmaVersion: 'latest',
  sourceType: 'module',
  locations: true,
});

const importsToTransform: Array<{start: number; end: number; replacement: string}> = [];

// AST を走査してすべての import/export を探す
walk(ast, {
  ImportDeclaration(node: any) {
    const specifier = node.source.value;
    const filename = specifier.split('/').pop()!;
    const moduleSpec = chunkMapping.get(filename);
    
    if (moduleSpec) {
      importsToTransform.push({
        start: node.source.start + 1, // 先頭のクオートを飛ばすために +1
        end: node.source.end - 1,     // 末尾のクオートを飛ばすために -1
        replacement: moduleSpec,
      });
    }
  },
  // ... 他の種類のノードも処理する
});

// 位置がずれないように後ろから順に変換を適用する
importsToTransform.sort((a, b) => b.start - a.start);
for (const transform of importsToTransform) {
  magicString.overwrite(transform.start, transform.end, transform.replacement);
}

HTML に import map を埋め込むときは、正規表現でゴリゴリ書き換える代わりに、Vite のタグ挿入用 API を使っています。

transformIndexHtml() {
  return {
    tags: [
      {
        tag: 'script',
        attrs: {type: 'importmap'},
        children: JSON.stringify(importMap, null, 2),
        injectTo: 'head-prepend',
      },
    ],
  };
}

HTML タグを正規表現で頑張ってマッチさせるより、こちらの方がずっと信頼できます。

数字で見る効果

このプラグインがどんな働きをしているのか、数字でざっくりお見せします。

  • 1 回のビルドで処理する JavaScript ファイルは 約 1,000 ファイル以上
  • ビルド時間は 約 2〜3 秒 増加(トレードオフとしては許容範囲)
  • 不要なハッシュ変更は 約 99% 削減(今はほとんどのファイルが、本当に中身が変わったときだけ変化)
  • プラグインのコードはコメントやエラーハンドリング込みで 約 340 行

今のところ出会ったあらゆるイレギュラーケースをこのプラグインで吸収できていて、ビルドの挙動もかなり予測しやすくなりました。

学んだこと

なぜ AST パースが必須なのか

バンドル後のコードに対して正規表現をかけるのはとても危険です。もしコード中の文字列がたまたまファイル名っぽい見た目だったら、正規表現はそれも容赦なく書き換えてしまいます。AST をパースしておけば、実際の import/export 文だけを確実に変換できます。

なぜ es-module-lexer ではなく Acorn を選んだか

es-module-lexer の方が速くて目的特化型のツールですが、ネイティブのパニック問題があるせいで自分の Vite プラグインの文脈では使い物になりませんでした。その点 Acorn は純粋な JavaScript 製なので、ネイティブ依存を気にしなくて済みます。将来的にはパフォーマンス改善としてまた es-module-lexer を試してみたい気持ちはありますが、今のところは Acorn でまったく困っていません。

なぜ他の選択肢ではなく Import Maps なのか

Import Maps はブラウザがネイティブでサポートしている Web 標準です。この問題を解く「正攻法」と言っていいと思います。ポリフィルの es-module-shims を使えば、古いブラウザ(たとえば Safari 16.4 未満)もきちんとケアできますし、全体としてとてもクリーンで保守しやすい解決策になっています。

まとめ

この Import Maps プラグインのおかげで、Vite のビルドで起きていた連鎖的なハッシュ変更をきれいに防げるようになりました。今では、依存関係が変わったときではなく、そのファイル自身の中身が変わったときだけ新しいハッシュが付きます。その結果、ビルドの挙動は予測しやすくなり、無駄なキャッシュ無効化も減り、Cloudflare Pages のファイル数制限の中に収めるのもずっと楽になりました。

この解決策はシンプルで保守しやすく、しかもモダンな Web 標準の上に素直に乗っています。問題を十分に深く理解できれば、「正しい解決策」が同時に「いちばんシンプルな解決策」でもある、という良い例になったと思います。

このプラグインはオープンソースで GitHub に公開しています: @foony/vite-plugin-import-mapnpm install @foony/vite-plugin-import-map でインストールすれば、自分の Vite プロジェクトでもすぐに使い始められます。

将来的には、ネイティブのパニック問題が解決されたタイミングで es-module-lexer を使って高速化したり、もっと複雑な import パターンへの対応を追加したりするかもしれません。でも今のところ、このプラグインは「自分がしてほしいこと」をきっちりやってくれています。

もしかしたら、いつか Vite がこういう仕組みをネイティブでサポートしてくれる日が来るかもしれませんね。

8 Ball Pool online multiplayer billiards icon