background blurbackground mobile blur

1/1/1970

Import Map を使ってカスケード式のハッシュ変更を解決した話

こんにちは! この問題は5年以上抱えていたのですが、いよいよ無視できないところまで来たので、ようやく取り組むことにしました。1つのファイルの中で1文字変えただけなのに、ビルド内のJavaScriptファイルの半分が新しいハッシュ付きファイル名になってしまうのです。実際の中身は何も変わっていないのに、です。これによって不必要なキャッシュ無効化が発生し、ビルド間で本当に何が変わったのかを追跡するのがほぼ不可能になり、そして何より最悪なのは、ファイル数の上限のせいでCloudflare Pagesのビルドが壊れていたことでした。

以下では、この問題、既存の解決策が私には合わなかった理由、そしてImport Mapを使ってカスタムViteプラグインを構築し、これを根本から解決した方法について解説していきます。

問題:カスケード式のハッシュ変更

Viteは本番ビルドでコンテンツベースのハッシュを使います。アプリをビルドすると、各JavaScriptファイルにはその内容に基づくハッシュがファイル名に付与されます。たとえばbutton.tsxbutton-abc12345.jsにコンパイルされ、内容が変わるとbutton-def45678.jsになります。これはキャッシュバスティングには素晴らしい仕組みです。ファイルが変わったら、ユーザーは新しいファイルを取得できます。

問題は、ファイルAがファイルBをインポートしているときに起きます。たとえば次のような状況です:

// 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以上のファイルが再ハッシュされました。

実際の影響は深刻でした。私たちは過去のビルドアセットの8バージョンをまとめて配信しています。少し古いバージョンのクライアントを使っているユーザーが、Cloudflare Pagesに新バージョンをデプロイしたあとも、自分のバージョンを動かし続けられるようにするためです。しかしCloudflare Pagesには2万ファイルの上限があり、先日のi18n対応で生成されるファイル数が爆発的に増えた結果、この上限にぶつかり始めていました。

カスケード式のハッシュを解決すれば、この上限に達することなくはるかに多くの過去ビルドを保持できるようになります。ほとんどのファイルがもう変わる必要がなくなるからです。さらに、古いビルドを使っているユーザーがエラーを起こす可能性も減ります。たまたま私たちが保持していて、かつ変更されていないファイルをリクエストしてくれる確率が大幅に上がるからです。

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

これを解決しようと考え始めたとき、いくつかのアプローチを検討しました。でもどれもしっくりきませんでした。

ビルド後スクリプト

最初に考えたのは、すべてのインポートパスを正規化し、ファイルを再ハッシュし、参照を更新するビルド後スクリプトを書くことでした。簡単そうに見えました。ハッシュ付きファイル名を安定した名前に正規表現で置換して、ハッシュを再計算するだけ、と。

このアプローチを却下したのは、「ハイゼンバグ」とキャッシュ汚染への懸念からです。Cloudflare Pagesに過去のビルドを保存しているとはいえ、キャッシュの不整合のリスクを冒すだけの価値はありませんでした。ビルド後にファイルを書き換えるスクリプトは、本番でしか現れない微妙なバグを生み出す可能性があり、それをデバッグするのは悪夢になりかねません。

Viteの manualChunks

もう1つの選択肢は、ViteのmanualChunks設定を使って、安定したコード(node_modulesなど)と不安定なコード(ビジネスロジック)を分離することでした。ベンダーコードは変更頻度が低いので、カスケードするファイルが少なくなる、というアイデアです。

これは根本的な問題を解決しません。緩和するだけです。ビジネスロジックのチャンク内ではやはりカスケード式のハッシュが発生してしまいます。私が欲しかったのは、問題を少しマシにするだけのものではなく、根本に対処する解決策でした。

Import Map:モダンな解決策

Import Mapはブラウザネイティブの機能(古いブラウザ向けにはポリフィルあり)で、モジュール指定子をファイルパスから切り離すものです。ファイルAは"./button-abc123.js"をインポートする代わりに、"button"をインポートします。ブラウザはImport Mapを使って"button"を実際のハッシュ付きファイル名に解決します。

これこそ私が必要としていたものでした。ファイルAの内容は同じまま(常に"button"をインポート)なので、ハッシュも変わりません。新しいハッシュが付くのはImport Mapと変更されたファイルだけ。これに対する良いプラグインがまだ誰も作っていなかったのには、ちょっと驚きました!

Vite プラグインを作る

私は次のようなViteプラグインを作ることにしました:

  1. すべての相対インポートを安定したモジュール指定子に変換する
  2. それらの指定子を実際のハッシュ付きファイル名にマッピングするImport Mapを生成する
  3. Import MapをHTMLに注入する

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

最初のアプローチ

最初はgenerateBundleフックを使ったViteプラグインから始めました。最初の試みでは正規表現でインポートパスを検索・置換していました。コーディングは簡単で、私たちの小さなチームFoonyでは動いていましたが、脆く、誤検出によって意図しない変換が行われる可能性のあるプラグインとしては、絶対に通用しませんでした。

正規表現アプローチには明らかな問題がありました。コード内の文字列がたまたまファイル名のように見えたら? 動的インポートはどうする? エクスポート文は? 他の人にも使ってもらえるプラグインを作るなら、もっと堅牢な解決策が必要でした。

AST パース

すべてのインポート文を見つけるには、JavaScriptコードを正しくパースする必要がありました。最初に試したのはes-module-lexerで、これはまさにESモジュールのパースのために設計されたものです。しかし残念ながら、Viteのモジュール解析フェーズでネイティブパニックを引き起こしました。asm.jsビルドを試してもパニックは止まりませんでした。

最終的にAcornに落ち着きました。高速で軽量、純粋なJavaScriptのパーサーです。ASTのトラバーサル用にacorn-walkと組み合わせれば、ネイティブ依存の問題なしに必要なものすべてが手に入りました。

解決した主な課題

あらゆるインポート形式への対応

インポートにはさまざまな形式があり、ASTでも別々に扱われます。対応すべきなのは:

  • 静的インポート: import x from "./file.js"
  • 動的インポート: 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) {
    // Static imports: import x from "spec"
    const specifier = node.source.value;
    // ... transform logic
  },
  ExportNamedDeclaration(node: any) {
    // Named exports with source: export { x, y } from "spec"
    if (!node.source?.value) return;
    // ... transform logic
  },
  ExportAllDeclaration(node: any) {
    // Export all: export * from "spec"
    if (!node.source?.value) return;
    // ... transform logic
  },
  ImportExpression(node: any) {
    // Dynamic imports: import("spec")
    // ... transform logic
  },
});

決定論的な衝突解決

複数のファイルが同じベース名を持つ場合(別ディレクトリのindex.tsxが複数あるなど)、それらを区別する必要があります。すべてに"index"を使うわけにはいきません。

解決策はこうです: 衝突がある場合は、元のソースパスとベース名を組み合わせてハッシュ化します。たとえばsrc/client/games/chess/index.tsx:indexをハッシュ化してindex-abc123を作ります。これにより、同じ名前のファイルが追加されたり削除されたりしても、同じファイルはビルド間で常に同じモジュール指定子を取得できます。

主要な識別子としてはchunk.facadeModuleId(エントリポイント)を使い、それが利用できない場合はchunk.moduleIds[0]にフォールバックします。これにより、決定論的なハッシュ化のための安定したソースパスが得られます。

ソースマップのチェイン

コードを変換すると、ソースマップのチェインが切れてしまいます。既存のソースマップは、元のTypeScriptソースからBabelとミニファイを経て現在のコードへとマッピングされています。私の変換はもう一層を追加するので、そのチェインを保持する必要があります。

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,
});

// Merge: use new map's mappings but preserve original sources
chunk.map = {
  ...newMap,
  sources: existingMap.sources || newMap.sources,
  sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
  file: newFileName,
};

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

ファイル内容を安定させる必要があります。そのために、インポートを変換し(Viteのハッシュ付きインポートを安定したインポートに置き換え)、ハッシュ計算からソースマップのコメントを取り除きます(これは古いファイル名を参照しているため)。

その後、新しいハッシュを計算し、ファイル名とImport Mapのエントリの両方を更新します。

最終実装

プラグインは4パス戦略を採用しています:

  1. カウントパス: 各ベース名を共有するファイル数をカウントして、名前の衝突を検出
  2. マップパス: チャンクマッピング(ハッシュ付きファイル名 → モジュール指定子)と初期Import Mapを作成
  3. 変換パス: コード内のインポートパスを書き換え、ハッシュを再計算し、ソースマップを更新
  4. リネームパス: バンドルのファイル名を更新し、Import Mapを確定

中核となる変換ロジックはこちら:

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

// Parse the code to get an AST
const ast = Parser.parse(chunk.code, {
  ecmaVersion: 'latest',
  sourceType: 'module',
  locations: true,
});

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

// Traverse the AST to find all imports/exports
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 to skip opening quote
        end: node.source.end - 1,     // -1 to skip closing quote
        replacement: moduleSpec,
      });
    }
  },
  // ... handle other node types
});

// Apply transformations in reverse order to preserve positions
importsToTransform.sort((a, b) => b.start - a.start);
for (const transform of importsToTransform) {
  magicString.overwrite(transform.start, transform.end, transform.replacement);
}

Import MapをHTMLに注入する際には、正規表現操作の代わりにViteのタグ注入APIを使っています:

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

これはHTMLタグを正規表現でマッチングするよりずっと信頼性が高いです。

数字で見る効果

このプラグインの効果を数字で示すと:

  • 約1,000以上のJavaScriptファイルをビルドごとに処理
  • 約2~3秒のビルド時間増加(許容範囲のトレードオフ)
  • 不必要なハッシュ変更を約99%削減(ほとんどのファイルは実際の内容が変わったときだけ変わるようになった)
  • プラグインコードは約340行(コメントとエラーハンドリングを含む)

これまでに遭遇したエッジケースはすべて処理できており、ビルドプロセスはずっと予測しやすくなりました。

学んだこと

なぜASTパースが不可欠なのか

バンドル後のコードに正規表現を使うのは危険です。コード内の文字列がたまたまファイル名のように見えたら、正規表現はそれも書き換えてしまいます。ASTパースを使えば、実際のインポート/エクスポート文だけを変換できることが保証されます。

なぜ es-module-lexer ではなくAcornなのか

es-module-lexerはより高速で目的特化していますが、ネイティブパニックの問題で私のViteプラグインのコンテキストでは使えませんでした。Acornは純粋なJavaScriptなので、ネイティブ依存の心配がありません。将来的には速度最適化のためにes-module-lexerを見直したいですが、今のところAcornで完璧に動いています。

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

Import Mapはブラウザのネイティブサポートがあるウェブ標準です。この問題を解決する「正しい」方法です。ポリフィル(es-module-shims)は古いブラウザ(Safari < 16.4 など)を優雅に処理してくれますし、解決策はクリーンでメンテナンスしやすいです。

おわりに

Import Mapプラグインは、Viteビルドにおけるカスケード式のハッシュ変更を見事に防いでくれます。ファイルは依存先が変わったときではなく、実際の内容が変わったときだけ新しいハッシュを取得するようになりました。これによりビルドはより予測しやすくなり、不必要なキャッシュ無効化が減り、Cloudflare Pagesのファイル制限内に収まるのにも役立ちます。

この解決策はシンプルで、メンテナンスしやすく、モダンなウェブ標準を活用しています。「正しい」解決策が同時に最もシンプルな解決策でもあるという好例です。問題を十分深く理解してはじめて、それが見えてくるのです。

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

将来的な改善としては、ネイティブパニックの問題が解決されたらes-module-lexerで最適化するとか、より複雑なインポートシナリオへの対応を追加するといったことが考えられます。でも今のところ、このプラグインは私が必要としていることをきちんとやってくれています。

そして、もしかすると? いつかViteがこういうものをネイティブにサポートしてくれるかもしれません。

(更新: Foonyのビルドでこのプラグインを試したところ、一部のユーザーで予期しない問題が出たので、いったん無効にしました。あとでまた見直すかもしれません。たぶん。それでも、これは素敵な解決策だと今も思っています。)

8 Ball Pool online multiplayer billiards icon