哈希难题
Related Materials
[v3.0] New hashing algorithm that "fixes (nearly) everything" - GitHub
rollup 在 v3.0 版本中对 hash 算法进行了重构,引入了 新的 hash 算法,解决了长期存在的 hash 不稳定问题,正确处理 renderChunk 插件转换并支持循环依赖。
问题阐述
旧版本 的 哈希算法 的 执行流程 如下:
渲染所有模块,但排除动态导入和 import.meta chunk 引用
- 先生成模块的主体代码
- 但此时动态导入(如 import('./foo.js'))和 import.meta.url 中的 chunk 引用还不知道最终文件名,所以先跳过
基于上述内容计算每个 chunk 中所有模块的 content hash
- 用模块内容生成哈希值
扩展哈希:考虑所有已知依赖 + chunk wrapper 中可能添加的动态内容
- Chunk wrapper 是指包裹模块内容的格式化代码(如 ES 模块的 import/export 语句,CommonJS 的 require/exports)
- 需要把这些可能变化的内容也纳入哈希计算
更新动态导入和 import.meta chunk 引用
- 现在才把真实的 chunk 文件名填进去
渲染 chunk wrappers(包含所有静态 import 和 export)
通过 renderChunk 插件钩子处理结果
存在的问题:
renderChunk插件钩子打破了content hash在
renderChunk中的 任何转换 都会被rollup完全忽略,不会影响chunk的hash值。那么就存在不同内容但hash相同的情况。Chunk wrapper未参与hash计算让
rollup来维护chunk wrapper中的每一种变化来扩展hash变更,需要 考虑 太多的 边界问题。
旧算法最根本的问题就是 hash 不反映最终输出内容,因为 renderChunk 是很多插件(如 terser 压缩)的核心处理环节,这部分变化被完全忽略,使得 hash 作为缓存标识失去了可信度。
旧算法源码级分析(PR #4543 之前)
源码基线:commit
9216f5235^(即 PR #4543 合入前一个 commit)
Hash 计算的完整组成
旧算法的 Hash 由两层结构组成:
第一层:getRenderedHash() — 单个 chunk 的内容 hash
// src/Chunk.ts (v2.x) 第 524-551 行
getRenderedHash(): string {
if (this.renderedHash) return this.renderedHash;
const hash = createHash();
// ① augmentChunkHash 插件钩子返回值
const hashAugmentation = this.pluginDriver.hookReduceValueSync(
'augmentChunkHash',
'',
[this.getChunkInfo()],
(augmentation, pluginHash) => {
if (pluginHash) {
augmentation += pluginHash;
}
return augmentation;
}
);
hash.update(hashAugmentation);
// ② preRender() 后的模块代码(MagicStringBundle 拼接)
hash.update(this.renderedSource!.toString());
// ③ 导出名称映射:moduleId:variableName:exportName
hash.update(
this.getExportNames()
.map(exportName => {
const variable = this.exportsByName.get(exportName)!;
return `${relativeId((variable.module as Module).id)}:${
variable.name
}:${exportName}`;
})
.join(',')
);
return (this.renderedHash = hash.digest('hex'));
}第二层:computeContentHashWithDependencies() — 带依赖的完整 hash
// src/Chunk.ts (v2.x) 第 886-908 行
private computeContentHashWithDependencies(
addons: Addons,
options: NormalizedOutputOptions,
bundle: OutputBundleWithPlaceholders
): string {
const hash = createHash();
// ④ intro + outro + banner + footer
hash.update([addons.intro, addons.outro, addons.banner, addons.footer].join(':'));
// ⑤ 输出格式
hash.update(options.format);
// 遍历自身及所有传递依赖(静态 + 动态)
const dependenciesForHashing = new Set<Chunk | ExternalModule>([this]);
for (const current of dependenciesForHashing) {
if (current instanceof ExternalModule) {
// ⑥ 外部模块路径
hash.update(`:${current.renderPath}`);
} else {
// ⑦ 依赖 chunk 的内容 hash(递归调用 getRenderedHash)
hash.update(current.getRenderedHash());
// ⑧ 依赖 chunk 的文件名模板(不含 hash 部分)
hash.update(current.generateId(addons, options, bundle, false));
}
if (current instanceof ExternalModule) continue;
for (const dependency of [...current.dependencies, ...current.dynamicDependencies]) {
dependenciesForHashing.add(dependency);
}
}
return hash.digest('hex').substr(0, 8);
}Hash 组成汇总表
| 编号 | 内容 | 来源方法 | 说明 |
|---|---|---|---|
| ① | augmentChunkHash 插件返回值 | getRenderedHash() | 用户插件可主动注入 |
| ② | 模块渲染后代码 | getRenderedHash() | 含原始动态导入路径 |
| ③ | 导出名称映射 | getRenderedHash() | moduleId:varName:exportName |
| ④ | intro:outro:banner:footer | computeContentHashWithDependencies() | addons 内容 |
| ⑤ | 输出格式字符串 | computeContentHashWithDependencies() | options.format |
| ⑥ | 外部模块路径 | computeContentHashWithDependencies() | ExternalModule.renderPath |
| ⑦ | 所有依赖 chunk 的 ①②③ | computeContentHashWithDependencies() | 递归 getRenderedHash() |
| ⑧ | 依赖 chunk 的文件名模板 | computeContentHashWithDependencies() | 不含 hash 部分 |
不在 Hash 中的内容
| 内容 | 原因 |
|---|---|
| import/export 语句(wrapper 代码) | finalise() 在 render() 中执行,晚于 hash 计算 |
| 动态导入的最终 chunk 路径 | finaliseDynamicImports() 在 render() 中执行 |
import.meta.url 的最终值 | finaliseImportMetas() 在 render() 中执行 |
renderChunk 钩子的修改 | 在 render() 最后才执行 |
执行时序的源码证据
证据 1:Bundle.generate() 调用顺序
// src/Bundle.ts 第 60-78 行
async generate(isWrite: boolean): Promise<OutputBundle> {
// ...
// 1. 预渲染所有 chunk
this.prerenderChunks(chunks, inputBase, snippets);
// 2. 分配 chunk ID(触发 hash 计算)
await this.addFinalizedChunksToBundle(chunks, inputBase, addons, outputBundle, snippets);
// ...
}
// src/Bundle.ts 第 85-103 行
private async addFinalizedChunksToBundle(...) {
// assignChunkIds 中调用 generateId → computeContentHashWithDependencies
this.assignChunkIds(chunks, inputBase, addons, bundle);
// render 在 hash 计算之后
await Promise.all(
chunks.map(async chunk => {
Object.assign(outputChunk, await chunk.render(...));
})
);
}证据 2:ImportExpression.render() 不渲染最终路径
// src/ast/nodes/ImportExpression.ts 第 48-76 行
render(code: MagicString, options: RenderOptions): void {
// ...
if (this.mechanism) {
// 只渲染 import( 和 ) 的机制部分
code.overwrite(..., this.mechanism.left, ...);
code.overwrite(..., this.mechanism.right, ...);
}
// this.source.render() 渲染的是原始路径,如 './foo.js'
// 而不是最终的 chunk 路径
this.source.render(code, options);
}
// renderFinalResolution 是单独的后处理方法
// 在 Chunk.render() → finaliseDynamicImports() 中调用
renderFinalResolution(
code: MagicString,
resolution: string, // 这里才是最终的 chunk 路径
...
): void {
code.overwrite(this.source.start, this.source.end, resolution);
}证据 3:Chunk.render() 中的后处理顺序
// src/Chunk.ts 第 710-711 行
async render(...): Promise<{ code: string; map: SourceMap }> {
// ... 其他处理 ...
// 这两个方法在 render() 中执行,此时 hash 已经算完了
this.finaliseDynamicImports(options, snippets); // 填充动态导入的最终路径
this.finaliseImportMetas(format, snippets); // 填充 import.meta.url
// ... finalise() 生成 wrapper ...
// renderChunk 钩子在最后执行
let code = await renderChunk({
code: prevCode,
...
});
return { code, map };
}Chunk Wrapper 与导出名称映射的区分
需要注意区分两个概念:
| 方面 | 导出名称映射(在 hash 中 ③) | import/export 语句(不在 hash 中) |
|---|---|---|
| 本质 | 元数据字符串 | 实际 JavaScript 代码 |
| 示例 | "src/utils.js:foo:foo" | export { foo }; |
| 生成时机 | preRender() 之后可用 | render() → finalise() |
| 包含内容 | 模块路径、变量名、导出名 | 完整语法、路径、格式特定代码 |
实际影响:
由于导出名称映射已经捕获了导出的语义信息(导出什么、从哪个模块),实际 import/export 语句不在 hash 中的影响主要是:
- import 语句的顺序变化不会改变 hash
- 同一个导出用不同语法表达(理论上)不会改变 hash
但导出名称变化(如 foo → bar)会改变 hash,因为 ③ 中包含了这个信息。
解决方案
排序渲染(尝试解决哈希问题)
解决哈希问题的一种方法是 先渲染 没有 任何依赖 的 chunks,然后 迭代渲染 那些 仅依赖 于 已渲染 chunks 的 chunks,直到所有 chunks 都被渲染完成。虽然这种方法在某些情况下可行,但存在几个明显缺陷:
不支持
chunks间的循环依赖这是一个非常重要的特性,因为在这种上下文中,循环依赖也可能是 两个互相动态导入 的
chunks。此外,rollup在处理 动态导入 时严重依赖一个机制:rollup会将依赖方chunk和依赖chunk之间共享的所有依赖移动到依赖方chunk中,导致在依赖chunk中出现对依赖方chunk的静态导入。机制解释
假设 有三个模块,模块
main(入口模块)、模块b和模块c,其中:- 模块
main动态导入模块b,静态导入模块c。 - 模块
b动态导入模块main,静态导入模块c。
js// main.js import { c } from './c.js'; console.log('a.js'); import('./b.js').then(res => { console.log(res, c); });js// b.js import { c } from './c.js'; console.log('c.js'); import('./main.js').then(res => { console.log(res, c); });js// c.js console.log('c.js'); export const c = '123';在这种场景下,
rollup会将模块main和模块b之间 共享静态依赖项(模块c)移至模块main中。这意味着在模块b中会出现对模块main的 静态导入。js// main.js console.log('c.js'); const c = '123'; console.log('a.js'); import('./Ckpwfego.js').then(res => { console.log(res, c); }); var main = /*#__PURE__*/ Object.freeze({ __proto__: null }); export { c, main as m };js// Ckpwfego.js import { c } from './main.js'; console.log('c.js'); import('./main.js') .then(function (n) { return n.m; }) .then(res => { console.log(res, c); });rollup的上述机制确保了在 动态导入 时,所有与 动态导入 所 共享的依赖项 都已被加载过了。对动态入口 D 计算必然已加载原子并移除依赖标记,详细的介绍过程可参见 代码块分配。- 模块
排序渲染
chunk算法 意味着在渲染一个chunk之前需要先了解这个chunk的所有依赖关系,还需要考虑到renderChunk钩子可能会引入新的依赖关系。
哈希占位符(Hash Placeholders)
因此,需要引入新的解决方案。核心思路就是为文件名引用设置 初始占位符,这样计算的 hash 值就是与文件名无关的 hash,只关注 chunk 自身的内容。
执行流程如下:
为每个
chunk分配 初始文件名。如果 文件名 中 没有哈希(options.chunkFileNames中没有[hash]占位符),这将是最终的文件名;但如果文件名中 包含哈希,则使用 等长占位符替代。tsclass Chunk { private preliminaryFileName: PreliminaryFileName | null = null; getPreliminaryFileName(): PreliminaryFileName { if (this.preliminaryFileName) { return this.preliminaryFileName; } let fileName: string; let hashPlaceholder: string | null = null; const { chunkFileNames, entryFileNames, file, format, preserveModules } = this.outputOptions; if (file) { fileName = basename(file); } else if (this.fileName === null) { const [pattern, patternName] = preserveModules || this.facadeModule?.isUserDefinedEntryPoint ? [entryFileNames, 'output.entryFileNames'] : [chunkFileNames, 'output.chunkFileNames']; fileName = renderNamePattern( typeof pattern === 'function' ? pattern(this.getPreRenderedChunkInfo()) : pattern, patternName, { format: () => format, hash: size => hashPlaceholder || (hashPlaceholder = this.getPlaceholder( patternName, size || DEFAULT_HASH_SIZE )), name: () => this.getChunkName() } ); if (!hashPlaceholder) { fileName = makeUnique(fileName, this.bundle); } } else { fileName = this.fileName; } if (!hashPlaceholder) { this.bundle[fileName] = FILE_PLACEHOLDER; } // Caching is essential to not conflict with the file name reservation above return (this.preliminaryFileName = { fileName, hashPlaceholder }); } getFileName(): string { return this.fileName || this.getPreliminaryFileName().fileName; } getImportPath(importer: string): string { return escapeId( getImportPath( importer, this.getFileName(), this.outputOptions.format === 'amd' && !this.outputOptions.amd.forceJsExtensionForImports, true ) ); } }ts// Four random characters from the private use area to minimize risk of // conflicts const hashPlaceholderLeft = '!~{'; const hashPlaceholderRight = '}~'; const hashPlaceholderOverhead = hashPlaceholderLeft.length + hashPlaceholderRight.length; // This is the size of a 128-bits xxhash with base64url encoding const MAX_HASH_SIZE = 21; export const DEFAULT_HASH_SIZE = 8; export const getHashPlaceholderGenerator = (): HashPlaceholderGenerator => { let nextIndex = 0; return (optionName, hashSize) => { if (hashSize > MAX_HASH_SIZE) { return error( logFailedValidation( `Hashes cannot be longer than ${MAX_HASH_SIZE} characters, received ${hashSize}. Check the "${optionName}" option.` ) ); } const placeholder = `${hashPlaceholderLeft}${toBase64( ++nextIndex ).padStart( hashSize - hashPlaceholderOverhead, '0' )}${hashPlaceholderRight}`; if (placeholder.length > hashSize) { return error( logFailedValidation( `To generate hashes for this number of chunks (currently ${nextIndex}), you need a minimum hash size of ${placeholder.length}, received ${hashSize}. Check the "${optionName}" option.` ) ); } return placeholder; }; };渲染
chunk中的所有模块。在第一步中已经有了 初始文件名,因此可以直接渲染所有 动态导入 和import.meta的chunk引用。旧算法将chunk content hash与dynamic import chunk hash和import.meta chunk hash分开计算,然后再统一计算一遍,新算法只计算一遍hash,之后修改hash值与chunk的内容相关,与文件名无关。渲染
chunk wrapper,同样使用 初始文件名 处理chunk导入。chunk wrapper的作用本质上,
chunk wrapper的操作是将chunk与其他chunk之间形成interop的关键。因为单个
chunk中是通过一个或多个module渲染而成的,渲染时rollup会将module与module间的import/export语句通过模块中的具体引用来替换(例如将import转换为对导出变量的直接引用)。但在
chunk之间,rollup(或用户通过splitChunks插件配置) 会对chunk graph进行进一步优化处理,可能会构建新的chunk间依赖关系(动态导入 或 静态导入)。因此rollup需要通过chunk wrapper操作将chunk与其他chunk之间形成interop,确保依赖链的完整。tsclass Chunk { async render(): Promise<ChunkRenderResult> { const { intro, outro, banner, footer } = await createAddons( outputOptions, pluginDriver, this.getRenderedChunkInfo() ); finalisers[format]( renderedSource, { accessedGlobals, dependencies: renderedDependencies, exports: renderedExports, hasDefaultExport, hasExports, id: preliminaryFileName.fileName, indent, intro, isEntryFacade: preserveModules || (facadeModule !== null && facadeModule.info.isEntry), isModuleFacade: facadeModule !== null, log: onLog, namedExportsMode: exportMode !== 'default', outro, snippets, usesTopLevelAwait }, outputOptions ); if (banner) magicString.prepend(banner); if (format === 'es' || format === 'cjs') { const shebang = facadeModule !== null && facadeModule.info.isEntry && facadeModule.shebang; if (shebang) { magicString.prepend(`#!${shebang}\n`); } } if (footer) magicString.append(footer); } }tsexport default function es( magicString: MagicStringBundle, { accessedGlobals, indent: t, intro, outro, dependencies, exports, snippets }: FinaliserOptions, { externalLiveBindings, freeze, generatedCode: { symbols }, importAttributesKey }: NormalizedOutputOptions ): void { const { n } = snippets; const importBlock = getImportBlock( dependencies, importAttributesKey, snippets ); if (importBlock.length > 0) intro += importBlock.join(n) + n + n; intro += getHelpersBlock( null, accessedGlobals, t, snippets, externalLiveBindings, freeze, symbols ); if (intro) magicString.prepend(intro); const exportBlock = getExportBlock(exports, snippets); if (exportBlock.length > 0) magicString.append(n + n + exportBlock.join(n).trim()); if (outro) magicString.append(outro); magicString.trim(); }通过
renderChunk钩子处理chunk。新算法还允许在
renderChunk插件钩子中访问完全chunk graph,尽管此时 名称是初始占位符。但由于rollup不对renderChunk的输出做假设,现在可以在该钩子中自由注入chunk名称。tsconst chunkGraph = getChunkGraph(chunks); async function transformChunk( magicString: MagicStringBundle, fileName: string, usedModules: Module[], chunkGraph: Record<string, RenderedChunk>, options: NormalizedOutputOptions, outputPluginDriver: PluginDriver, log: LogHandler ) { const code = await outputPluginDriver.hookReduceArg0( 'renderChunk', [ magicString.toString(), chunkGraph[fileName], options, { chunks: chunkGraph } ], (code, result, plugin) => { if (result == null) return code; if (typeof result === 'string') result = { code: result, map: undefined }; // strict null check allows 'null' maps to not be pushed to the chain, while 'undefined' gets the missing map warning if (result.map !== null) { const map = decodedSourcemap(result.map); sourcemapChain.push( map || { missing: true, plugin: plugin.name } ); } return result.code; } ); } function getChunkGraph(chunks: Chunk[]) { return Object.fromEntries( chunks.map(chunk => { const renderedChunkInfo = chunk.getRenderedChunkInfo(); return [renderedChunkInfo.fileName, renderedChunkInfo]; }) ); }通过将
chunk中所有 占位符 替换为 默认占位符 并生成哈希,计算chunk的 纯内容哈希。为了让 哈希值 只与
chunk内容本身相关,并且在不同构建中保持一致,需要 在计算哈希之前,将chunk中的 占位符 替换为一个 固定的、相同的 数值。这样,哈希值 就不会受到 占位符 具体内容的影响,从而 保证一致性 和 可重现性。tsconst REPLACER_REGEX = new RegExp( `${hashPlaceholderLeft}[0-9a-zA-Z_$]{1,${ MAX_HASH_SIZE - hashPlaceholderOverhead }}${hashPlaceholderRight}`, 'g' ); export const replacePlaceholdersWithDefaultAndGetContainedPlaceholders = ( code: string, placeholders: Set<string> ): { containedPlaceholders: Set<string>; transformedCode: string } => { const containedPlaceholders = new Set<string>(); const transformedCode = code.replace(REPLACER_REGEX, placeholder => { if (placeholders.has(placeholder)) { containedPlaceholders.add(placeholder); return `${hashPlaceholderLeft}${'0'.repeat( placeholder.length - hashPlaceholderOverhead )}${hashPlaceholderRight}`; } return placeholder; }); return { containedPlaceholders, transformedCode }; };通过
augmentChunkHash钩子来增强chunk的content-hash。tsconst { containedPlaceholders, transformedCode } = replacePlaceholdersWithDefaultAndGetContainedPlaceholders(code, placeholders); let contentToHash = transformedCode; const hashAugmentation = pluginDriver.hookReduceValueSync( 'augmentChunkHash', '', [chunk.getRenderedChunkInfo()], (augmentation, pluginHash) => { if (pluginHash) { augmentation += pluginHash; } return augmentation; } ); if (hashAugmentation) { contentToHash += hashAugmentation; }当所有的
chunk都完成content-hash的计算后,通过搜索每个chunk中包含哪些 占位符 并更新chunk的hash,计算最终的hash。递归检索chunk中所有依赖chunk的content-hash并进行合并来增强最终的chunk的content-hash。tsfunction generateFinalHashes( renderedChunksByPlaceholder: Map<string, RenderedChunkWithPlaceholders>, hashDependenciesByPlaceholder: Map<string, HashResult>, initialHashesByPlaceholder: Map<string, string>, placeholders: Set<string>, bundle: OutputBundleWithPlaceholders, getHash: GetHash ) { const hashesByPlaceholder = new Map<string, string>(initialHashesByPlaceholder); for (const placeholder of placeholders) { const { fileName } = renderedChunksByPlaceholder.get(placeholder)!; let contentToHash = ''; const hashDependencyPlaceholders = new Set<string>([placeholder]); for (const dependencyPlaceholder of hashDependencyPlaceholders) { const { containedPlaceholders, contentHash } = hashDependenciesByPlaceholder.get(dependencyPlaceholder)!; contentToHash += contentHash; for (const containedPlaceholder of containedPlaceholders) { // When looping over a map, setting an entry only causes a new iteration if the key is new hashDependencyPlaceholders.add(containedPlaceholder); } } let finalFileName: string | undefined; let finalHash: string | undefined; do { // In case of a hash collision, create a hash of the hash if (finalHash) { contentToHash = finalHash; } finalHash = getHash(contentToHash).slice(0, placeholder.length); finalFileName = replaceSinglePlaceholder(fileName, placeholder, finalHash); } while (bundle[lowercaseBundleKeys].has(finalFileName.toLowerCase())); bundle[finalFileName] = FILE_PLACEHOLDER; hashesByPlaceholder.set(placeholder, finalHash); } return hashesByPlaceholder; }用 最终哈希 替换 占位符。由于在第一步中使用 等长占位符,因此不会导致
source map的位置偏移,也就无需更新source map。tsimport { replacePlaceholders } from './hashPlaceholders'; function addChunksToBundle( renderedChunksByPlaceholder: Map<string, RenderedChunkWithPlaceholders>, hashesByPlaceholder: Map<string, string>, bundle: OutputBundleWithPlaceholders, nonHashedChunksWithPlaceholders: RenderedChunkWithPlaceholders[], pluginDriver: PluginDriver, options: NormalizedOutputOptions ) { for (const { chunk, code, fileName, sourcemapFileName, map } of renderedChunksByPlaceholder.values()) { let updatedCode = replacePlaceholders(code, hashesByPlaceholder); const finalFileName = replacePlaceholders(fileName, hashesByPlaceholder); } }tsconst REPLACER_REGEX = new RegExp( `${hashPlaceholderLeft}[0-9a-zA-Z_$]{1,${ MAX_HASH_SIZE - hashPlaceholderOverhead }}${hashPlaceholderRight}`, 'g' ); export const replacePlaceholders = ( code: string, hashesByPlaceholder: Map<string, string> ): string => code.replace( REPLACER_REGEX, placeholder => hashesByPlaceholder.get(placeholder) || placeholder );
为了 避免意外替换非占位符,占位符 利用了 javascript 支持 unicode 字符的特性。使用保留平面中的随机字符,如 \uf7f9\ue4d3(占位符开始)和 \ue3cc\uf1fe(占位符结束)。
占位符改造
[v3.0] Use ASCII characters for hash placeholders 中对 占位符 进行改造,解决如下问题:
防止转义问题
使用
unicode字符在某些工具链中会被 自动转义,导致 占位符 被破坏。调试更友好
相比看不懂的
unicode字符,新格式使用可见的ascii字符,这样 占位符 一目了然,开发者可以快速识别他们与某个chunk的关联。减少误匹配风险
新模式
_!~{\d+}~不是有效的javascript语法,只会出现在 字符串 和 注释 中,即使被错误替换也只会造成有限的损害,只有完全匹配特定数字序列时才会被替换。
新算法源码级分析
源码基线:当前 rollup 仓库最新版本
核心数据结构
新算法引入了几个关键的数据结构来协调 hash 计算:
// src/utils/renderChunks.ts 第 27-30 行
interface HashResult {
containedPlaceholders: Set<string>; // 该 chunk 代码中引用的其他 chunk 占位符
contentHash: string; // 该 chunk 的内容 hash(占位符已替换为默认值)
}
// src/Chunk.ts 第 83-91 行
interface PreliminaryFileName {
fileName: string; // 带占位符的文件名,如 "chunk-!~{1}~.js"
hashPlaceholder: string | null; // 占位符字符串,如 "!~{1}~"
}数据流向:
PreliminaryFileName在步骤 1 生成,包含占位符的文件名HashResult在步骤 5-6 计算,存储每个 chunk 的内容 hash 及其引用的其他 chunkgenerateFinalHashes()在步骤 7 使用这些数据计算最终 hash
循环依赖处理:传递闭包算法
步骤 7 中的 generateFinalHashes() 是新算法的核心创新,它通过 传递闭包 解决了循环依赖问题:
// src/utils/renderChunks.ts 第 293-329 行
function generateFinalHashes(...) {
for (const placeholder of placeholders) {
let contentToHash = '';
// 初始化依赖集合,只包含自身
const hashDependencyPlaceholders = new Set<string>([placeholder]);
// 关键:遍历 Set 的同时动态添加新元素
// JavaScript Set 的特性:在迭代过程中添加新元素会在后续迭代中被访问
for (const dependencyPlaceholder of hashDependencyPlaceholders) {
const { containedPlaceholders, contentHash } =
hashDependenciesByPlaceholder.get(dependencyPlaceholder)!;
// 累加依赖的内容 hash
contentToHash += contentHash;
// 将该依赖引用的其他 chunk 也加入集合
for (const containedPlaceholder of containedPlaceholders) {
hashDependencyPlaceholders.add(containedPlaceholder);
}
}
// 对累加的内容计算最终 hash
finalHash = getHash(contentToHash).slice(0, placeholder.length);
}
}算法解析:
假设存在循环依赖 A ↔ B(A 引用 B,B 也引用 A):
| 步骤 | hashDependencyPlaceholders | contentToHash |
|---|---|---|
| 初始 | {A} | "" |
| 处理 A | {A, B} (发现 A 引用 B) | contentHash_A |
| 处理 B | {A, B} (B 引用 A,但 A 已存在) | contentHash_A + contentHash_B |
| 结束 | 集合不再增长,循环终止 | 最终值 |
关键点:
- 使用
Set自动去重,避免无限循环 - JavaScript 的
Set在迭代时可以动态添加元素 - 最终 hash =
hash(所有依赖链上 chunk 的 contentHash 拼接)
新旧算法 Hash 组成对比
| 组成部分 | 旧算法 | 新算法 |
|---|---|---|
| 模块渲染后代码(preRender 阶段) | ✅ | ✅ |
| 导出名称映射 | ✅ | ✅(包含在渲染代码中) |
augmentChunkHash 插件返回值 | ✅ | ✅ |
intro/outro/banner/footer | ✅ | ✅(包含在渲染代码中) |
options.format | ✅ | ✅(通过 wrapper 体现) |
| 外部模块路径 | ✅ | ✅(包含在渲染代码中) |
| 动态导入的最终路径 | ❌ | ✅(占位符形式) |
import.meta.url 的最终值 | ❌ | ✅(占位符形式) |
| import/export 语句(wrapper) | ❌ | ✅ |
renderChunk 钩子的修改 | ❌ | ✅ |
| 所有依赖 chunk 的 hash | 部分 | ✅(传递闭包) |
完整执行流程(源码级)
Bundle.generate() [src/Bundle.ts:53-105]
│
├─ getHashPlaceholder = getHashPlaceholderGenerator()
│ └─ 创建占位符生成器,格式 "!~{序号}~"
│
├─ generateChunks() → 生成所有 Chunk 对象
│ └─ 每个 Chunk 调用 getPreliminaryFileName() [src/Chunk.ts:580-616]
│ └─ 返回 { fileName: "chunk-!~{1}~.js", hashPlaceholder: "!~{1}~" }
│
└─ renderChunks() [src/utils/renderChunks.ts:40-89]
│
├─ 1. reserveEntryChunksInBundle() → 预留入口 chunk 文件名
│
├─ 2. Promise.all(chunks.map(chunk => chunk.render()))
│ └─ Chunk.render() [src/Chunk.ts:703-788]
│ ├─ renderModules() → 渲染所有模块代码
│ ├─ finalisers[format]() → 生成 wrapper(import/export 语句)
│ └─ 返回 ChunkRenderResult(含 MagicString)
│
├─ 3. transformChunksAndGenerateContentHashes()
│ │ [src/utils/renderChunks.ts:202-291]
│ │
│ ├─ 3.1 对每个 chunk 并行执行:
│ │ └─ transformChunk() → 调用 renderChunk 插件钩子
│ │
│ ├─ 3.2 replacePlaceholdersWithDefaultAndGetContainedPlaceholders()
│ │ └─ 将 "!~{1}~" 替换为 "!~{00000000}~"
│ │ └─ 记录 containedPlaceholders(引用了哪些其他 chunk)
│ │
│ ├─ 3.3 pluginDriver.hookReduceValueSync('augmentChunkHash', ...)
│ │ └─ 插件可注入额外内容参与 hash 计算
│ │
│ └─ 3.4 getHash(contentToHash) → 计算初始 contentHash
│ └─ 存入 hashDependenciesByPlaceholder Map
│
├─ 4. generateFinalHashes() [src/utils/renderChunks.ts:293-329]
│ └─ 传递闭包算法:合并所有依赖的 contentHash 计算最终 hash
│
└─ 5. addChunksToBundle() [src/utils/renderChunks.ts:332-405]
└─ replacePlaceholders() → 用最终 hash 替换占位符新算法的影响
插件钩子执行流程图
插件钩子执行流程图发生了变化,以下是 改造后的流程图:
对比 改造前 的流程图:
发生了如下变化:
执行时机上的变化
banner、footer、intro、outro插件钩子的执行时机延后,原先是在renderStart插件钩子执行完后执行。现在是在renderChunk插件钩子执行前执行。augmentChunkHash插件钩子的执行时机延后,原先是在renderDynamicImport插件钩子决策之后执行。现在是在renderChunk插件钩子执行后执行。
执行方式上的变化
banner、footer、intro、outro插件钩子由 并行执行 改为现在的 串行执行。
钩子可用的 Chunk 信息
部分钩子现在能接收额外信息。详细介绍这些变化前,先定义几个关键类型:
PrerenderedChunk
PrerenderedChunk 包含在任何渲染发生前以及 chunk 名称 生成前 的基本 chunk 信息。此次更新后,这种简化的 chunk 信息仅传递给 entryFileNames 和 chunkFileNames 选项。从上述新的流程图中可以获知,现阶段 无法获取到已渲染过的模块信息。作为替代,现在会包含 moduleIds 列表信息,至少能让开发者大致了解 chunk 中包含的内容。
interface PreRenderedChunk {
exports: string[];
facadeModuleId: string | null;
isDynamicEntry: boolean;
isEntry: boolean;
isImplicitEntry: boolean;
moduleIds: string[];
name: string;
type: 'chunk';
}RenderedChunk
RenderedChunk 包含 chunk 的完整渲染信息。imports 与 已渲染模块 中的文件名将包含 占位符 而 非文件哈希。RenderedChunk 可以在 renderChunk 钩子、augmentChunkHash 钩子以及 banner、footer、intro、outro 钩子和选项中可用。
同时,renderChunk 的签名被扩展了第四个参数 meta: { chunks: { [fileName: string]: RenderedChunk } },可以提供对整个 chunk graph 的访问。
额外注意的点
需要注意的是,当在 renderChunk 中 添加 或 删除 imports 或 exports 时,rollup 不会做额外的工作帮助维护 RenderedChunk 对象。因此 用户插件 现在应该注意要自己维护 RenderChunk 对象,更新最新的 RenderedChunk 对象信息。这将为 后续的插件 和 最终 bundle 提供正确的信息。因为随后 rollup 会根据 RenderedChunk 对象中的信息来替换 imports、importedBindings 和 dynamicImports 占位符,生成最终的哈希值(除了 implicitlyLoadedBefore 和 fileName)。
interface RenderedChunk {
dynamicImports: string[];
exports: string[];
facadeModuleId: string | null;
fileName: string;
implicitlyLoadedBefore: string[];
importedBindings: {
[imported: string]: string[];
};
imports: string[];
isDynamicEntry: boolean;
isEntry: boolean;
isImplicitEntry: boolean;
moduleIds: string[];
modules: {
[id: string]: RenderedModule;
};
name: string;
referencedFiles: string[];
type: 'chunk';
}新增功能
intro、outro、banner、footer作为函数,他们现在会在每个chunk中被调用。虽然他们无法访问chunk中的已渲染模块,但会获得chunk中所有包含的moduleIds列表。哈希长度可在 文件名模式 中更改,例如
[name]-[hash:12].js将创建12个字符长度的哈希。
重大变化
entryFileNames和chunkFileNames不能访问 包含已渲染模块内容 的modules对象。取而代之可以访问包含的moduleIds列表。- 插件钩子的顺序已更改,请比较上面的图表与 Rollup 文档 中的图表。
renderChunk钩子中的fileName和引用的imports将获得带有 占位符 而非 哈希 的文件名。不过,这些文件名仍可安全用于钩子的返回值,因为任何哈希占位符最终都会被实际哈希替换。
测试用例
import('./b.js').then(res => {
console.log(res);
});import('./c.js').then(res => {
console.log(res);
});
export const qux = 'QUX';export const c = 'c';import { defineConfig } from 'rollup';
export default defineConfig({
input: 'main.js',
output: {
dir: 'dist',
format: 'es',
chunkFileNames: '[hash].js'
}
});那么打包后的产物如下:
import('./CM53L61n.js').then(res => {
console.log(res);
});import('./CPjDz2XZ.js').then(res => {
console.log(res);
});
const qux = 'QUX';
export { qux };const c = 'c';
export { c };那么如果只修改了 b.js 文件名为 bNext.js,其余保持不变。
import('./bNext.js').then(res => {
console.log(res);
});import('./c.js').then(res => {
console.log(res);
});
export const qux = 'QUX';export const c = 'c';import { defineConfig } from 'rollup';
export default defineConfig({
input: 'main.js',
output: {
dir: 'dist',
format: 'es',
chunkFileNames: '[hash].js'
}
});那么打包后的产物如下: