哈希难题
Related Materials
[v3.0] New hashing algorithm that "fixes (nearly) everything" - GitHub
rollup 在 v3.0 版本中对 hash 算法进行了重构,引入了 新的 hash 算法,解决了长期存在的 hash 不稳定问题,正确处理 renderChunk 插件转换并支持循环依赖。
问题阐述
旧版本 的 哈希算法 的 执行流程 如下:
- 渲染除
动态导入和import.meta的chunk引用外的所有模块。 - 基于此计算
chunk中所有模块的内容哈希。 - 考虑所有已知依赖和可能添加到
chunk wrapper中的动态内容来扩展哈希。 - 更新动态导入和
import.meta的chunk引用。 - 渲染包含所有
静态导入和导出的chunk wrapper。 - 通过
renderChunk插件钩子处理结果。
总结一句话就是先计算块的所以依赖模块的 content hash,然后再计算块的动态引用块的 content hash 和 import.meta 的 content hash,最后更新块的 动态导入 和 import.meta 的引用。
存在的问题:
renderChunk插件钩子打破了content hash在
renderChunk中的 任何转换 都会被rollup完全忽略,不会影响chunk的hash值。那么就存在不同内容但hash相同的情况。chunk wrapper场景复杂让
rollup来维护chunk wrapper中的每一种变化来扩展hash变更,需要 考虑 太多的 边界问题。哈希值不稳定
存在
chunk的内容本身并没有变更,但因为不相关的变更(修改文件名)而导致chunk的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的上述机制确保了在 动态导入 时,所有与 动态导入 所 共享的依赖项 都已被加载过了。这种机制对于处理复杂的模块依赖关系非常重要,尤其是在大型项目中,模块之间的依赖关系可能非常复杂且相互交织。通过这种方式,rollup能够有效地管理和优化模块的 加载顺序 和 依赖关系。- 模块
排序渲染
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语法,只会出现在 字符串 和 注释 中,即使被错误替换也只会造成有限的损害,只有完全匹配特定数字序列时才会被替换。
新算法的影响
插件钩子执行流程图
插件钩子执行流程图发生了变化,以下是 改造后的流程图:
对比 改造前 的流程图:
发生了如下变化:
执行时机上的变化
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'
}
});那么打包后的产物如下:
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 };通过上述的例子,可以发现对于 文件名称变更 在新算法中不会导致 chunk 的 hash 值发生变化。