Skip to content

hashing dilemma

考虑 rollup#4543 的实现,rollup v3 引入了一种新的哈希算法,可以有效避免 rollup v3 之前的版本中打破了 content hash 的初衷。

问题阐述

旧版本的哈希算法(rollup v3 之前)

算法执行流程如下:

  1. 渲染所有模块但不包括动态导入和 import.meta 块引用。
  2. 基于此,计算块中所有模块的内容哈希。
  3. 额外扩展所有已知的依赖项和可能在块包装器中添加的动态内容。
  4. 更新动态导入和 import.meta 块引用。
  5. 渲染包含所有静态导入和导出的块包装器。
  6. 通过 renderChunk 插件钩子运行结果。

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

存在的问题:

  1. renderChunk 中的任何转换都会被完全忽略。也就是说 renderChunk 插件中的任何转换都不会影响 chunkhash 值,存在不同内容但 hash 相同的情况,这就打破了 content hash 的初衷。
  2. rollup 来维护块中的每一种变化然后再触发 hash 的更新,这存在太多的边界问题,是一项脆弱的工作。
  3. 哈希值不稳定,有可能因为与内容不相关的变更而导致 hash 的变化(比如更新文件命名)。

可能的解决方案:排序渲染:

一种解决方法是首先渲染所有没有任何依赖项的块,然后迭代地渲染其他块,这些块只包含已经渲染的块作为依赖项。直到所有块都被渲染。在某些情况下这样做可以起作用,但这种方法有几个缺点:

  1. 它不支持块之间的循环依赖关系。这是一个非常重要的特性,因为在这种情况下,循环依赖也意味着两个块相互动态导入彼此。此外,rollup 在动态导入时严重依赖于此机制,通过将导入块和目标导入之间共享的所有依赖项移至导入块中,在被引入的块中出现了静态引用来自于导入模块,以以下例子为例:

    假设有三个模块,模块 main(入口模块)、模块 b 和模块 c,其中:

    • 模块 main 动态导入模块 b,静态导入模块 c
    • 模块 b 动态导入模块 main,静态导入模块 c
    js
    import { c } from './c.js';
    console.log('a.js');
    import('./b.js').then(res => {
      console.log(res, c);
    });
    js
    import { c } from './c.js';
    console.log('c.js');
    import('./main.js').then(res => {
      console.log(res, c);
    });
    js
    console.log('c.js');
    export const c = '123';

    在这种情况下,rollup 会将模块 main 和模块 b 之间共享的静态依赖项(模块 c)移至模块 main 中。这意味着在模块 b 中会出现对模块 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
    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 能够有效地管理和优化模块的加载顺序和依赖关系。

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

新版本的哈希算法(Hash placeholders)

因此,需要新的解决方案来做支持。核心思路就是为文件名引用设置 初始占位符,这样计算的 hash 值就是与文件名无关的 hash,只关注 chunk 的内容。执行流程如下:

  1. 为每个块分配一个初始的块名称。如果文件名中没有哈希(options.chunkFileNames 中没有 [hash] 占位符),这将是最终的文件名,但如果文件名中包含哈希,则会包含一个等长的占位符。
  2. 渲染块中的所有模块。由于我们已经有了初始的文件名,因此可以直接渲染所有动态导入和 import.meta 块引用。旧算法将 chunk content hashdynamic import chunk hashimport.meta chunk hash 分开计算,然后再统一计算一遍,新算法只计算一遍 hash,改 hash 值与 chunk 的内容相关,与文件名无关。
  3. 渲染块包装器。我们也使用初始的文件名进行块导入。
  4. 通过 renderChunk 钩子运行块。
  5. 通过将块中的所有占位符替换为默认占位符,然后生成哈希,计算仅内容的哈希。
  6. 一旦所有块都完成此操作,通过搜索每个块中包含哪些占位符并更新块哈希,计算最终哈希,同时考虑所有传递依赖项的仅内容哈希。
  7. 用最终哈希替换占位符。由于我们确保占位符与哈希的长度相同,因此不需要更新源映射。

为了避免意外替换非占位符,占位符利用了 javascript 支持 Unicode 字符的事实。我使用了保留平面中的四个随机字符,目前是 \uf7f9\ue4d3 作为占位符的开始和 \ue3cc\uf1fe 作为占位符的结束。

这种方法还允许我们在 renderChunk 中完全访问块图,尽管此时名称是初步的。但由于 rollup 不对 renderChunk 的输出做假设,您现在可以在该钩子中自由注入块名称。

改造后的影响

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

parallel
sequential
first
async
sync

改造前的插件钩子执行流程图:

对比一下可以很直观的看出变化点

  1. 执行时机上的变化

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

  • bannerfooterintrooutro 钩子由原先的并行执行改为现在的串行执行。

在插件钩子中可以得到的块信息

部分钩子现在可以获取到额外的数据。为了详细说明,我们首先定义一些类型:

  • PrerenderedChunk 在任何渲染发生之前以及块名称生成之前包含非常基本的块信息。在此更新之后,这一简化的块信息集仅传递给 entryFileNameschunkFileNames 选项。从上述新的流程图中可以获知,现阶段我们无法获取到已渲染过的模块信息。取而代之的是,我们可以获得一个 moduleIds 列表,以便至少可以了解块中大致包含的内容。
typescript
interface PreRenderedChunk {
  exports: string[];
  facadeModuleId: string | null;
  isDynamicEntry: boolean;
  isEntry: boolean;
  isImplicitEntry: boolean;
  moduleIds: string[];
  name: string;
  type: 'chunk';
}
  • RenderedChunk 包含块的完整渲染信息。导入的文件名和渲染模块中的文件名为占位符而不是先前的文件哈希。RenderedChunk 可以在 renderChunk 钩子、augmentChunkHash 钩子以及 bannerfooterintrooutro 钩子和选项中可以直接使用。
    • 此外,renderChunk 的签名被扩展了第四个参数 meta: { chunks: { [fileName: string]: RenderedChunk } },可以提供对整个块依赖图的访问。

还要注意,当在 renderChunk 中添加或删除 importsexports 时,rollup 不会耗费性能帮助维护 RenderedChunk 对象。因此插件现在应该注意要自己维护 RenderChunk 对象,更新最新的 RenderedChunk 对象信息。这将为后续要执行的插件和最终产物提供正确的信息,因为随后 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 作为函数,它们现在会在每个块中调用。虽然它们无法访问块中的已渲染模块,但它们会获得块中所有模块的 ID 列表。

  • 哈希长度可以在 file name pattern 中进行更改,例如 [name]-[hash:12].js 将创建 12 个字符的哈希。

breaking changes

  • entryFileNameschunkFileNames 不能再访问已渲染模块的信息。取而代之的是可以访问 moduleIds 列表。
  • 插件钩子的顺序已更改,请比较上面的图表与 Rollup 文档 中的图表。
  • 文件名称和块中包含的导入文件名将在 renderChunk 钩子中获得带有占位符而不是哈希的文件名。占位符文件名可以安全地在钩子中使用和替换,因为任何哈希占位符最终将被实际哈希替换。

例子说明

线上演示库

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

对于 rollup v3.0 之前的版本来说,由于最后的 hash 值依赖于文件名引用,那么若只更新文件名, chunkhash 值也会发生变化。

Discuss

Released under the MIT License. (ee0e562)