background blurbackground mobile blur

1/1/1970

我如何用 Import Maps 解决级联哈希变更问题

大家好!这个问题已经困扰了我 5 年多,但直到现在我才决定解决它,因为它已经到了让我无法再忽视的地步。当我修改一个文件中的一个字符时,构建中一半的 JavaScript 文件都会获得新的哈希文件名,即使它们的实际内容并没有改变。这导致了不必要的缓存失效,几乎无法追踪两次构建之间到底改变了什么,而且最糟糕的是:由于文件数量限制,我的 Cloudflare Pages 构建被破坏了。

下面我将分析这个问题,解释为什么现有解决方案对我不起作用,以及我如何使用 Import Maps 构建了一个自定义 Vite 插件,一劳永逸地解决了这个问题。

问题:级联哈希变更

Vite 在生产构建中使用基于内容的哈希。当你构建应用时,每个 JavaScript 文件的文件名中都会包含一个基于其内容的哈希值。如果 button.tsx 编译为 button-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 中的实际逻辑根本没有改变。

这会在你的整个依赖图中级联传播。改一个工具函数,突然你的一半 js 文件都获得了新哈希。在我的情况下,在 useBackgroundMusic.ts 中改变一个字符就导致了 500 多个文件被重新哈希。

实际影响是巨大的。我们将过去构建的资源打包了 8 个版本,这样当我们将新版本部署到 Cloudflare Pages 时,使用稍微旧一些客户端版本的用户仍然可以运行他们的版本。然而,Cloudflare Pages 有 20,000 个文件的限制,我们因为之前的 i18n 改动开始撞上这个限制,那次改动让我们创建的文件数量爆炸式增长。

解决级联哈希问题让我们能够存储更多过去的构建,而不会触及这些限制,因为现在大多数文件不再需要改变。这也降低了用户在使用旧构建时出错的可能性,因为他们请求的文件极有可能恰好是我们仍然保留的、未改变的文件。

为什么不用 [其他方案]?

当我第一次考虑解决这个问题时,我考虑过几种方法。但没有一种完全合适。

构建后脚本

我最初的想法是写一个构建后脚本,它会规范化所有的导入路径,重新哈希文件,并更新引用。这看起来很简单:用正则替换哈希文件名为稳定名称,然后重新计算哈希。

我拒绝了这个方法,因为我担心"海森堡 bug"和缓存污染。即使我们在 Cloudflare Pages 中存储了过去的构建,缓存不一致的风险也不值得冒。一个在构建后修改文件的脚本可能会引入只在生产环境中出现的微妙 bug,调试这些 bug 简直是噩梦。

Vite manualChunks

另一个选项是使用 Vite 的 manualChunks 配置,将稳定的代码(比如 node_modules)与不稳定的代码(业务逻辑)分开。这个想法是,vendor 代码改变频率较低,所以级联的文件会更少。

这实际上并没有解决根本问题,只是缓解了它。在你的业务逻辑 chunk 内,你仍然会出现级联哈希。我想要一个能解决核心问题的方案,而不仅仅是让它稍微好一点。

Import Maps:现代化的解决方案

Import Maps 是浏览器原生功能(对旧浏览器有 polyfill 支持),它将模块说明符与文件路径解耦。文件 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

初始方案

我开始时使用 Vite 插件的 generateBundle 钩子。我的第一次尝试是用正则查找并替换导入路径。这很容易编码,在我们 Foony 这样的小团队中也能工作,但很脆弱,在插件中肯定不行,因为可能会有误判被错误修改。

正则方法有明显的问题:如果代码中的某个字符串恰好看起来像文件名怎么办?动态导入怎么办?导出语句怎么办?如果我要为他人构建一个插件,我需要一个更稳健的解决方案。

AST 解析

我需要正确解析 JavaScript 代码以找到所有导入语句。我的第一次尝试是 es-module-lexer,它专门用于解析 ES 模块。不幸的是,它在 Vite 的模块分析阶段引发了 native panic。即使尝试 asm.js 构建也无法阻止这些 panic。

我最终选择了 Acorn,一个快速、轻量、纯 JavaScript 的解析器。结合 acorn-walk 进行 AST 遍历,它给了我所需的一切,而没有原生依赖问题。

解决的关键挑战

处理所有导入类型

导入有多种形式,它们在 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]。这给了我一个稳定的源路径用于确定性哈希。

Source Map 链式处理

当我转换代码时,我打破了 source map 链。现有的 source map 从原始 TypeScript 源代码,经过 Babel 和压缩,映射到当前的代码。我的转换又增加了一层,所以我需要保留这个链。

我使用 MagicString 来跟踪我的转换并生成一个新的 source map。然后我通过保留原始的 sourcessourcesContent 数组,将它与现有的 map 合并。这维持了完整的链:原始源代码 → (现有 map) → 转换后的代码。

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 的哈希导入替换为我的稳定导入),然后从哈希计算中剥离 source map 注释(它们引用的是旧文件名)。

之后,我计算一个新的哈希,并更新文件名和 import map 条目。

最终实现

该插件采用四遍策略:

  1. 计数遍: 通过统计每个基本名称被多少文件共享来检测命名冲突
  2. 映射遍: 创建 chunk 映射(哈希文件名 → 模块说明符)和初始 import map
  3. 转换遍: 重写代码中的导入路径,重新计算哈希,更新 source map
  4. 重命名遍: 更新 bundle 文件名并完成 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 解析确保你只转换真正的导入/导出语句。

为什么选择 Acorn 而非 es-module-lexer

es-module-lexer 更快,也更有针对性,但 native panic 问题让它在我的 Vite 插件场景中无法使用。Acorn 是纯 JavaScript,这意味着无需担心原生依赖。未来我会想看看 es-module-lexer 作为速度优化,但现在 Acorn 工作得很完美。

为什么选择 Import Maps 而非其他方案

Import Maps 是 web 标准,具有原生浏览器支持。它们是解决这个问题的"正确"方式。polyfill(es-module-shims)优雅地处理了旧浏览器(例如 Safari < 16.4),解决方案干净且易于维护。

结论

Import Maps 插件成功阻止了我的 Vite 构建中的级联哈希变更。文件现在只在其实际内容改变时才获得新哈希,而不是在它们的依赖改变时。这使得构建更可预测,减少了不必要的缓存失效,并帮助我们保持在 Cloudflare Pages 的文件限制以内。

该方案简单、易维护,使用了现代 web 标准。这是一个很好的例子,说明有时"正确"的解决方案也是最简单的,只要你对问题理解得足够深入,就能看到它。

该插件是开源的,可在 GitHub 上获取:@foony/vite-plugin-import-map。你可以用 npm install @foony/vite-plugin-import-map 安装它,并开始在你自己的 Vite 项目中使用。

未来的改进可能包括在 native panic 问题解决后用 es-module-lexer 进行优化,或者添加对更复杂导入场景的支持。但目前,该插件做的正是我需要它做的。

谁知道呢?也许有一天 Vite 会原生支持这样的功能。

(更新:在 Foony 的构建上试用该插件后,一些用户遇到了意外问题,所以我暂时禁用了它。我之后会再回来看看。也许吧。我仍然认为这是一个不错的解决方案。)

8 Ball Pool online multiplayer billiards icon