Skip to content

Incremental Build

目前,rollup 会在 watch 模式下,自动使用 cache 机制来实现增量构建。cache 模式下,rollup 的增量构建类似于 reactfiber 树的增量更新,只会编译因文件修改所关联的模块,而不会编译其他无关联的模块。先来介绍一下 rollupcache 机制的决策逻辑。

Cache Hit Conditions

rollup 缓存命中逻辑具体实现如下:

ts
const cachedModule = this.graph.cachedModules.get(id);
if (
  cachedModule &&
  !cachedModule.customTransformCache &&
  cachedModule.originalCode === sourceDescription.code &&
  !(await this.pluginDriver.hookFirst('shouldTransformCachedModule', [
    {
      ast: cachedModule.ast,
      code: cachedModule.code,
      id: cachedModule.id,
      meta: cachedModule.meta,
      moduleSideEffects: cachedModule.moduleSideEffects,
      resolvedSources: cachedModule.resolvedIds,
      syntheticNamedExports: cachedModule.syntheticNamedExports
    }
  ]))
) {
  if (cachedModule.transformFiles) {
    for (const emittedFile of cachedModule.transformFiles)
      this.pluginDriver.emitFile(emittedFile);
  }
  await module.setSource(cachedModule);
}

需要满足以下几个条件才可以复用缓存:

  • 判断缓存模块存在,首先根据 resolveId 检查是否存在缓存的模块 cachedModule。由于 rollup 暂时不支持 persistent cache,因此初次构建时,cachedModule 不存在,跳过缓存。

    js
    const cachedModule = this.graph.cachedModules.get(id);
  • 若插件中使用了自定义缓存(即用户插件主动使用了插件上下文提供的 this.cache,为指定模块的解析设置缓存),此时 this.cache 所关联的模块将跳过缓存。

  • 前后代码是否发生变更,若变更则跳过缓存。

  • 调用 shouldTransformCachedModule 钩子 为指定模块跳过缓存。

Cache Timing

rollup 在构建完所有模块后,即触发了 buildEnd 事件。之后 rollup 就会通过 rawInputOptions.cache 标识位来确认是否需要缓存。

ts
export async function rollupInternal(
  rawInputOptions: RollupOptions,
  watcher: RollupWatcher | null
): Promise<RollupBuild> {
  // remove the cache object from the memory after graph creation (cache is not used anymore)
  const useCache = rawInputOptions.cache !== false;
  await catchUnfinishedHookActions(graph.pluginDriver, async () => {
    try {
      timeStart('initialize', 2);
      await graph.pluginDriver.hookParallel('buildStart', [inputOptions]);
      timeEnd('initialize', 2);
      await graph.build();
    } catch (error_: any) {
      const watchFiles = Object.keys(graph.watchFiles);
      if (watchFiles.length > 0) {
        error_.watchFiles = watchFiles;
      }
      await graph.pluginDriver.hookParallel('buildEnd', [error_]);
      await graph.pluginDriver.hookParallel('closeBundle', []);
      throw error_;
    }
    await graph.pluginDriver.hookParallel('buildEnd', []);
  });
  const result: RollupBuild = {
    cache: useCache ? graph.getCache() : undefined
  };
  return result;
}

默认情况下 rollup 会执行缓存操作(即 rawInputOptions.cache !== false 为真),因此通过 graph.getCache() 缓存所需的信息。

ts
class Graph {
  getCache(): RollupCache {
    // handle plugin cache eviction
    for (const name in this.pluginCache) {
      const cache = this.pluginCache[name];
      let allDeleted = true;
      for (const [key, value] of Object.entries(cache)) {
        if (value[0] >= this.options.experimentalCacheExpiry)
          delete cache[key];
        else allDeleted = false;
      }
      if (allDeleted) delete this.pluginCache[name];
    }

    return {
      modules: this.modules.map(module => module.toJSON()),
      plugins: this.pluginCache
    };
  }
}

从源码中可以看出 rollup 会试图缓存 modulesplugins 的信息,那么来看一下具体缓存了哪些信息。

Cache Module

模块的缓存内容是通过 module.toJSON() 方法生成的,来看一下 module.toJSON() 方法的实现。

ts
export default class Module {
  toJSON(): ModuleJSON {
    return {
      ast: this.info.ast!,
      attributes: this.info.attributes,
      code: this.info.code!,
      customTransformCache: this.customTransformCache,

      dependencies: Array.from(this.dependencies, getId),
      id: this.id,
      meta: this.info.meta,
      moduleSideEffects: this.info.moduleSideEffects,
      originalCode: this.originalCode,
      originalSourcemap: this.originalSourcemap,
      resolvedIds: this.resolvedIds,
      sourcemapChain: this.sourcemapChain,
      syntheticNamedExports: this.info.syntheticNamedExports,
      transformDependencies: this.transformDependencies,
      transformFiles: this.transformFiles
    };
  }
}

从上述源码中可以看出缓存的内容,rollup 缓存模块的信息主要包括:

Ast

需要注意的是,这里的 astcompat estree ast,而不是经过 rollup 内部实现的 ast class node 实例化后的 ast 实例树,因此即便是缓存过了但依旧需要再次执行 语义分析

当命中缓存后,调用 Module.setSource 方法时会使用缓存的数据。

ts
type ProgramNode = RollupAstNode<estree.Program>;

class Module {
  async setSource({ ast }: { ast: ProgramNode }) {
    if (ast) {
      this.ast = new nodeConstructors[ast.type](
        programParent,
        this.scope
      ).parseNode(ast) as Program;
      this.info.ast = ast;
    } else {
      // Measuring asynchronous code does not provide reasonable results
      timeEnd('generate ast', 3);
      const astBuffer = await parseAsync(
        code,
        false,
        this.options.jsx !== false
      );
      timeStart('generate ast', 3);
      this.ast = convertProgram(astBuffer, programParent, this.scope);
    }
  }
}

可以看到 rollup 再次通过缓存的标准 estree ast 来递归实例化 rollup 实现的 ast node 类实例,最后赋值给 module.ast 变量。而 estree ast 则赋值给 module.info.ast 变量作为缓存。

ts
this.ast = new nodeConstructors[ast.type](
  programParent,
  this.scope
).parseNode(ast) as Program;

rollup 中,module 实例中存储着两种 ast 结构,一种是 estree 规范的 ast,存储在 module.info.ast 变量中;而另一种是 rollup 根据 estree 结构生成的 ast 类实例,存储在 module.ast 变量中。后续的 语义分析tree-shaking 操作均在 rollup 实现的 ast 类实例上进行。

若没有命中缓存(首次构建或禁用缓存 rawInputOptions.cache = false 为真),rollup 会借助 swc 的能力将代码解析为 ast

ts
// Measuring asynchronous code does not provide reasonable results
timeEnd('generate ast', 3);
const astBuffer = await parseAsync(code, false, this.options.jsx !== false);
timeStart('generate ast', 3);
this.ast = convertProgram(astBuffer, programParent, this.scope);
// Make lazy and apply LRU cache to not hog the memory
Object.defineProperty(this.info, 'ast', {
  get: () => {
    if (this.graph.astLru.has(fileName)) {
      return this.graph.astLru.get(fileName)!;
    } else {
      const parsedAst = this.tryParse();
      // If the cache is not disabled, we need to keep the AST in memory
      // until the end when the cache is generated
      if (this.options.cache !== false) {
        Object.defineProperty(this.info, 'ast', {
          value: parsedAst
        });
        return parsedAst;
      }
      // Otherwise, we keep it in a small LRU cache to not hog too much
      // memory but allow the same AST to be requested several times.
      this.graph.astLru.set(fileName, parsedAst);
      return parsedAst;
    }
  }
});

借助 swc 来生成 estree ast 的详细过程可参考 Native Parser

注意

rollupswc 之间是传递 ArrayBuffer 结构的 ast,后续是通过 javascript 解析 ArrayBuffer 结构来实例化 rollup 内部实现的 ast node class 实例。因此这里可以看到,rollupmodule.info.ast 是以懒处理的方式来实现的,在需要的时候再生成 javascript 结构的 estree ast(比如缓存和插件复用 ast)。

ts
// Make lazy and apply LRU cache to not hog the memory
Object.defineProperty(this.info, 'ast', {
  get: () => {
    if (this.graph.astLru.has(fileName)) {
      return this.graph.astLru.get(fileName)!;
    } else {
      const parsedAst = this.tryParse();
      // If the cache is not disabled, we need to keep the AST in memory
      // until the end when the cache is generated
      if (this.options.cache !== false) {
        Object.defineProperty(this.info, 'ast', {
          value: parsedAst
        });
        return parsedAst;
      }
      // Otherwise, we keep it in a small LRU cache to not hog too much
      // memory but allow the same AST to be requested several times.
      this.graph.astLru.set(fileName, parsedAst);
      return parsedAst;
    }
  }
});

Transformed Code

Cache Plugin 小结中可以得知,对用户插件的 transform 钩子的解析产物缓存是加速构建的重要手段。

Dependencies

rollup 只缓存依赖模块的 id,而不会缓存依赖模块的 astcode 等信息。

ts
export function getId(m: { id: string | null }): string {
  return m.id!;
}
export default class Module {
  toJSON(): ModuleJSON {
    return {
      ast: this.info.ast!,
      attributes: this.info.attributes,
      code: this.info.code!,
      customTransformCache: this.customTransformCache,

      dependencies: Array.from(this.dependencies, getId),
      id: this.id,
      meta: this.info.meta,
      moduleSideEffects: this.info.moduleSideEffects,
      originalCode: this.originalCode,
      originalSourcemap: this.originalSourcemap,
      resolvedIds: this.resolvedIds,
      sourcemapChain: this.sourcemapChain,
      syntheticNamedExports: this.info.syntheticNamedExports,
      transformDependencies: this.transformDependencies,
      transformFiles: this.transformFiles
    };
  }
}

假设 a 模块依赖于 b 模块

js
import { b } from './b.js';
js
export const b = 'module b';

那么当 a 模块命中缓存时,会复用依赖模块已解析完成的 resolveId 结果。那么对于 b 模块来说,rollup 跳过了对 b 模块的 resolveId 解析,省了 resolveId 插件 的调用。

换句话说,若模块命中缓存,那么该模块的所有依赖模块的 resolveId 钩子均不会执行。

Sourcemap

Source Map 一章中可以得知,rollup 内部是使用 magic string 来管理代码上的变更,方便快速生成 sourcemap 信息。对于简单的代码变更使用 magic string 维护 sourcemap 信息无可厚非,但若使用 magic string 来维护复杂的代码转译工作,对于部分开发人员来说可能存在心智负担。

因此就存在有部分插件并不是依赖 magic string 来生成 sourcemap 信息,而是通过工具来生成 sourcemap 信息,那么这里会存在性能上的损耗。

综上,在大型项目中缓存 sourcemap 信息在提高构建效率上效果也是显著的。

Cache Plugin

vite 插件体系与 rollup 插件体系兼容,因此 rollup 插件设计上存在的问题在 vite 中同样存在。在生产环境中,插件的解析效率对 bundler 构建模块的效率影响很大,因此 vite 的优化方案之一 Warm Up Frequently Used Files,通过预热的方式提前解析模块来缓解插件执行效率低的问题。

可见缓存模块的解析产物是有多么重要,rollup 在其中做了假设,即插件默认情况下不存在副作用。

在假设的前提下,当输入不变的情况下(即 original code 不变),插件的解析结果是确定的(即 transformed code 不变),那么将 transformed code 缓存起来就跳过了插件的执行,从而提升构建效率。

当然,用户插件也存在副作用,因此 rollup 提供以下方式来处理副作用的用户插件:

  1. shouldTransformCachedModule 钩子 返回 true 跳过指定模块的缓存。

  2. rolluptransform 的插件上下文中提供 this.cache,若用户插件通过 this.cache 来为指定模块设置自定义缓存的话,那么 rollup 将不会缓存该模块。

    ts
    export function getTrackedPluginCache(
      pluginCache: PluginCache,
      onUse: () => void
    ): PluginCache {
      return {
        delete(id: string) {
          onUse();
          return pluginCache.delete(id);
        },
        get(id: string) {
          onUse();
          return pluginCache.get(id);
        },
        has(id: string) {
          onUse();
          return pluginCache.has(id);
        },
        set(id: string, value: any) {
          onUse();
          return pluginCache.set(id, value);
        }
      };
    }
    async function transform(
      source: SourceDescription,
      module: Module,
      pluginDriver: PluginDriver,
      log: LogHandler
    ): Promise<TransformModuleJSON> {
      let customTransformCache = false;
      const useCustomTransformCache = () => (customTransformCache = true);
    
      code = await pluginDriver.hookReduceArg0(
        'transform',
        [currentSource, id],
        transformReducer,
        (pluginContext, plugin): TransformPluginContext => {
          pluginName = plugin.name;
          return {
            ...pluginContext,
            cache: customTransformCache
              ? pluginContext.cache
              : getTrackedPluginCache(
                  pluginContext.cache,
                  useCustomTransformCache
                )
          };
        }
      );
    }

    有意思的一个点,this.cache 是一个隐藏的特性,rollup 并没有在官方文档中提及。

rollupwatch 模式下,每次文件变更时,会重新实例化 Graph 类,同时会增加 pluginCache使用次数

ts
class Graph {
  constructor(
    private readonly options: NormalizedInputOptions,
    watcher: RollupWatcher | null
  ) {
    if (options.cache !== false) {
      if (options.cache?.modules) {
        for (const module of options.cache.modules)
          this.cachedModules.set(module.id, module);
      }
      this.pluginCache = options.cache?.plugins || Object.create(null);

      // increment access counter
      for (const name in this.pluginCache) {
        const cache = this.pluginCache[name];
        for (const value of Object.values(cache)) value[0]++;
      }
    }
  }
}

用户通过配置 experimentalCacheExpiry 来设置缓存产物是否有效。

ts
class Graph {
  getCache(): RollupCache {
    for (const name in this.pluginCache) {
      const cache = this.pluginCache[name];
      let allDeleted = true;
      for (const [key, value] of Object.entries(cache)) {
        if (value[0] >= this.options.experimentalCacheExpiry)
          delete cache[key];
        else allDeleted = false;
      }
      if (allDeleted) delete this.pluginCache[name];
    }
  }
}

Decision Logic

以下图依赖关系为例:

watch 模式下,首次构建时从入口模块 A 开始,每一个模块都会执行 resolveIdloadtransform 钩子。当修改了 B 模块时,如下图所示:

那么决策逻辑如下:

  1. A 模块作为入口模块,每次都会执行 resolveId。同时每一个模块都会执行 load 钩子获取 original code 内容。

  2. 缓存决策逻辑:

    • 判断缓存模块存在,首先根据 resolveId 检查是否存在缓存的模块 cachedModule,其实就是前一次构建时缓存的模块。

      js
      const cachedModule = this.graph.cachedModules.get(id);
    • 判断是否存在自定义转换缓存(cachedModule.customTransformCache),若存在则跳过缓存。

    • 前后代码是否发生变更,若变更则跳过缓存。

    • 调用 shouldTransformCachedModule 钩子 确定当前模块是否需要应用缓存,若返回 true 则跳过缓存(默认需要执行缓存操作)。

  3. 根据 2 的缓存策略判断,可知对于 A 入口模块来说,是满足缓存策略的,因此不会执行 transform 钩子。

  4. 继续对于子依赖模块的缓存决策逻辑,A 模块的子依赖模块有 BC 模块。由于 A 模块命中缓存,那么 BC 两个模块都不会执行 resolveId 钩子,只通过 load 钩子获取 original code 内容。

  5. 对于 C 模块来说,通过 2 的缓存策略判断,C 模块也命中了缓存,因此也不会执行 transform 钩子。

  6. 对于 B 模块来说,通过 2 的缓存策略判断,B 模块由于进行模块修改,那么没有命中缓存。因此 B 模块需要执行 transform 钩子。

  7. 继续对于 B 模块的子依赖模块的缓存决策逻辑,B 模块的子依赖模块有 DE 模块。由于 B 模块没有命中缓存,那么需要重新为子依赖模块执行 resolveId。因此 DE 模块都会执行 resolveId 钩子。

  8. 对于 D 模块来说,通过 2 的缓存策略判断,D 模块命中了缓存,因此不会执行 transform 钩子。

  9. 继续对于 D 模块的子依赖模块的缓存决策逻辑,D 模块的子依赖模块有 F 模块。由于 D 模块命中缓存,那么会复用子依赖模块的 resolveId 结果,也就是说 D 的子依赖模块 F 不会执行 resolveId 钩子。

  10. 对于 F 模块来说,通过 2 的缓存策略判断,F 模块命中了缓存,因此不会执行 transform 钩子。由于没有子依赖模块,解析结束。

  11. 对于 E 模块来说,通过 2 的缓存策略判断,E 模块没有命中缓存,因此会执行 transform 钩子。由于没有子依赖模块,解析结束。

经过以上流程可知,当修改了 B 模块时,会发生以下逻辑:

  • A 模块执行 resolveIdload 钩子。
  • B 模块执行 resolveIdload 钩子,同时执行 transform 钩子。
  • C 模块执行 load 钩子。
  • D 模块执行 resolveIdload 钩子。
  • E 模块执行 resolveIdload 钩子。
  • F 模块执行 load 钩子。

与冷启动构建比较,只针对变更模块(B 模块)执行 transform 钩子,其余模块均不执行 transform 钩子。同时根据缓存决策逻辑,resolveId 钩子执行次数也会减少。但与之不变的是,每一个模块都会执行 load 钩子。

Performance

rollup 增量更新类似于 reactfiber 树的增量更新,自顶而下的进行检测,对于入口模块每次都会调用 resolveId,同时对于每一个模块都会执行 load 钩子。模块若被命中缓存,则复用该模块的 transform 钩子的转译产物,同时还会复用该模块所包含的所有依赖模块的 resolveId 结果。

有了上述缓存特性,与 watch 模式下的初次构建相比减少大量不相关模块的 resolveIdtransform 钩子的执行次数,仅对入口模块和变更模块执行 transform 钩子,加速了 watch 模式下的热更新构建速度。不过遗憾的是,每一个模块都需要执行 load 钩子,这在大量模块的场景下,是十分消耗性能的。

rollup issue 2182rollup issue 3728 中可以看到,rollup 目前对于硬盘空间上的持久性缓存(Persistent Cache)还不支持,也就是说 rollup 目前只支持对于 watch 模式下的增量更新,而不支持再次冷启动时的增量更新。webpack 支持了 Persistent Cache,这也是 webpack 在二次冷启动上胜过 rollup 的原因之一。

Vite Incremental Build

vite 现阶段也还未实现完整的 Persistent Cache,仅对 预构建 产物支持了 Persistent Cache,详情可见 feat: Persistent cache-Jul 5, 2021,原因可能与部分配置文件的变更会导致缓存失效有关,需要考虑得更加周全,同时还提供了两个缓存思路。

  • 第一个思路是在 插件层面 而不是在 整个依赖图层面 实现缓存。这种方式可以让缓存的 粒度更细更容易管理

  • 第二个思路是在服务器端预先转换所有请求,并实现一个类似 import-analysis 的功能。这个功能使用转换请求的哈希值作为查询参数,并利用浏览器的强缓存机制。这种方法需要在文件发生变化时(通过文件监视器实现),以及在服务器重启时(就像这个 PR 中实现的那样)递归地使清除插件缓存失效。这类似于 vite 中的 ssrTransformation 机制。

Contributors

Changelog

Discuss

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