

1/1/1970
我是如何用 Import Maps 解决级联哈希变更的
Howdy!这个问题困扰了我 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 改造 之后,生成的文件数量一下子爆炸,开始频繁撞到上限。
解决级联哈希问题之后,我们就能在不触碰这些限制的前提下存更多历史构建,因为现在大多数文件都不需要再变化了。这样也降低了老版本用户出错的概率,因为他们更有可能请求到一个内容没变、我们又刚好还保留着的文件。
为什么不用 [其他方案]?
一开始想解决这个问题时,我考虑过几种做法,但都不太满意。
构建后的脚本
我最早的想法是写一个构建后脚本,去规范化所有 import 路径、重新计算哈希、然后更新引用。听起来挺简单,就是用正则把带哈希的文件名替换成稳定的名字,再重新算一遍哈希。
我放弃这个方案,是因为会担心「海森堡 bug」和缓存被污染。虽然我们会在 Cloudflare Pages 里存历史构建,但缓存不一致的风险太大了。构建完成后再去改文件的脚本,很容易引入那种只会在生产环境出现的微妙 bug,而这类 bug 一旦出现,调试起来会非常噩梦。
Vite manualChunks
另一个选项是用 Vite 的 manualChunks 配置,把比较稳定的代码(比如 node_modules)和不稳定的业务代码分开。思路是第三方依赖变动没那么频繁,这样级联变动的文件就会少一点。
但这其实没解决根本问题,只是稍微缓解一下。业务代码的 chunk 里还是会发生级联哈希变化。我想要的是一个能从底层解决问题的方案,而不是让问题「没那么糟」而已。
Import Maps:更现代的解法
Import Maps 是一个浏览器原生特性(老浏览器可以用 polyfill),它能把模块标识符和实际文件路径解耦。文件 A 不再去 import "./button-abc123.js",而是 import "button"。浏览器会用 Import Map 把 "button" 解析到真正带哈希的文件名上。
这刚好就是我需要的效果。文件 A 的内容永远一样(它一直写的是 "button"),所以它的哈希也就不会变。只有 Import Map 和被改动的那个文件本身会生成新哈希。我当时还挺惊讶,居然还没有一个好用的插件来做这件事。
实现之旅
我决定写一个 Vite 插件,它会做几件事:
- 把所有相对路径的 import 转成稳定的模块标识符
- 生成一个 Import Map,把这些标识符映射到真正带哈希的文件名
- 把 Import Map 注入到 HTML 里
这个插件现在已经在 GitHub 上开源了: @foony/vite-plugin-import-map
最初的尝试
一开始我是用 Vite 的 generateBundle 钩子写了一个插件。第一版直接用正则去找和替换 import 路径。这样写起来特别快,对我们这个小团队 Foony 也没啥问题,但这种做法非常脆弱,放到插件生态里肯定不行,因为很容易误伤一些根本不该被改动的内容。
正则方案的问题很明显:如果代码里的某个字符串刚好长得像个文件名怎么办?动态 import 怎么处理?export 语句又怎么算?如果我要写的是一个给别人用的插件,就必须找一个更稳妥的方案。
AST 解析
我需要真正把 JavaScript 代码解析出来,找到所有的 import 语句。我起初用的是 es-module-lexer,它是专门为解析 ES Modules 设计的。不过它在 Vite 的模块分析阶段会直接触发原生崩溃。就算尝试了 asm.js 版本也没法避免这些崩溃。
最后我选了 Acorn,一个快速、轻量、纯 JavaScript 的解析器。再加上用 acorn-walk 做 AST 遍历,就能完全满足我的需求,而且不会有原生依赖带来的各种问题。
关键难点与解法
处理所有类型的 import
`import 有很多种形式,在 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",而我的插件只在找 ImportDeclaration 和 ImportExpression 节点,自然就什么都没做。
现在我会这样处理所有情况:
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。然后再和已有的 map 合并,保留原来的 sources 和 sourcesContent。这样就能维持完整链路:原始源码 →(已有 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,
};
对变换后的内容重新哈希
我需要的是「稳定的文件内容」。为此,我会先把代码里的 import 改掉(把 Vite 自己的带哈希 import 换成我自己的稳定标识符),然后在算哈希之前,把文件里关于 source map 的注释去掉,因为那些东西还在引用旧文件名。
处理完之后,我再算一个新的哈希,更新文件名,同时修改 Import Map 里的对应条目。
最终实现
这个插件整体上分成四个阶段:
- 计数阶段: 统计有多少文件共享同一个基础文件名,用来检测命名冲突
- 映射阶段: 创建 chunk 映射(带哈希文件名 → 模块标识符),并生成初始 Import Map
- 变换阶段: 重写代码里的 import 路径,重新计算哈希,更新 source map
- 重命名阶段: 更新打包产物的文件名,并最终确定 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,而不是再用正则去改 HTML:
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 解析能保证我们只会改动真正的 import/export 语句。
为什么选 Acorn 而不是 es-module-lexer
es-module-lexer 更快,也更针对这个场景,但它的原生崩溃问题让我在 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 项目里试试。
后续可能会考虑在 es-module-lexer 的原生崩溃问题解决之后,用它来做进一步优化,或者为更复杂的 import 场景加点额外支持。但就现在来说,这个插件已经完全满足了我的需求。
谁知道呢,说不定哪天 Vite 就会原生支持类似的能力。