哈希难题
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'
}
});
那么打包后的产物如下: