Skip to content

哈希难题

rollupv3.0 版本中对 hash 算法进行了重构,引入了 新的 hash 算法,解决了长期存在的 hash 不稳定问题,正确处理 renderChunk 插件转换并支持循环依赖。

问题阐述

旧版本哈希算法执行流程 如下:

  1. 渲染所有模块,但排除动态导入和 import.meta chunk 引用

    • 先生成模块的主体代码
    • 但此时动态导入(如 import('./foo.js'))和 import.meta.url 中的 chunk 引用还不知道最终文件名,所以先跳过
  2. 基于上述内容计算每个 chunk 中所有模块的 content hash

    • 用模块内容生成哈希值
  3. 扩展哈希:考虑所有已知依赖 + chunk wrapper 中可能添加的动态内容

    • Chunk wrapper 是指包裹模块内容的格式化代码(如 ES 模块的 import/export 语句,CommonJS 的 require/exports)
    • 需要把这些可能变化的内容也纳入哈希计算
  4. 更新动态导入和 import.meta chunk 引用

    • 现在才把真实的 chunk 文件名填进去
  5. 渲染 chunk wrappers(包含所有静态 import 和 export)

  6. 通过 renderChunk 插件钩子处理结果

存在的问题:

  1. renderChunk 插件钩子打破了 content hash

    renderChunk 中的 任何转换 都会被 rollup 完全忽略,不会影响 chunkhash 值。那么就存在不同内容但 hash 相同的情况。

  2. Chunk wrapper 未参与 hash 计算

    rollup 来维护 chunk wrapper 中的每一种变化来扩展 hash 变更,需要 考虑 太多的 边界问题

旧算法最根本的问题就是 hash 不反映最终输出内容,因为 renderChunk 是很多插件(如 terser 压缩)的核心处理环节,这部分变化被完全忽略,使得 hash 作为缓存标识失去了可信度。


旧算法源码级分析(PR #4543 之前)

源码基线:commit 9216f5235^(即 PR #4543 合入前一个 commit)

Hash 计算的完整组成

旧算法的 Hash 由两层结构组成:

第一层:getRenderedHash() — 单个 chunk 的内容 hash

typescript
// 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

typescript
// 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:footercomputeContentHashWithDependencies()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() 调用顺序

typescript
// 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() 不渲染最终路径

typescript
// 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() 中的后处理顺序

typescript
// 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

但导出名称变化(如 foobar改变 hash,因为 ③ 中包含了这个信息。


解决方案

排序渲染(尝试解决哈希问题)

解决哈希问题的一种方法是 先渲染 没有 任何依赖chunks,然后 迭代渲染 那些 仅依赖已渲染 chunkschunks,直到所有 chunks 都被渲染完成。虽然这种方法在某些情况下可行,但存在几个明显缺陷:

  1. 不支持 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 计算必然已加载原子并移除依赖标记,详细的介绍过程可参见 代码块分配

  2. 排序渲染 chunk 算法 意味着在渲染一个 chunk 之前需要先了解这个 chunk 的所有依赖关系,还需要考虑到 renderChunk 钩子可能会引入新的依赖关系。

哈希占位符(Hash Placeholders)

因此,需要引入新的解决方案。核心思路就是为文件名引用设置 初始占位符,这样计算的 hash 值就是与文件名无关的 hash,只关注 chunk 自身的内容。

执行流程如下

  1. 为每个 chunk 分配 初始文件名。如果 文件名没有哈希(options.chunkFileNames 中没有 [hash] 占位符),这将是最终的文件名;但如果文件名中 包含哈希,则使用 等长占位符替代

    ts
    class 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;
        };
      };
  2. 渲染 chunk 中的所有模块。在第一步中已经有了 初始文件名,因此可以直接渲染所有 动态导入import.metachunk 引用。旧算法将 chunk content hashdynamic import chunk hashimport.meta chunk hash 分开计算,然后再统一计算一遍,新算法只计算一遍 hash,之后修改 hash 值与 chunk 的内容相关,与文件名无关。

  3. 渲染 chunk wrapper,同样使用 初始文件名 处理 chunk 导入。

    chunk wrapper 的作用

    本质上,chunk wrapper 的操作是将 chunk 与其他 chunk 之间形成 interop 的关键。

    因为单个 chunk 中是通过 一个多个 module 渲染而成的,渲染时 rollup 会将 modulemodule 间的 import/export 语句通过模块中的具体引用来替换(例如将 import 转换为对导出变量的直接引用)。

    但在 chunk 之间,rollup(或用户通过 splitChunks 插件配置) 会对 chunk graph 进行进一步优化处理,可能会构建新的 chunk 间依赖关系(动态导入静态导入)。因此 rollup 需要通过 chunk wrapper 操作将 chunk 与其他 chunk 之间形成 interop,确保依赖链的完整。

    ts
    class 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);
      }
    }
    ts
    export 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();
    }
  4. 通过 renderChunk 钩子处理 chunk

    新算法还允许在 renderChunk 插件钩子中访问完全 chunk graph,尽管此时 名称是初始占位符。但由于 rollup 不对 renderChunk 的输出做假设,现在可以在该钩子中自由注入 chunk 名称。

    ts
    const 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];
         })
       );
     }
  5. 通过将 chunk 中所有 占位符 替换为 默认占位符 并生成哈希,计算 chunk纯内容哈希

    为了让 哈希值 只与 chunk 内容本身相关,并且在不同构建中保持一致,需要 在计算哈希之前,将 chunk 中的 占位符 替换为一个 固定的相同的 数值。这样,哈希值 就不会受到 占位符 具体内容的影响,从而 保证一致性可重现性

    ts
    const 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 };
      };
  6. 通过 augmentChunkHash 钩子来增强 chunkcontent-hash

    ts
     const { 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;
     }
  7. 当所有的 chunk 都完成 content-hash 的计算后,通过搜索每个 chunk 中包含哪些 占位符 并更新 chunkhash,计算最终的 hash。递归检索 chunk 中所有依赖 chunkcontent-hash 并进行合并来增强最终的 chunkcontent-hash

    ts
      function 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;
      }
  8. 最终哈希 替换 占位符。由于在第一步中使用 等长占位符,因此不会导致 source map 的位置偏移,也就无需更新 source map

    ts
    import { 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);
       }
    }
    ts
    const 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 中对 占位符 进行改造,解决如下问题:

  1. 防止转义问题

    使用 unicode 字符在某些工具链中会被 自动转义,导致 占位符 被破坏。

  2. 调试更友好

    相比看不懂的 unicode 字符,新格式使用可见的 ascii 字符,这样 占位符 一目了然,开发者可以快速识别他们与某个 chunk 的关联。

  3. 减少误匹配风险

    新模式 _!~{\d+}~ 不是有效的 javascript 语法,只会出现在 字符串注释 中,即使被错误替换也只会造成有限的损害,只有完全匹配特定数字序列时才会被替换。

新算法源码级分析

源码基线:当前 rollup 仓库最新版本

核心数据结构

新算法引入了几个关键的数据结构来协调 hash 计算:

typescript
// 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}~"
}

数据流向

  1. PreliminaryFileName 在步骤 1 生成,包含占位符的文件名
  2. HashResult 在步骤 5-6 计算,存储每个 chunk 的内容 hash 及其引用的其他 chunk
  3. generateFinalHashes() 在步骤 7 使用这些数据计算最终 hash

循环依赖处理:传递闭包算法

步骤 7 中的 generateFinalHashes() 是新算法的核心创新,它通过 传递闭包 解决了循环依赖问题:

typescript
// 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):

步骤hashDependencyPlaceholderscontentToHash
初始{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部分✅(传递闭包)

完整执行流程(源码级)

text
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 替换占位符

新算法的影响

插件钩子执行流程图

插件钩子执行流程图发生了变化,以下是 改造后的流程图

parallel
sequential
first
async
sync

对比 改造前 的流程图:

发生了如下变化:

  1. 执行时机上的变化

    • bannerfooterintrooutro 插件钩子的执行时机延后,原先是在 renderStart 插件钩子执行完后执行。现在是在 renderChunk 插件钩子执行前执行。
    • augmentChunkHash 插件钩子的执行时机延后,原先是在 renderDynamicImport 插件钩子决策之后执行。现在是在 renderChunk 插件钩子执行后执行。
  2. 执行方式上的变化

  • bannerfooterintrooutro 插件钩子由 并行执行 改为现在的 串行执行

钩子可用的 Chunk 信息

部分钩子现在能接收额外信息。详细介绍这些变化前,先定义几个关键类型:

PrerenderedChunk

PrerenderedChunk 包含在任何渲染发生前以及 chunk 名称 生成前 的基本 chunk 信息。此次更新后,这种简化的 chunk 信息仅传递给 entryFileNameschunkFileNames 选项。从上述新的流程图中可以获知,现阶段 无法获取到已渲染过的模块信息。作为替代,现在会包含 moduleIds 列表信息,至少能让开发者大致了解 chunk 中包含的内容。

typescript
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 钩子以及 bannerfooterintrooutro 钩子和选项中可用。

同时,renderChunk 的签名被扩展了第四个参数 meta: { chunks: { [fileName: string]: RenderedChunk } },可以提供对整个 chunk graph 的访问。

额外注意的点

需要注意的是,当在 renderChunk添加删除 importsexports 时,rollup 不会做额外的工作帮助维护 RenderedChunk 对象。因此 用户插件 现在应该注意要自己维护 RenderChunk 对象,更新最新的 RenderedChunk 对象信息。这将为 后续的插件最终 bundle 提供正确的信息。因为随后 rollup 会根据 RenderedChunk 对象中的信息来替换 importsimportedBindingsdynamicImports 占位符,生成最终的哈希值(除了 implicitlyLoadedBeforefileName)。

typescript
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';
}

新增功能

  • introoutrobannerfooter 作为函数,他们现在会在每个 chunk 中被调用。虽然他们无法访问 chunk 中的已渲染模块,但会获得 chunk 中所有包含的 moduleIds 列表。

  • 哈希长度可在 文件名模式 中更改,例如 [name]-[hash:12].js 将创建 12 个字符长度的哈希。

重大变化

  • entryFileNameschunkFileNames 不能访问 包含已渲染模块内容modules 对象。取而代之可以访问包含的 moduleIds 列表。
  • 插件钩子的顺序已更改,请比较上面的图表与 Rollup 文档 中的图表。
  • renderChunk 钩子中的 fileName 和引用的 imports 将获得带有 占位符 而非 哈希 的文件名。不过,这些文件名仍可安全用于钩子的返回值,因为任何哈希占位符最终都会被实际哈希替换。

测试用例

线上演示库

js
import('./b.js').then(res => {
  console.log(res);
});
js
import('./c.js').then(res => {
  console.log(res);
});
export const qux = 'QUX';
js
export const c = 'c';
js
import { defineConfig } from 'rollup';

export default defineConfig({
  input: 'main.js',
  output: {
    dir: 'dist',
    format: 'es',
    chunkFileNames: '[hash].js'
  }
});

那么打包后的产物如下:

js
import('./CM53L61n.js').then(res => {
  console.log(res);
});
js
import('./CPjDz2XZ.js').then(res => {
  console.log(res);
});
const qux = 'QUX';

export { qux };
js
const c = 'c';

export { c };

那么如果只修改了 b.js 文件名为 bNext.js,其余保持不变。

线上演示库

js
import('./bNext.js').then(res => {
  console.log(res);
});
js
import('./c.js').then(res => {
  console.log(res);
});
export const qux = 'QUX';
js
export const c = 'c';
js
import { defineConfig } from 'rollup';

export default defineConfig({
  input: 'main.js',
  output: {
    dir: 'dist',
    format: 'es',
    chunkFileNames: '[hash].js'
  }
});

那么打包后的产物如下:

js
import('./CM53L61n.js').then(res => {
  console.log(res);
});
js
import('./CPjDz2XZ.js').then(res => {
  console.log(res);
});
const qux = 'QUX';

export { qux };
js
const c = 'c';

export { c };

通过上述的例子,可以发现对于 文件名称变更 在新算法中不会导致 chunkhash 值发生变化。

贡献者

页面历史

Discuss

根据 CC BY-SA 4.0 许可证发布。 (ca50eaa)