Skip to content

哈希难题

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

问题阐述

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

  1. 渲染除 动态导入import.metachunk 引用外的所有模块。
  2. 基于此计算 chunk 中所有模块的内容哈希。
  3. 考虑所有已知依赖和可能添加到 chunk wrapper 中的动态内容来扩展哈希。
  4. 更新动态导入和 import.metachunk 引用。
  5. 渲染包含所有 静态导入导出chunk wrapper
  6. 通过 renderChunk 插件钩子处理结果。

总结一句话就是先计算块的所以依赖模块的 content hash,然后再计算块的动态引用块的 content hashimport.metacontent hash,最后更新块的 动态导入import.meta 的引用。

存在的问题:

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

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

  2. chunk wrapper 场景复杂

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

  3. 哈希值不稳定

    存在 chunk 的内容本身并没有变更,但因为不相关的变更(修改文件名)而导致 chunkhash 发生变化。

解决方案

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

解决哈希问题的一种方法是 先渲染 没有 任何依赖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 的上述机制确保了在 动态导入 时,所有与 动态导入共享的依赖项 都已被加载过了。这种机制对于处理复杂的模块依赖关系非常重要,尤其是在大型项目中,模块之间的依赖关系可能非常复杂且相互交织。通过这种方式,rollup 能够有效地管理和优化模块的 加载顺序依赖关系

  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 语法,只会出现在 字符串注释 中,即使被错误替换也只会造成有限的损害,只有完全匹配特定数字序列时才会被替换。

新算法的影响

插件钩子执行流程图

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

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 值发生变化。

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)