Incremental Build
目前,rollup
会在 watch
模式下,自动使用 cache
机制来实现增量构建。cache
模式下,rollup
的增量构建类似于 react
的 fiber
树的增量更新,只会编译因文件修改所关联的模块,而不会编译其他无关联的模块。先来介绍一下 rollup
的 cache
机制的决策逻辑。
Cache Hit Conditions
rollup
缓存命中逻辑具体实现如下:
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
不存在,跳过缓存。jsconst cachedModule = this.graph.cachedModules.get(id);
若插件中使用了自定义缓存(即用户插件主动使用了插件上下文提供的
this.cache
,为指定模块的解析设置缓存),此时this.cache
所关联的模块将跳过缓存。前后代码是否发生变更,若变更则跳过缓存。
调用
shouldTransformCachedModule
钩子 为指定模块跳过缓存。
Cache Timing
rollup
在构建完所有模块后,即触发了 buildEnd
事件。之后 rollup
就会通过 rawInputOptions.cache
标识位来确认是否需要缓存。
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()
缓存所需的信息。
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
会试图缓存 modules
和 plugins
的信息,那么来看一下具体缓存了哪些信息。
Cache Module
模块的缓存内容是通过 module.toJSON()
方法生成的,来看一下 module.toJSON()
方法的实现。
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
需要注意的是,这里的 ast
是 compat estree ast
,而不是经过 rollup
内部实现的 ast class node
实例化后的 ast
实例树,因此即便是缓存过了但依旧需要再次执行 语义分析。
当命中缓存后,调用 Module.setSource
方法时会使用缓存的数据。
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
变量作为缓存。
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
。
// 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。
注意
rollup
与 swc
之间是传递 ArrayBuffer
结构的 ast
,后续是通过 javascript
解析 ArrayBuffer
结构来实例化 rollup
内部实现的 ast node class
实例。因此这里可以看到,rollup
对 module.info.ast
是以懒处理的方式来实现的,在需要的时候再生成 javascript
结构的 estree ast
(比如缓存和插件复用 ast
)。
// 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
,而不会缓存依赖模块的 ast
、code
等信息。
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
模块
import { b } from './b.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
提供以下方式来处理副作用的用户插件:
shouldTransformCachedModule
钩子 返回true
跳过指定模块的缓存。rollup
在transform
的插件上下文中提供this.cache
,若用户插件通过this.cache
来为指定模块设置自定义缓存的话,那么rollup
将不会缓存该模块。tsexport 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
并没有在官方文档中提及。
rollup
在 watch
模式下,每次文件变更时,会重新实例化 Graph
类,同时会增加 pluginCache
的 使用次数。
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
来设置缓存产物是否有效。
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
开始,每一个模块都会执行 resolveId
、 load
和 transform
钩子。当修改了 B
模块时,如下图所示:
那么决策逻辑如下:
A
模块作为入口模块,每次都会执行resolveId
。同时每一个模块都会执行load
钩子获取original code
内容。缓存决策逻辑:
判断缓存模块存在,首先根据
resolveId
检查是否存在缓存的模块cachedModule
,其实就是前一次构建时缓存的模块。jsconst cachedModule = this.graph.cachedModules.get(id);
判断是否存在自定义转换缓存(
cachedModule.customTransformCache
),若存在则跳过缓存。前后代码是否发生变更,若变更则跳过缓存。
调用
shouldTransformCachedModule
钩子 确定当前模块是否需要应用缓存,若返回true
则跳过缓存(默认需要执行缓存操作)。
根据
2
的缓存策略判断,可知对于A
入口模块来说,是满足缓存策略的,因此不会执行transform
钩子。继续对于子依赖模块的缓存决策逻辑,
A
模块的子依赖模块有B
和C
模块。由于A
模块命中缓存,那么B
和C
两个模块都不会执行resolveId
钩子,只通过load
钩子获取original code
内容。对于
C
模块来说,通过2
的缓存策略判断,C
模块也命中了缓存,因此也不会执行transform
钩子。对于
B
模块来说,通过2
的缓存策略判断,B
模块由于进行模块修改,那么没有命中缓存。因此B
模块需要执行transform
钩子。继续对于
B
模块的子依赖模块的缓存决策逻辑,B
模块的子依赖模块有D
、E
模块。由于B
模块没有命中缓存,那么需要重新为子依赖模块执行resolveId
。因此D
和E
模块都会执行resolveId
钩子。对于
D
模块来说,通过2
的缓存策略判断,D
模块命中了缓存,因此不会执行transform
钩子。继续对于
D
模块的子依赖模块的缓存决策逻辑,D
模块的子依赖模块有F
模块。由于D
模块命中缓存,那么会复用子依赖模块的resolveId
结果,也就是说D
的子依赖模块F
不会执行resolveId
钩子。对于
F
模块来说,通过2
的缓存策略判断,F
模块命中了缓存,因此不会执行transform
钩子。由于没有子依赖模块,解析结束。对于
E
模块来说,通过2
的缓存策略判断,E
模块没有命中缓存,因此会执行transform
钩子。由于没有子依赖模块,解析结束。
经过以上流程可知,当修改了 B
模块时,会发生以下逻辑:
A
模块执行resolveId
和load
钩子。B
模块执行resolveId
和load
钩子,同时执行transform
钩子。C
模块执行load
钩子。D
模块执行resolveId
和load
钩子。E
模块执行resolveId
和load
钩子。F
模块执行load
钩子。
与冷启动构建比较,只针对变更模块(B
模块)执行 transform
钩子,其余模块均不执行 transform
钩子。同时根据缓存决策逻辑,resolveId
钩子执行次数也会减少。但与之不变的是,每一个模块都会执行 load
钩子。
Performance
rollup
增量更新类似于 react
的 fiber
树的增量更新,自顶而下的进行检测,对于入口模块每次都会调用 resolveId
,同时对于每一个模块都会执行 load
钩子。模块若被命中缓存,则复用该模块的 transform
钩子的转译产物,同时还会复用该模块所包含的所有依赖模块的 resolveId
结果。
有了上述缓存特性,与 watch
模式下的初次构建相比减少大量不相关模块的 resolveId
和 transform
钩子的执行次数,仅对入口模块和变更模块执行 transform
钩子,加速了 watch
模式下的热更新构建速度。不过遗憾的是,每一个模块都需要执行 load
钩子,这在大量模块的场景下,是十分消耗性能的。
从 rollup issue 2182、rollup 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
机制。