Tree Shaking
相关配置
从官方提供的 文档可知,rollup 主要的 tree-shaking 配置项如下:
Annotations
默认值为 true,表示 rollup 会根据注解来判断是否保留某些代码。注解的格式为 @__PURE__、#__PURE__ 或 @__NO_SIDE_EFFECTS__、#__NO_SIDE_EFFECTS__,rollup 会根据注解来判断是否保留某些代码。
@__PURE__或#__PURE__的注释标记特定的函数调用或构造函数调用为无副作用。这意味着rollup将执行tree-shaking优化,即移除调用,除非返回值在一些未tree-shaking优化的代码中被使用。这些注解需要紧跟在调用之前才能生效。以下代码将完全除屑优化,除非将该选项设置为false,否则它将保持不变。js/*@__PURE__*/ console.log('side-effect'); class Impure { constructor() { console.log('side-effect'); } } /*@__PURE__ There may be additional text in the comment */ new Impure();@__NO_SIDE_EFFECTS__或者#__NO_SIDE_EFFECTS__的注释标记函数声明本身是无副作用的。当一个函数被标记为没有副作用时,所有对该函数的调用都将被认为是没有副作用的。下面的代码将被完全tree-sharking优化。js/*@__NO_SIDE_EFFECTS__*/ function impure() { console.log('side-effect'); } /*@__NO_SIDE_EFFECTS__*/ const impureArrowFn = () => { console.log('side-effect'); }; impure(); // <-- call will be considered as side effect free impureArrowFn(); // <-- call will be considered as side effect free
annotations 在 rollup 中的实现
rollup 内部为 ast 的每一个节点都实现了 ast class node 类,当解析完由原生 swc 生成的 ast 后,会通过所生成的 ast 结构来递归实例化每一个所需的 ast class node。实例后的 ast 树才是 rollup 后续操作的 ast 树,也就是说后续所有操作均是基于实例后的 ast 树。
export default class Module {
async setSource({
ast,
code,
customTransformCache,
originalCode,
originalSourcemap,
resolvedIds,
sourcemapChain,
transformDependencies,
transformFiles,
...moduleOptions
}: TransformModuleJSON & {
resolvedIds?: ResolvedIdMap;
transformFiles?: EmittedFile[] | undefined;
}): Promise<void> {
// 省略其他代码
this.ast = new nodeConstructors[ast.type](
programParent,
this.scope
).parseNode(ast) as Program;
// 省略其他代码
}
}需要了解的是所有的 ast node class 均继承 NodeBase,同时在 ast node class 的构造函数中,会通过 parseNode 来进一步解析 ast children node class,此时会通过 super.parseNode(esTreeNode); 来调用 NodeBase 的 parseNode 方法。
export const ANNOTATION_KEY = '_rollupAnnotations';
export const INVALID_ANNOTATION_KEY = '_rollupRemoved';
export default class NodeBase {
parseNode(esTreeNode: GenericEsTreeNode): this {
for (const [key, value] of Object.entries(esTreeNode)) {
if (key.charCodeAt(0) === 95 /* _ */) {
if (key === ANNOTATION_KEY) {
this.annotations = value as RollupAnnotation[];
} else if (key === INVALID_ANNOTATION_KEY) {
(this as unknown as Program).invalidAnnotations =
value as RollupAnnotation[];
}
}
}
}
}此时就会去收集 ast node 前置的注释,并将其赋值给 this.annotations。
compat estree ast
这里需要注意一点,标准的 estree ast 本身并不包含注释。
rollup 通过在 rust 层借助 swc 的能力最终生成的是 compat estree ast,这种 ast 结构包含了 estree ast 所需的所有信息,同时也进一步扩展了一些属性,例如 _rollupAnnotations,会包含 statements 前的注释节点信息,细节可参考 Optimize Ast Compatibility。
Manual Pure Functions
默认值为 true,表示 rollup 会根据手动设置的纯函数来判断是否保留某些代码。
Module Side Effects
在 tree shaking 中扮演重要的角色。当设置为 false 时,对于未引用的其他模块和外部依赖模块,会进行 tree shaking。
import { foo } from './foo.js';
import { unused } from 'external-a';
import 'external-b';
console.log(42);import { bar } from './bar.js';
console.log(bar);
export const foo = 'foo';export const bar = 'bar';outputs:
// treeshake.moduleSideEffects === true
// import { foo } from './foo.js' is equivalent to import './foo.js';
import 'external-a';
import 'external-b';
const bar = 'bar';
console.log(bar);
console.log(42);console.log(42);若指定具有副作用的外部或内部依赖模块,若依赖模块的引用没有被使用,则相当于解析为
import { a } from 'external-a';
// ==>
import 'external-b';TIP
模块加载 css 通常会使用
import './style.css';方式进行依赖加载。若 style.css 指定为无副作用,那么 style.css 模块在 tree-shaking 时会被移除,这是不合理的。
因此通常情况下 style.css 会指定具有副作用,以确保 style.css 模块的副作用会在 tree-shaking 时被保留。
若使用了依赖模块的引用变量,则会扫描重新导出模块是否存在副作用的问题取决于变量的重新导出方式。
// 输入文件 a.js
import { foo } from './b.js';
import { demo } from 'demo';
console.log(foo, demo);
// 输入文件 b.js
// 直接重新导出将忽略副作用
export { foo } from './c.js';
console.log('this side-effect is ignored');
// 输入文件 c.js
// 非直接重新导出将包含副作用
import { foo } from './d.js';
foo.mutated = true;
console.log('this side-effect and the mutation are retained');
export { foo };
// 输入文件 d.js
console.log('d.js');
export const foo = 42;也就是说对于直接重新导出的模块且该模块指定为无副作用,那么 rollup 会忽略其副作用,而非直接重新导出的模块,rollup 会保留该模块的副作用,即使该模块指定为无副作用。这与 moduleSideEffects 的默认值为 true 是不一样的,后者会保留所有可达模块的副作用。
// treeshake.moduleSideEffects === false 时的输出
import { demo } from 'demo';
console.log('d.js');
const foo = 42;
foo.mutated = true;
console.log('this side-effect and the mutation are retained');
console.log(foo, demo);
// treeshake.moduleSideEffects === true 时的输出
import { demo } from 'demo';
console.log('d.js');
const foo = 42;
foo.mutated = true;
console.log('this side-effect and the mutation are retained');
console.log('this side-effect is ignored');
console.log(foo, demo);Analysis of rollup's moduleSideEffects mechanism
moduleSideEffects config
moduleSideEffects 配置项可以通过两种方式来告知 rollup。
- 插件的钩子(
resolveId、load、transform):指定局部模块的副作用,优先级最高,可以覆盖treeshake.moduleSideEffects配置项。 - 通过
treeshake.moduleSideEffects配置项:指定全局模块的副作用,优先级最低。默认情况下treeshake.moduleSideEffects = true,即rollup会假设所有模块均具备副作用。
moduleSideEffects 注意事项
moduleSideEffects 本质上和 package.json 中的 sideEffects 字段是等价的,但 rollup 自身并不尊重 package.json 中的 sideEffects 字段,参考:rollup/rollup#2593。
官方的 @rollup/plugin-node-resolve 插件会尊重 package.json 中的 sideEffects 字段,但本质上是归类于插件提供的副作用。
import { foo } from './side-effect-1.js';
import { bar } from './side-effect-2.js';
import { noSideEffect } from './no-side-effect.js';
console.log(foo, noSideEffect);console.log('side-effect-1');
export const foo = 42;console.log('side-effect-2');
export const bar = 42;console.log('no-side-effect');
export const bar = 42;The Mechanism of moduleSideEffects
moduleSideEffects 的配置项很常见,会影响模块层面的副作用。由于 rollup 默认情况下假设所有的模块的顶层语句均包含副作用,那么 moduleSideEffects 配置项本质上是为 rollup 假设指定模块的顶层语句是否存在副作用。
入口模块均具备副作用,不会被 sideEffects 的配置项所覆盖,因此入口模块自身的副作用一定会包含在最终的产物中。
当模块不具备副作用时,对于 rollup 来说,会采用 惰性求值 的优化策略,即等到无副作用模块的引用实际被使用到了才会进一步分析并包含自身的副作用。细节上 rollup 在第一遍 tree-shaking 时,并不会包含指定为无副作用模块的副作用,只会包含指定为具有副作用的模块的副作用。在语义分析副作用模块的副作用时,若副作用的语句依赖了指定为无副作用模块的引用变量,认定该无副作用模块被副作用依赖的应用及其相关语句也具备副作用,同时激活无副作用模块的副作用检测。在第二次 tree-shaking 时,会包含其无副作用模块的副作用以及被副作用模块依赖的引用的相关语句。
当模块具备副作用时,那么即使具有副作用的模块暴露出来的引用没有被实际使用但也会进一步分析并包含自身的副作用。
The Impact of sideEffects on vite
vite 和 node 在运行时并不尊重 sideEffects 相关的配置项。换句话说这些工具并没有在编译阶段做语义分析来实现开发阶段的 tree-shaking 特性,这的确降低了编译的复杂度,缩短编译时间,有助于高效的开发体验。但与此同时也带来了隐性风险,存在因副作用的影响导致开发阶段(不支持 tree-shaking)与生产阶段(配备了 tree-shaking)产物执行不一致的问题。
简单举一个例子,main.js 作为入口模块:
import { foo } from './foo.js';
import { bar } from './bar.js';
// rollup DCE analysis
if (1 + 1 === 2) {
console.log(foo);
} else {
console.log(bar);
}console.log('foo');
export const foo = 42;console.log('bar');
export const bar = 42;当配置了 treeshake.moduleSideEffects 为 false 时,即默认所有的模块均不具备副作用,那么通过 rollup 的 tree-shaking 优化后,产物如下:
console.log('foo');
const foo = 42;
{
console.log(foo);
}由于所有的模块均不具备副作用,那么 rollup 会从入口模块开始检索副作用,通过 DCE 优化:
true分支所包含的foo引用会因console.log具备副作用而保留,而false分支所包含的console.log(bar);因DCE而被优化掉,bar实际上并不会被用到。false分支所包含的console.log(bar);因DCE而被优化掉,bar实际上并不会被用到。
最终对于主入口模块 main.js 来说,只有 foo 的依赖模块存在实际引用变量,而 bar 依赖模块的引用变量只导入但不引用。若此时 bar 模块具备副作用,那么 bar 模块的顶层副作用语句会被保留,若 bar 模块不具备副作用,那么 bar 模块的顶层副作用语句会被优化掉。
但对于 vite 来说,由于开发阶段不支持 tree-shaking 的工具来说,再加上架构的特殊性,没有足够的上下文完成常见构建工具(webpack、vite)的 tree-shaking 细粒度的优化,同时构建工具本身也并不愿意耗费性能对模块进行语义分析而影响用户的开发体验。那么对于 vite 来说,认为所有的模块均具备副作用,因此开发阶段的输出会包含 bar 的顶层副作用语句。
这种隐性的问题会导致 开发阶段 和 生产阶段 产物执行不一致的问题。
有下述几种方案来优化这个问题:
- 约定副作用:可以发现大多情况下是因为用户在模块的顶层编写副作用代码而导致上述的问题,那么可以考虑规范副作用的编写,将其封装在函数中,并在需要时显式调用,避免在模块的顶层编写副作用代码。
- 一致性测试:对于复杂的副作用模块,可以考虑在开发和生产环境中进行一致性测试,确保两者的行为一致。
Property Read Side Effects
默认值为 true
表示 rollup 会根据属性读取的副作用来判断是否保留某些代码。
Try Catch Deoptimization
默认值为 true
依赖于抛出错误的特征检测工作流,rollup 将默认禁用 try 语句中的 tree-shaking 优化,也就是说 rollup 会保留 try 语句中的所有代码。如果函数参数在 try 语句中被使用,那么该参数也不会被优化处理。
function a(d, w) {
d = d + 1;
w = w + 1;
try {
const b = 1;
const c = w;
} catch (_) {}
}
const param1 = 1;
const param2 = 2;
a(param1, param2);function a(d, w) {
w = w + 1;
try {
const b = 1;
const c = w;
} catch (_) {}
}
const param1 = 1;
const param2 = 2;
a(param1, param2);上述例子中在 a 函数中,由于 w 参数在 try 语句中被使用,因此 rollup 认定 w 相关的语句具备副作用,而 d 参数在 try 语句外被使用,因此 rollup 会优化掉 d 参数的副作用。
生成 AST
rollup 会在 rust 侧借助 swc 的能力来生成 babel ast,然后再转译为 compat ast,最后通过 arraybuffer 结构将 compat ast 传递给 javascript 侧。实现细节可参考 Native Parser。
rollup 内部为每一个 ast node 都实现了类,获取到 estree ast 后,rollup 会根据 estree ast 的结构来递归实例每一个 ast node 类,最终生成一颗完整的 ast 实例。后续 tree-shaking 操作均在 ast 实例上进行。
在构建结束后会 缓存 estree ast,在 watch 模式下,通过使用缓存来复用 estree ast,跳过与 rust 侧的通信,从而提升增量构建的性能。

针对 acorn parser 切换到 swc parser 的改动,可以参考 Native Parser。
准备阶段
AST 上下文
rollup 会为每一个模块都实例化 ast,同时也会提供 astContext 对象,协助 rollup 收集模块信息。
this.astContext = {
addDynamicImport: this.addDynamicImport.bind(this),
addExport: this.addExport.bind(this),
addImport: this.addImport.bind(this),
addImportMeta: this.addImportMeta.bind(this),
code, // Only needed for debugging
deoptimizationTracker: this.graph.deoptimizationTracker,
error: this.error.bind(this),
fileName, // Needed for warnings
getExports: this.getExports.bind(this),
getModuleExecIndex: () => this.execIndex,
getModuleName: this.basename.bind(this),
getNodeConstructor: (name: string) =>
nodeConstructors[name] || nodeConstructors.UnknownNode,
getReexports: this.getReexports.bind(this),
importDescriptions: this.importDescriptions,
includeAllExports: () => this.includeAllExports(true),
includeDynamicImport: this.includeDynamicImport.bind(this),
includeVariableInModule: this.includeVariableInModule.bind(this),
log: this.log.bind(this),
magicString: this.magicString,
manualPureFunctions: this.graph.pureFunctions,
module: this,
moduleContext: this.context,
options: this.options,
requestTreeshakingPass: () => (this.graph.needsTreeshakingPass = true),
traceExport: (name: string) => this.getVariableForExportName(name)[0],
traceVariable: this.traceVariable.bind(this),
usesTopLevelAwait: false
};
this.scope = new ModuleScope(this.graph.scope, this.astContext);
this.namespace = new NamespaceVariable(this.astContext);
const programParent = { context: this.astContext, type: 'Module' };
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 会通过上述获取到的 compat estree ast 结构来递归实例化 ast node 类,最终生成 ast 实例树。在此期间,rollup 会调用 astContext 对象中的方法来收集当前模块的信息。
例如:
- 通过上下文对象中的
addDynamicImport方法收集模块的dynamic import信息。 - 通过上下文对象中的
addImport方法收集import信息。 - 通过上下文对象中的
addExport方法收集export和reexports的信息。 - 通过上下文对象中的
addImportMeta方法收集import.meta信息。 - 通过判断
ast的结构来判断是否当前模块使用顶层await等信息。
来看一下具体是如何通过 compat estree ast 结构来收集模块信息。
收集模块信息
动态依赖当
ImportExpression实例中执行initialise方法时,此时也意味着子ast node类均已经完成实例化。jsclass ImportExpression extends NodeBase { initialise() { super.initialise(); this.scope.context.addDynamicImport(this); } parseNode(esTreeNode: GenericEsTreeNode): this { this.sourceAstNode = esTreeNode.source; return super.parseNode(esTreeNode); } }调用
ast上下文提供的addDynamicImport方法,将ImportExpression节点信息存储到当前Module实例的dynamicImports属性中。tsclass Module { readonly dynamicImports: DynamicImport[] = []; private addDynamicImport(node: ImportExpression) { let argument: AstNode | string = node.sourceAstNode; if (argument.type === NodeType.TemplateLiteral) { if ( (argument as TemplateLiteralNode).quasis.length === 1 && typeof (argument as TemplateLiteralNode).quasis[0].value .cooked === 'string' ) { argument = (argument as TemplateLiteralNode).quasis[0].value .cooked!; } } else if ( argument.type === NodeType.Literal && typeof (argument as LiteralStringNode).value === 'string' ) { argument = (argument as LiteralStringNode).value!; } this.dynamicImports.push({ argument, id: null, node, resolution: null }); } }静态依赖与上述一样,当
ImportDeclaration的子节点都实例化完成,那么会调用ImportDeclaration实例的initialise方法。jsclass ImportDeclaration extends NodeBase { initialise(): void { super.initialise(); this.scope.context.addImport(this); } }其中会调用
addImport方法,将ImportDeclaration节点信息存储到当前Module实例的importDescriptions属性中。tsclass Module { readonly importDescriptions = new Map<string, ImportDescription>(); private addImport(node: ImportDeclaration): void { const source = node.source.value; this.addSource(source, node); for (const specifier of node.specifiers) { const localName = specifier.local.name; if ( this.scope.variables.has(localName) || this.importDescriptions.has(localName) ) { this.error( logRedeclarationError(localName), specifier.local.start ); } const name = specifier instanceof ImportDefaultSpecifier ? 'default' : specifier instanceof ImportNamespaceSpecifier ? '*' : specifier.imported instanceof Identifier ? specifier.imported.name : specifier.imported.value; this.importDescriptions.set(localName, { module: null as never, // filled in later name, source, start: specifier.start }); } } }由于
import的导入方式有多种,包括默认导入、具名导入、命名空间导入,他们的ast node是不一样的,因此在importDescriptions中存储的方式需要做具体区分。默认导入对应的
ast node是ImportDefaultSpecifier节点。jsimport demo from 'demo.js'; module.importDescriptions.set('demo', { module: null, name: 'default', source: 'demo.js', start: 7 });在
importDescriptions中以default作为标识符。具名导入对应的
ast node是ImportSpecifier节点。jsimport { demo } from 'demo.js'; import { next as demo1 } from 'demo.js'; module.importDescriptions.set('demo', { module: null, // filled in later name: 'demo', source: 'demo.js', start: 72 }); module.importDescriptions.set('demo1', { module: null, // filled in later name: 'next', source: 'demo.js', start: 80 });在
importDescriptions中存储imported.name或imported.value命名空间导入对应的
ast node是ImportNamespaceSpecifier节点。jsimport * as demo from 'demo.js'; module.importDescriptions.set('demo', { module: null, name: '*', source: 'demo.js', start: 36 });在
importDescriptions中以*作为标识符。
Export Statement And Reexports Statementsexport涉及到的ast node包括如下三种- 默认导出(
ExportDefaultDeclaration) - 具名导出(
ExportNamedDeclaration) - 命名空间导出(
ExportAllDeclaration)
js// case 1: ExportDefaultDeclaration export default 'Y'; // case 2: ExportAllDeclaration export * as name from './foo.js'; // case 3: ExportAllDeclaration export * from './foo.js'; // case 4: ExportNamedDeclaration export const a = 123; // case 5: ExportNamedDeclaration const demoA = 123; const demoB = 123; export { demoA as _a, demoB as _b }; // case 6: ExportNamedDeclaration export function foo() { console.log('foo'); } // case 7: ExportNamedDeclaration export { foo as _foo } from './foo.js';三种
export ast node在初始化阶段会调用addExport方法,将当前模块的导出信息添加到Module实例的exports、reexportDescriptions、exportAllSources属性中。tsclass Module { private addExport( node: | ExportAllDeclaration | ExportNamedDeclaration | ExportDefaultDeclaration ): void { if (node instanceof ExportDefaultDeclaration) { // export default foo; this.assertUniqueExportName('default', node.start); this.exports.set('default', { identifier: node.variable.getAssignedVariableName(), localName: 'default' }); } else if (node instanceof ExportAllDeclaration) { const source = node.source.value; this.addSource(source, node); if (node.exported) { // export * as name from './other' const name = node.exported instanceof Literal ? node.exported.value : node.exported.name; this.assertUniqueExportName(name, node.exported.start); this.reexportDescriptions.set(name, { localName: '*', module: null as never, // filled in later, source, start: node.start }); } else { // export * from './other' this.exportAllSources.add(source); } } else if (node.source instanceof Literal) { // export { name } from './other' const source = node.source.value; this.addSource(source, node); for (const { exported, local, start } of node.specifiers) { const name = exported instanceof Literal ? exported.value : exported.name; this.assertUniqueExportName(name, start); this.reexportDescriptions.set(name, { localName: local instanceof Literal ? local.value : local.name, module: null as never, // filled in later, source, start }); } } else if (node.declaration) { const declaration = node.declaration; if (declaration instanceof VariableDeclaration) { // export var { foo, bar } = ... // export var foo = 1, bar = 2; for (const declarator of declaration.declarations) { for (const localName of extractAssignedNames(declarator.id)) { this.assertUniqueExportName(localName, declarator.id.start); this.exports.set(localName, { identifier: null, localName }); } } } else { // export function foo () {} const localName = (declaration.id as Identifier).name; this.assertUniqueExportName(localName, declaration.id!.start); this.exports.set(localName, { identifier: null, localName }); } } else { // export { foo, bar, baz } for (const { local, exported } of node.specifiers) { // except for reexports, local must be an Identifier const localName = (local as Identifier).name; const exportedName = exported instanceof Identifier ? exported.name : exported.value; this.assertUniqueExportName(exportedName, exported.start); this.exports.set(exportedName, { identifier: null, localName }); } } } }可以发现对于
reexport,rollup还特意在Module实例中通过reexportDescriptions和exportAllSources属性来区分。那么上述
export-statement.js样例解析抽象结果如下:js// case 1: ExportDefaultDeclaration export default foo; // case 1 output module.exports.set('default', { identifier: null, localName: 'default' }); // case 2: ExportAllDeclaration export * as name from './foo.js'; // case 2 output module.reexportDescriptions.set('name', { localName: '*', module: null, // filled in later source: './foo.js', start: 72 }); // case 3: ExportAllDeclaration export * from './foo.js'; // case 3 output module.exportAllSources.add('./foo.js'); // case 4: ExportNamedDeclaration export const a = 123; // case 4 output module.exports.set('a', { identifier: null, localName: 'a' }); // case 5: ExportNamedDeclaration const demoA = 123; const demoB = 123; export { demoA as _a, demoB as _b }; // case 5 output module.exports.set('_a', { identifier: null, localName: 'demoA' }); module.exports.set('_b', { identifier: null, localName: 'demoB' }); // case 6: ExportNamedDeclaration export function foo() { console.log('foo'); } // case 6 output module.exports.set('foo', { identifier: null, localName: 'foo' }); // case 7: ExportNamedDeclaration export { foo as _foo } from './foo.js'; // case 7 output module.reexportDescriptions.set('_foo', { localName: 'foo', module: null, // filled in later source: './foo.js', start: 289 });Duplicate Declaration Reference Check
rollup会在此处执行 语法分析 操作,这样做的原因可参考Optimize Syntax Analysis一文讲解,import和export都会对重复声明的变量名进行校验。export通过assertUniqueExportName方法来检测重复声明的变量名。tsfunction assertUniqueExportName(name: string, nodeStart: number) { if (this.exports.has(name) || this.reexportDescriptions.has(name)) { this.error(logDuplicateExportError(name), nodeStart); } }通过检测导出的变量是否已经出现在
Module实例的exports或reexportDescriptions属性中,来判断是否重复声明。import在addImport中,通过检测导入的引用变量是否出现在作用域声明的变量中(scope.variables) 或Module实例的importDescriptions属性中,来判断是否重复声明。tsif ( this.scope.variables.has(localName) || this.importDescriptions.has(localName) ) { this.error(logRedeclarationError(localName), specifier.local.start); }也就是说对于
rollup不允许在同一个模块中重复声明变量,包括顶层作用域声明的变量和import导出的变量。若出现重复声明引用,则会抛出错误,并终止打包。
总结
在实例化
ast的过程中记录的import和export节点的信息是本质上就是当前模块作用域中使用的localName与依赖方模块作用域中所导出的localName的映射关系。不管是
import还是export,依赖模块在收集过程中并没有进行填充,原因也很简单,因为此时该模块对应的依赖模块还没有开始加载,因此还获取不到。tsthis.importDescriptions.set(localName, { module: null as never, // filled in later name, source, start: specifier.start }); this.reexportDescriptions.set(name, { localName: local instanceof Literal ? local.value : local.name, module: null as never, // filled in later, source, start });当子依赖模块加载完成后,会调用
linkImports方法,通过addModulesToImportDescriptions方法将子依赖模块的引用填充到importer模块的importDescriptions和reexportDescriptions中。jsclass Module { addModulesToImportDescriptions(importDescription) { for (const specifier of importDescription.values()) { const { id } = this.resolvedIds[specifier.source]; specifier.module = this.graph.modulesById.get(id); } } linkImports() { this.addModulesToImportDescriptions(this.importDescriptions); this.addModulesToImportDescriptions(this.reexportDescriptions); const externalExportAllModules = []; for (const source of this.exportAllSources) { const module = this.graph.modulesById.get( this.resolvedIds[source].id ); if (module instanceof ExternalModule) { externalExportAllModules.push(module); continue; } this.exportAllModules.push(module); } this.exportAllModules.push(...externalExportAllModules); } }执行
linkImports方法时,当前模块的所有依赖项模块都已经解析完成,所以可以在this.graph.modulesById中获取到当前模块的所有依赖项模块的引用。此时当前模块的
importDescriptions和reexportDescriptions属性对应的所有依赖项模块引用就可以进行填充。exportAllSources中存储的source,也通过this.graph.modulesById找到对应的依赖项模块,并将其添加到this.exportAllModules。通过层层回溯且收集绑定,最终
ast实例化阶段未填充的依赖项模块已经全部填充完毕。- 默认导出(
Import Meta与
import.meta相关的ast node节点是MetaProperty。这是声明式的节点,由于无法在ast实例化阶段判断是否需要import.meta节点信息。因此节点信息并没有在实例化
ast时进行处理,而是在tree shaking阶段确认需要包含import.meta节点后才会通过addImportMeta方法收集import.meta的信息。jsclass MateProperty { include() { if (!this.included) { this.included = true; if (this.meta.name === IMPORT) { this.scope.context.addImportMeta(this); const parent = this.parent; const metaProperty = (this.metaProperty = parent instanceof MemberExpression && typeof parent.propertyKey === 'string' ? parent.propertyKey : null); if (metaProperty?.startsWith(FILE_PREFIX)) { this.referenceId = metaProperty.slice(FILE_PREFIX.length); } } } } } class Module { addImportMeta(node) { this.importMetas.push(node); } }TLA这是为了标记当前模块是否包含了
Top Level Await。针对Top Level Await在不同bundlers的实现在 Detailed Explanation And Implementation Of TLA 中已经详细说明了,这里不再赘述。与
Top Level Await相关的ast node是AwaitExpression。jsclass AwaitExpression extends NodeBase { include(context: InclusionContext, includeChildrenRecursively: IncludeChildren): void { if (!this.deoptimized) this.applyDeoptimizations(); if (!this.included) { this.included = true; checkTopLevelAwait: if (!this.scope.context.usesTopLevelAwait) { let parent = this.parent; do { if (parent instanceof FunctionNode || parent instanceof ArrowFunctionExpression) break checkTopLevelAwait; } while ((parent = (parent as Node).parent as Node)); this.scope.context.usesTopLevelAwait = true; } } this.argument.include(context, includeChildrenRecursively); } }与
import.meta节点一样,AwaitExpression节点信息也是在tree shaking阶段确认需要包含AwaitExpression节点后才会通过addAwaitExpression方法收集AwaitExpression的信息。判断模块中是否包含
AwaitExpression节点的方式很简单,即往父级遍历,直到遇到FunctionNode或ArrowFunctionExpression节点为止,若均没有遇到,那么就代表当前模块存在Top Level Await,那么会在当前模块实例的usesTopLevelAwait属性标记为true。js// usesTopLevelAwait: true await 1; // usesTopLevelAwait: false (async () => { await 1; })(); // usesTopLevelAwait: false (function () { await 1; })();
以上这些信息均在 ast 实例化阶段收集完成。而 includeAllExports、includeDynamicImport、includeVariableInModule 这些方法在 tree shaking 阶段起作用。
在 AST 节点中创建作用域
rollup 会为每个 ast node 节点创建一个 scope 作用域。作用域分为全局作用域、函数作用域、模块作用域、参数作用域、catch 作用域、with 作用域。
class Scope {
readonly children: ChildScope[] = [];
readonly variables = new Map<string, Variable>();
hoistedVariables?: Map<string, LocalVariable>;
}
class ChildScope extends Scope {
constructor(
readonly parent: Scope,
readonly context: AstContext
) {
super();
parent.children.push(this);
}
}
class ModuleScope extends ChildScope {
declare parent: GlobalScope;
constructor(parent: GlobalScope, context: AstContext) {
super(parent, context);
this.variables.set(
'this',
new LocalVariable(
'this',
null,
UNDEFINED_EXPRESSION,
context,
'other'
)
);
}
}
class Module {
async setSource({
ast,
code,
customTransformCache,
originalCode,
originalSourcemap,
resolvedIds,
sourcemapChain,
transformDependencies,
transformFiles,
...moduleOptions
}: TransformModuleJSON & {
resolvedIds?: ResolvedIdMap;
transformFiles?: EmittedFile[] | undefined;
}): Promise<void> {
this.scope = new ModuleScope(this.graph.scope, this.astContext);
}
}rollup 为模块创建一个 ModuleScope 实例(即模块作用域),继承至 GlobalScope 实例(即全局作用域,也就是 this.graph.scope 的值)。
在初始化阶段会为 ModuleScope 实例添加 this 变量,作为当前模块作用域中第一个变量。
class ModuleScope extends ChildScope {
declare parent: GlobalScope;
constructor(parent: GlobalScope, context: AstContext) {
super(parent, context);
this.variables.set(
'this',
new LocalVariable(
'this',
null,
UNDEFINED_EXPRESSION,
context,
'other'
)
);
}
}接下来通过实例来讲解 scope 在 ast 实例化过程中是如何发挥作用的。
const localVariable = 123;
function scopeDemo() {
const scopeDemoLocalVariable = 345;
const scopeDemoLocalFunction = function (params) {
const d = 4445;
return params + d;
};
console.log(scopeDemoLocalFunction(scopeDemoLocalVariable));
}
scopeDemo();
console.log(localVariable);由于 ast node 均继承于 NodeBase 类,因此实例化 ast node 时会触发 NodeBase 构造函数的执行,为当前的 ast node 节点创建 scope 作用域。
class NodeBase extends ExpressionEntity {
constructor(parent, parentScope) {
super();
this.parent = parent;
this.scope = parentScope;
this.createScope(parentScope);
}
createScope(parentScope) {
this.scope = parentScope;
}
}我们先拿 const localVariable = 123; 这条语句来举例,VariableDeclaration 节点初始化完成后(即 VariableDeclaration 节点的所有 ast node 均已初始化完成),则会调用 VariableDeclaration 节点的 initialise 方法。
class VariableDeclaration extends NodeBase {
initialise() {
super.initialise();
this.isUsingDeclaration =
this.kind === 'await using' || this.kind === 'using';
for (const declarator of this.declarations) {
declarator.declareDeclarator(this.kind, this.isUsingDeclaration);
}
}
}在 VariableDeclaration 节点的 initialise 方法中,通过 super.initialise() 方法为当前的 VariableDeclaration 节点创建 scope 作用域。
遍历 VariableDeclaration 节点的 declarations 数组(声明的每一个变量,例如 const a = 1, b = 2; 中,declarations 数组中包含两个 VariableDeclarator 节点),调用 VariableDeclarator 节点的 declareDeclarator 方法
class VariableDeclarator extends NodeBase {
declareDeclarator(kind, isUsingDeclaration) {
this.isUsingDeclaration = isUsingDeclaration;
this.id.declare(kind, this.init || UNDEFINED_EXPRESSION);
}
}在 declareDeclarator 方法中会触发 Identifier 节点(this.id)的 declare 方法,其中会通过 this.scope.addDeclaration 方法将 Identifier 节点添加到 scope.variables 中。
由于在 VariableDeclaration 节点创建了作用域且到 Identifier 节点实例化期间均没有产生新的作用域,因此 Identifier 节点的作用域与 VariableDeclaration 节点的作用域一致,即 Identifier 节点的作用域为 ModuleScope。
class Identifier extends NodeBase {
declare(kind, init) {
let variable;
const { treeshake } = this.scope.context.options;
switch (kind) {
case 'var': {
variable = this.scope.addDeclaration(
this,
this.scope.context,
init,
kind
);
if (treeshake && treeshake.correctVarValueBeforeDeclaration) {
// Necessary to make sure the init is deoptimized. We cannot call deoptimizePath here.
variable.markInitializersForDeoptimization();
}
break;
}
case 'function': {
// in strict mode, functions are only hoisted within a scope but not across block scopes
variable = this.scope.addDeclaration(
this,
this.scope.context,
init,
kind
);
break;
}
case 'let':
case 'const':
case 'using':
case 'await using':
case 'class': {
variable = this.scope.addDeclaration(
this,
this.scope.context,
init,
kind
);
break;
}
case 'parameter': {
variable = this.scope.addParameterDeclaration(this);
break;
}
/* istanbul ignore next */
default: {
/* istanbul ignore next */
throw new Error(
`Internal Error: Unexpected identifier kind ${kind}.`
);
}
}
return [(this.variable = variable)];
}
}那么对于 const localVariable = 123; 语句来说,最终会将 localVariable 变量添加到 ModuleScope 的 scope.variables 中。
class Scope {
addDeclaration(identifier, context, init, kind) {
const name = identifier.name;
const existingVariable =
this.hoistedVariables?.get(name) || this.variables.get(name);
if (existingVariable) {
const existingKind = existingVariable.kind;
if (kind === 'var' && existingKind === 'var') {
existingVariable.addDeclaration(identifier, init);
return existingVariable;
}
context.error(
parseAst_js.logRedeclarationError(name),
identifier.start
);
}
const newVariable = new LocalVariable(
identifier.name,
identifier,
init,
context,
kind
);
this.variables.set(name, newVariable);
return newVariable;
}
}
class ModuleScope extends ChildScope {
addDeclaration(identifier, context, init, kind) {
if (this.context.module.importDescriptions.has(identifier.name)) {
context.error(
parseAst_js.logRedeclarationError(identifier.name),
identifier.start
);
}
return super.addDeclaration(identifier, context, init, kind);
}
}由于模块作用域中包含了已经声明过的变量,因此很容易做 重复性声明的语法检测。
当检测到当前模块作用域中声明的变量与 import 导入的变量声明重复,则抛出重复性声明异常。
若非 var 关键字声明的变量重复声明(例如 let、const 等),则抛出异常。检测通过后会根据 Identifier 的 ast node 节点信息实例化 LocalVariable 类,并将实例化的变量添加到当前作用域的 scope.variables 中,为后续的 语法分析 和 tree shaking 做准备。
创建作用域
前面提到的是在顶层语法中创建的作用域,他们所属的是 ModuleScope(模块作用域)。前面也有提及到,作用域不仅仅只有 ModuleScope、GlobalScope,还有 FunctionScope、ParameterScope、CatchBodyScope、FunctionBodyScope 等。
函数作用域
当遇到函数声明语句时,会在 FunctionNode 节点中调用 createScope 方法为当前的 FunctionNode 节点创建新的 scope 作用域。
class FunctionNode extends FunctionBase {
constructor() {
super(...arguments);
this.objectEntity = null;
}
createScope(parentScope) {
this.scope = new FunctionScope(parentScope);
this.constructedEntity = new ObjectEntity(
Object.create(null),
OBJECT_PROTOTYPE
);
// This makes sure that all deoptimizations of "this" are applied to the
// constructed entity.
this.scope.thisVariable.addEntityToBeDeoptimized(
this.constructedEntity
);
}
}class Scope {
constructor() {
this.children = [];
this.variables = new Map();
}
}
class ChildScope extends Scope {
constructor(parent, context) {
super();
this.parent = parent;
this.context = context;
this.accessedOutsideVariables = new Map();
parent.children.push(this);
}
}
class ParameterScope extends ChildScope {
constructor(parent, isCatchScope) {
super(parent, parent.context);
this.parameters = [];
this.hasRest = false;
this.bodyScope = isCatchScope
? new CatchBodyScope(this)
: new FunctionBodyScope(this);
}
}
class ReturnValueScope extends ParameterScope {
constructor() {
super(...arguments);
this.returnExpression = null;
this.returnExpressions = [];
}
}
class FunctionScope extends ReturnValueScope {
constructor(parent) {
const { context } = parent;
super(parent, false);
this.variables.set(
'arguments',
(this.argumentsVariable = new ArgumentsVariable(context))
);
this.variables.set(
'this',
(this.thisVariable = new ThisVariable(context))
);
}
}在 FunctionScope 中为当前函数节点创建一个继承 ModuleScope 的新的 FunctionScope 作用域。
在新的 FunctionScope 作用域中创建 this 变量和 arguments 变量作为当前作用域中的初始变量。同时为父集 ModuleScope 收集新的函数作用域存储在父集 ModuleScope 的 children 数组中。
class ChildScope extends Scope {
constructor(parent, context) {
super();
this.parent = parent;
this.context = context;
this.accessedOutsideVariables = new Map();
parent.children.push(this);
}
}注意
函数作用域中声明的变量并不是添加到 FunctionScope 的 scope.variables 中,而是添加到 FunctionScope.bodyScope 的 scope.variables 中。
function functionDeclaration(node: FunctionDeclaration, position, buffer) {
const { scope } = node;
const flags = buffer[position];
node.async = (flags & 1) === 1;
node.generator = (flags & 2) === 2;
const annotations = (node.annotations = convertAnnotations(buffer[position + 1], buffer));
node.annotationNoSideEffects = annotations.some(comment => comment.type === 'noSideEffects');
const idPosition = buffer[position + 2];
node.id =
idPosition === 0 ? null : convertNode(node, scope.parent as ChildScope, idPosition, buffer);
const parameters = (node.params = convertNodeList(node, scope, buffer[position + 3], buffer));
scope.addParameterVariables(
parameters.map(
parameter => parameter.declare('parameter', UNKNOWN_EXPRESSION) as ParameterVariable[]
),
parameters[parameters.length - 1] instanceof RestElement
);
node.body = convertNode(node, scope.bodyScope, buffer[position + 4], buffer);
}那么 bodyScope 是哪里来的呢?
class ParameterScope extends ChildScope {
constructor(parent, isCatchScope) {
super(parent, parent.context);
this.parameters = [];
this.hasRest = false;
this.bodyScope = isCatchScope ? new CatchBodyScope(this) : new FunctionBodyScope(this);
}
}
class ReturnValueScope extends ParameterScope {}
class FunctionScope extends ReturnValueScope {}可以看到在创建函数作用域的时候会创建 FunctionBodyScope 作为当前函数作用域的 bodyScope,同时 FunctionBodyScope 的父集为 FunctionScope。后续在 函数体中声明的变量 均会添加到 FunctionBodyScope 的 scope.variables 中。
函数表达式的参数会通过 scope.addParameterVariables 方法添加到 FunctionScope 的 scope.variables 中。
function functionExpression(node: FunctionExpression, position, buffer) {
const { scope } = node;
const flags = buffer[position];
node.async = (flags & 1) === 1;
node.generator = (flags & 2) === 2;
const annotations = (node.annotations = convertAnnotations(buffer[position + 1], buffer));
node.annotationNoSideEffects = annotations.some(comment => comment.type === 'noSideEffects');
const idPosition = buffer[position + 2];
node.id = idPosition === 0 ? null : convertNode(node, node.idScope, idPosition, buffer);
const parameters = (node.params = convertNodeList(node, scope, buffer[position + 3], buffer));
scope.addParameterVariables(
parameters.map(
parameter => parameter.declare('parameter', UNKNOWN_EXPRESSION) as ParameterVariable[]
),
parameters[parameters.length - 1] instanceof RestElement
);
node.body = convertNode(node, scope.bodyScope, buffer[position + 4], buffer);
}同时还会将参数添加到函数作用域(FunctionScope)下的函数体作用域(bodyScope,bodyScope.parent 为 FunctionScope)的 hoistedVariables 中作为提示的变量。
class Scope {
addHoistedVariable(name: string, variable: LocalVariable) {
(this.hoistedVariables ||= new Map()).set(name, variable);
}
}
class ParameterScope extends ChildScope {
addParameterDeclaration(identifier: Identifier): ParameterVariable {
const { name, start } = identifier;
const existingParameter = this.variables.get(name);
if (existingParameter) {
return this.context.error(logDuplicateArgumentNameError(name), start);
}
const variable = new ParameterVariable(name, identifier, this.context);
this.variables.set(name, variable);
// We also add it to the body scope to detect name conflicts with local
// variables. We still need the intermediate scope, though, as parameter
// defaults are NOT taken from the body scope but from the parameters or
// outside scope.
this.bodyScope.addHoistedVariable(name, variable);
return variable;
}
}hoistedVariables 的作用是为了在函数体中检测标识符是否与参数变量声明冲突。
function logRedeclarationError(name: string): RollupLog {
return {
code: REDECLARATION_ERROR,
message: `Identifier "${name}" has already been declared`
};
}
class FunctionBodyScope extends ChildScope {
// There is stuff that is only allowed in function scopes, i.e. functions can
// be redeclared, functions and var can redeclare each other
addDeclaration(
identifier: Identifier,
context: AstContext,
init: ExpressionEntity,
kind: VariableKind
): LocalVariable {
const name = identifier.name;
const existingVariable =
this.hoistedVariables?.get(name) || (this.variables.get(name) as LocalVariable);
if (existingVariable) {
const existingKind = existingVariable.kind;
if (
(kind === 'var' || kind === 'function') &&
(existingKind === 'var' || existingKind === 'function' || existingKind === 'parameter')
) {
existingVariable.addDeclaration(identifier, init);
return existingVariable;
}
context.error(logRedeclarationError(name), identifier.start);
}
const newVariable = new LocalVariable(identifier.name, identifier, init, context, kind);
this.variables.set(name, newVariable);
return newVariable;
}
}可以看到若存在非 var 和 function 关键字声明的变量与参数变量同名,则抛出异常: Identifier xxx has already been declared。因此在实例化 ast 树的过程中,就可以检测到函数体中变量声明的重复问题。
实例化 AST
上述提到 module.ast 是 rollup 实现的 ast node 类的实例,结构兼容 estree ast,后续 tree shaking 操作均是在 module.ast 实例上进行。
那么核心问题来了,rollup 是如何将根据 compat estree ast 结构来实例化 rollup 实现的 ast node 类实例呢?
rollup 内部为每一个 ast node 类型实现了对应的 ast node 类,通过 nodeConstructors 来实例化对应的 ast node 类。
import type * as estree from 'estree';
type OmittedEstreeKeys =
| 'loc'
| 'range'
| 'leadingComments'
| 'trailingComments'
| 'innerComments'
| 'comments';
interface AstNodeLocation {
end: number;
start: number;
}
type RollupAstNode<T> = Omit<T, OmittedEstreeKeys> & AstNodeLocation;
type AstNode = RollupAstNode<estree.Node>;
interface GenericEsTreeNode extends AstNode {
[key: string]: any;
}
type AnnotationType = 'pure' | 'noSideEffects';
interface RollupAnnotation {
start: number;
end: number;
type: AnnotationType;
}
const nodeConstructors: Record<string, typeof NodeBase> = {
ArrayExpression,
ArrayPattern,
ArrowFunctionExpression,
AssignmentExpression,
AssignmentPattern,
AwaitExpression,
BinaryExpression,
BlockStatement,
BreakStatement,
CallExpression,
CatchClause,
ChainExpression,
ClassBody,
ClassDeclaration,
ClassExpression,
ConditionalExpression,
ContinueStatement,
DebuggerStatement,
Decorator,
DoWhileStatement,
EmptyStatement,
ExportAllDeclaration,
ExportDefaultDeclaration,
ExportNamedDeclaration,
ExportSpecifier,
ExpressionStatement,
ForInStatement,
ForOfStatement,
ForStatement,
FunctionDeclaration,
FunctionExpression,
Identifier,
IfStatement,
ImportAttribute,
ImportDeclaration,
ImportDefaultSpecifier,
ImportExpression,
ImportNamespaceSpecifier,
ImportSpecifier,
LabeledStatement,
Literal,
LogicalExpression,
MemberExpression,
MetaProperty,
MethodDefinition,
NewExpression,
ObjectExpression,
ObjectPattern,
PanicError,
ParseError,
PrivateIdentifier,
Program,
Property,
PropertyDefinition,
RestElement,
ReturnStatement,
SequenceExpression,
SpreadElement,
StaticBlock,
Super,
SwitchCase,
SwitchStatement,
TaggedTemplateExpression,
TemplateElement,
TemplateLiteral,
ThisExpression,
ThrowStatement,
TryStatement,
UnaryExpression,
UnknownNode,
UpdateExpression,
VariableDeclaration,
VariableDeclarator,
WhileStatement,
YieldExpression
};
class NodeBase {
parseNode(esTreeNode: GenericEsTreeNode): this {
for (const [key, value] of Object.entries(esTreeNode)) {
// Skip properties defined on the class already.
// This way, we can override this function to add custom initialisation and then call super.parseNode
// Note: this doesn't skip properties with defined getters/setters which we use to pack wrap booleans
// in bitfields. Those are still assigned from the value in the esTreeNode.
if (this.hasOwnProperty(key)) continue;
if (key.charCodeAt(0) === 95 /* _ */) {
if (key === ANNOTATION_KEY) {
this.annotations = value as RollupAnnotation[];
} else if (key === INVALID_ANNOTATION_KEY) {
(this as unknown as Program).invalidAnnotations =
value as RollupAnnotation[];
}
} else if (typeof value !== 'object' || value === null) {
(this as GenericEsTreeNode)[key] = value;
} else if (Array.isArray(value)) {
(this as GenericEsTreeNode)[key] = [];
for (const child of value) {
(this as GenericEsTreeNode)[key]
.push(
child === null ? null : new nodeConstructors[child.type]()
)(this, this.scope)
.parseNode(child);
}
} else {
(this as GenericEsTreeNode)[key] = new nodeConstructors[value.type](
this,
this.scope
).parseNode(value);
}
}
// extend child keys for unknown node types
childNodeKeys[esTreeNode.type] ||=
createChildNodeKeysForNode(esTreeNode);
this.initialise();
return this;
}
}通过 compat estree ast 的结构,通过 DFS 递归实例化每一个 ast node 类。
Ast 的节点操作
getVariableForExportName
注意
在 generateModuleGraph 阶段会执行实例化 Module 的操作,同时根据 source code 解析对应的 Ast 树。需要注意的是此时模块的依赖项模块还没有开始实例化,因此无法在解析 Ast 树时获取到子依赖模块的引用,所以在 importDescriptions 和 reexportDescriptions 中收集到的导入变量和重导出变量没有填充依赖模块项的模块引用,处于 未填充状态。
当依赖项模块均已经实例化完成后,就会执行 module.linkImports() 方法为 importDescriptions 和 reexportDescriptions 填充依赖模块项的引用。
class Module {
linkImports(): void {
this.addModulesToImportDescriptions(this.importDescriptions);
this.addModulesToImportDescriptions(this.reexportDescriptions);
const externalExportAllModules: ExternalModule[] = [];
for (const source of this.exportAllSources) {
const module = this.graph.modulesById.get(this.resolvedIds[source].id)!;
if (module instanceof ExternalModule) {
externalExportAllModules.push(module);
continue;
}
this.exportAllModules.push(module);
}
this.exportAllModules.push(...externalExportAllModules);
}
}tree-sharking 阶段, importDescriptions 和 reexportDescriptions 的依赖模块项的模块引用已经填充完毕,因此在 tree-sharking 阶段可以通过以下方式来获取依赖项的 module 引用。
// main.js
// export * as d from 'demo.js';
const reexportDeclaration = this.reexportDescriptions.get(name);
const childDependenciesReexportModule = reexportDeclaration.module;
// import { d } from 'demo.js';
const importDescription = this.importDescriptions.get(name);
const childDependenciesImportModule = importDescription.module;继续分析 getVariableForExportName 方法的实现。
class Module {
getVariableForExportName(
name: string,
{
importerForSideEffects,
isExportAllSearch,
onlyExplicit,
searchedNamesAndModules
}: {
importerForSideEffects?: Module;
isExportAllSearch?: boolean;
onlyExplicit?: boolean;
searchedNamesAndModules?: Map<string, Set<Module | ExternalModule>>;
} = EMPTY_OBJECT
): [variable: Variable | null, indirectExternal?: boolean] {
if (name[0] === '*') {
if (name.length === 1) {
// export * from './other'
return [this.namespace];
}
// export * from 'external'
const module = this.graph.modulesById.get(
name.slice(1)
) as ExternalModule;
return module.getVariableForExportName('*');
}
// export { foo } from './other'
const reexportDeclaration = this.reexportDescriptions.get(name);
if (reexportDeclaration) {
const [variable] = getVariableForExportNameRecursive(
reexportDeclaration.module,
reexportDeclaration.localName,
importerForSideEffects,
false,
searchedNamesAndModules
);
if (!variable) {
return this.error(
logMissingExport(
reexportDeclaration.localName,
this.id,
reexportDeclaration.module.id
),
reexportDeclaration.start
);
}
if (importerForSideEffects) {
setAlternativeExporterIfCyclic(
variable,
importerForSideEffects,
this
);
if (this.info.moduleSideEffects) {
getOrCreate(
importerForSideEffects.sideEffectDependenciesByVariable,
variable,
getNewSet<Module>
).add(this);
}
}
return [variable];
}
const exportDeclaration = this.exports.get(name);
if (exportDeclaration) {
if (exportDeclaration === MISSING_EXPORT_SHIM_DESCRIPTION) {
return [this.exportShimVariable];
}
const name = exportDeclaration.localName;
const variable = this.traceVariable(name, {
importerForSideEffects,
searchedNamesAndModules
})!;
if (importerForSideEffects) {
setAlternativeExporterIfCyclic(
variable,
importerForSideEffects,
this
);
getOrCreate(
importerForSideEffects.sideEffectDependenciesByVariable,
variable,
getNewSet<Module>
).add(this);
}
return [variable];
}
if (onlyExplicit) {
return [null];
}
if (name !== 'default') {
const foundNamespaceReexport =
this.namespaceReexportsByName.get(name) ??
this.getVariableFromNamespaceReexports(
name,
importerForSideEffects,
searchedNamesAndModules
);
this.namespaceReexportsByName.set(name, foundNamespaceReexport);
if (foundNamespaceReexport[0]) {
return foundNamespaceReexport;
}
}
if (this.info.syntheticNamedExports) {
return [
getOrCreate(
this.syntheticExports,
name,
() =>
new SyntheticNamedExportVariable(
this.astContext,
name,
this.getSyntheticNamespace()
)
)
];
}
// we don't want to create shims when we are just
// probing export * modules for exports
if (!isExportAllSearch && this.options.shimMissingExports) {
this.shimMissingExport(name);
return [this.exportShimVariable];
}
return [null];
}
}可以看到实现上针对不同类型的导出方式获取的方式有些差异化,以下逐一介绍针对不同方式的导出,其获取 Rollup Ast Node 的不同实现方式。
重导出(reexport)变量的
Rollup Ast节点获取可以看出针对重导出的
Rollup Ast变量声明节点的获取会调用getVariableForExportNameRecursive方法tsconst reexportDeclaration = this.reexportDescriptions.get(name); if (reexportDeclaration) { const [variable] = getVariableForExportNameRecursive( // 重导出依赖模块 reexportDeclaration.module, reexportDeclaration.localName, importerForSideEffects, false, searchedNamesAndModules ); // 其他逻辑省略 }在
getVariableForExportNameRecursive方法内部递归调用 重导出的依赖模块 的getVariableForExportName方法来检索依赖模块中是否声明了目标变量,若没有则继续递归调用直到找到目标变量的Rollup Ast Node节点为止。tsfunction getVariableForExportNameRecursive( target: Module | ExternalModule, name: string, importerForSideEffects: Module | undefined, isExportAllSearch: boolean | undefined, searchedNamesAndModules = new Map< string, Set<Module | ExternalModule> >() ): [variable: Variable | null, indirectExternal?: boolean] { const searchedModules = searchedNamesAndModules.get(name); if (searchedModules) { if (searchedModules.has(target)) { return isExportAllSearch ? [null] : error(logCircularReexport(name, target.id)); } searchedModules.add(target); } else { searchedNamesAndModules.set(name, new Set([target])); } return target.getVariableForExportName(name, { importerForSideEffects, isExportAllSearch, searchedNamesAndModules }); }导出(export)变量的
Rollup Ast节点获取获取
export的变量的Rollup Ast Node节点tsclass Module { getVariableForExportName( name: string, { importerForSideEffects, isExportAllSearch, onlyExplicit, searchedNamesAndModules }: { importerForSideEffects?: Module; isExportAllSearch?: boolean; onlyExplicit?: boolean; searchedNamesAndModules?: Map< string, Set<Module | ExternalModule> >; } = EMPTY_OBJECT ): [variable: Variable | null, indirectExternal?: boolean] { // 其他逻辑省略 const exportDeclaration = this.exports.get(name); if (exportDeclaration) { if (exportDeclaration === MISSING_EXPORT_SHIM_DESCRIPTION) { return [this.exportShimVariable]; } const name = exportDeclaration.localName; const variable = this.traceVariable(name, { importerForSideEffects, searchedNamesAndModules })!; if (importerForSideEffects) { setAlternativeExporterIfCyclic( variable, importerForSideEffects, this ); getOrCreate( importerForSideEffects.sideEffectDependenciesByVariable, variable, getNewSet<Module> ).add(this); } return [variable]; } } // 其他逻辑省略 }可以看到会通过
traceVariable方法获取目标模块中声明的导出变量。jsclass Module { traceVariable( name: string, { importerForSideEffects, isExportAllSearch, searchedNamesAndModules }: { importerForSideEffects?: Module; isExportAllSearch?: boolean; searchedNamesAndModules?: Map<string, Set<Module | ExternalModule>>; } = EMPTY_OBJECT ): Variable | null { const localVariable = this.scope.variables.get(name); if (localVariable) { return localVariable; } const importDescription = this.importDescriptions.get(name); if (importDescription) { const otherModule = importDescription.module; if (otherModule instanceof Module && importDescription.name === '*') { return otherModule.namespace; } const [declaration] = getVariableForExportNameRecursive( otherModule, importDescription.name, importerForSideEffects || this, isExportAllSearch, searchedNamesAndModules ); if (!declaration) { return this.error( logMissingExport(importDescription.name, this.id, otherModule.id), importDescription.start ); } return declaration; } return null; } }但可能需要考虑以下两种特殊情况:
导出的是当前模块中声明的变量
js// target.js export const a = 1;这是一个递归出口。对于这种情况直接通过
this.scope.variables.get(name)获取当前作用域中声明的Rollup Ast Node节点即可。jsconst localVariable = this.scope.variables.get(name); if (localVariable) { return localVariable; }导出的是依赖模块中声明的变量
js// target.js import { a } from './other'; export { a };对于这种本质上和重导出的性质一致,因此继续通过
getVariableForExportNameRecursive方法递归其他模块获取目标模块作用域中包含的变量声明。jsif (importDescription) { const otherModule = importDescription.module; if (otherModule instanceof Module && importDescription.name === '*') { return otherModule.namespace; } const [declaration] = getVariableForExportNameRecursive( otherModule, importDescription.name, importerForSideEffects || this, isExportAllSearch, searchedNamesAndModules ); if (!declaration) { return this.error( logMissingExport(importDescription.name, this.id, otherModule.id), importDescription.start ); } return declaration; }举一个例子
js// main.js export { a } from './other';js// other.js export { a } from './other-next';js// other-next.js import { a } from './other-next-next.js'; export { a };js// other-next-next.js export const a = 123;若在
main.js中通过getVariableForExportName方法获取a变量的Rollup Ast Node节点时,会递归调用getVariableForExportNameRecursive方法,直到找到a变量为止。也就是最后会获取到other-next-next模块中声明的a变量的Rollup Ast Node节点,同时该Rollup Ast Node节点的module属性为other-next-next模块的引用。:::
确定 statements 是否具有副作用
提前说明
每一个模块中都会包含 moduleSideEffects 属性(module.info.moduleSideEffects),该属性用于标记当前的模块是否具有副作用,从而影响 Rollup 对模块的 tree-sharking 行为。
interface ModuleOptions {
attributes: Record<string, string>;
meta: CustomPluginOptions;
moduleSideEffects: boolean | 'no-treeshake';
syntheticNamedExports: boolean | string;
}moduleSideEffects 值分为以下三种:
moduleSideEffects = true表示该模块具有副作用,这是 Rollup 的默认行为,
Rollup认为所有模块都具有副作用。tsclass ModuleLoader { constructor( private readonly graph: Graph, private readonly modulesById: Map<string, Module | ExternalModule>, private readonly options: NormalizedInputOptions, private readonly pluginDriver: PluginDriver ) { this.hasModuleSideEffects = options.treeshake ? options.treeshake.moduleSideEffects : () => true; } private getResolvedIdWithDefaults( resolvedId: NormalizedResolveIdWithoutDefaults | null, attributes: Record<string, string> ): ResolvedId | null { if (!resolvedId) { return null; } const external = resolvedId.external || false; return { attributes: resolvedId.attributes || attributes, external, id: resolvedId.id, meta: resolvedId.meta || {}, moduleSideEffects: resolvedId.moduleSideEffects ?? this.hasModuleSideEffects(resolvedId.id, !!external), resolvedBy: resolvedId.resolvedBy ?? 'rollup', syntheticNamedExports: resolvedId.syntheticNamedExports ?? false }; } }执行
Rollup的resolveId插件钩子时,可以通过钩子返回的moduleSideEffects属性来指定模块是否有副作用,从而影响Rollup对模块的tree-sharking行为。钩子没有返回moduleSideEffects属性时,则默认模块具有副作用(即moduleSideEffects = true),当然也可以通过options.treeshake.moduleSideEffects属性来指定上述的默认行为。tree-sharking阶段会根据statements是否有副作用(hasEffects)执行tree-sharking操作。tsclass Module { include(): void { const context = createInclusionContext(); if (this.ast!.shouldBeIncluded(context)) this.ast!.include(context, false); } }moduleSideEffects = false:表示该模块没有副作用,tree-sharking阶段会直接删掉该模块。moduleSideEffects = 'no-treeshake':表示该模块不需要执行tree-sharking操作,完全保留该模块的内容及其所有依赖项。tsclass Graph { private includeStatements(): void { // 省略上方逻辑 if (this.options.treeshake) { let treeshakingPass = 1; do { timeStart(`treeshaking pass ${treeshakingPass}`, 3); this.needsTreeshakingPass = false; for (const module of this.modules) { if (module.isExecuted) { if (module.info.moduleSideEffects === 'no-treeshake') { module.includeAllInBundle(); } else { module.include(); } } } if (treeshakingPass === 1) { // We only include exports after the first pass to avoid issues with // the TDZ detection logic for (const module of entryModules) { if (module.preserveSignature !== false) { module.includeAllExports(false); this.needsTreeshakingPass = true; } } } timeEnd(`treeshaking pass ${treeshakingPass++}`, 3); } while (this.needsTreeshakingPass); } // 省略下方逻辑 } }可以看到如果需要执行的模块,若其
moduleSideEffects属性值设置为'no-treeshake'时,Rollup会通过执行module.includeAllInBundle()方法来保留该模块中的所有内容,includeAllInBundle的详细说明可见下方。
moduleSideEffects 属性值还可以通过插件的 load、transform 钩子来指定当前模块是否具有副作用,插件执行完后 Rollup 会通过 updateOptions 方法来更新模块的 moduleSideEffects 属性。
class Module {
updateOptions({
meta,
moduleSideEffects,
syntheticNamedExports
}: Partial<PartialNull<ModuleOptions>>): void {
if (moduleSideEffects != null) {
this.info.moduleSideEffects = moduleSideEffects;
}
if (syntheticNamedExports != null) {
this.info.syntheticNamedExports = syntheticNamedExports;
}
if (meta != null) {
Object.assign(this.info.meta, meta);
}
}
}执行完 生成模块依赖图、排序模块执行顺序 之后,会正式进入 tree-sharking 阶段,具体的逻辑实现在 includeStatements 方法中。
class Graph {
async build(): Promise<void> {
timeStart('generate module graph', 2);
await this.generateModuleGraph();
timeEnd('generate module graph', 2);
timeStart('sort and bind modules', 2);
this.phase = BuildPhase.ANALYSE;
this.sortModules();
timeEnd('sort and bind modules', 2);
timeStart('mark included statements', 2);
this.includeStatements();
timeEnd('mark included statements', 2);
this.phase = BuildPhase.GENERATE;
}
}includeStatements
rollup 以用户的配置项(即 options.input) 和 implictlyLoadedBefore 作为入口,通过 BFS 算法遍历各个入口模块的所有依赖项,遍历到的依赖项模块需要执行(即 module.isExecuted = true)。
function markModuleAndImpureDependenciesAsExecuted(
baseModule: Module
): void {
baseModule.isExecuted = true;
const modules = [baseModule];
const visitedModules = new Set<string>();
for (const module of modules) {
for (const dependency of [
...module.dependencies,
...module.implicitlyLoadedBefore
]) {
if (
!(dependency instanceof ExternalModule) &&
!dependency.isExecuted &&
(dependency.info.moduleSideEffects ||
module.implicitlyLoadedBefore.has(dependency)) &&
!visitedModules.has(dependency.id)
) {
dependency.isExecuted = true;
visitedModules.add(dependency.id);
modules.push(dependency);
}
}
}
}
class Graph {
private includeStatements(): void {
const entryModules = [
...this.entryModules,
...this.implicitEntryModules
];
for (const module of entryModules) {
markModuleAndImpureDependenciesAsExecuted(module);
}
// 省略下方逻辑
}
}标记完哪些模块需要执行后,则要正式进入 tree-sharking 阶段。
class Graph {
private includeStatements(): void {
// 省略上方逻辑
if (this.options.treeshake) {
// 省略 tree-sharking 逻辑
} else {
for (const module of this.modules) module.includeAllInBundle();
}
// 省略下方逻辑
}
}根据用户配置项(即 options.treeshake)判断是否开启 tree-sharking。若不需要执行 tree-sharking,则对所有模块执行 includeAllInBundle 方法。先来看一下 includeAllInBundle 方法的逻辑。
class Module {
includeAllInBundle(): void {
this.ast!.include(createInclusionContext(), true);
this.includeAllExports(false);
}
}includeAllInBundle 方法做了两件事情,在此之前需要明确一件事情,若 Ast Node 确认为需要包含在最后的产物中则会执行 node.include,后续 ast.render 阶段会跳过这些 未包含 的 Ast 节点,从而达到 tree-sharking 的效果。
调用
ast.include方法,将当前模块的Ast节点标记为 包含。tsclass Program { include( context: InclusionContext, includeChildrenRecursively: IncludeChildren ): void { this.included = true; for (const node of this.body) { if (includeChildrenRecursively || node.shouldBeIncluded(context)) { node.include(context, includeChildrenRecursively); } } } }调用
ast.include的第二个参数includeChildrenRecursively标记为true,意味着在递归模块的Ast节点,会递归包含(确认执行node.include)所有的子孙Ast Node节点。若没有这个标记位,那么Ast Node是否需要包含在最终的代码中是由Ast Node是否存在 副作用 而决定的。jsclass NodeBase { shouldBeIncluded(context: InclusionContext): boolean { return this.included || (!context.brokenFlow && this.hasEffects(createHasEffectsContext())); } }调用
includeAllExports方法。
includeAllExports 方法主要也做了两件事:
function markModuleAndImpureDependenciesAsExecuted(
baseModule: Module
): void {
baseModule.isExecuted = true;
const modules = [baseModule];
const visitedModules = new Set<string>();
for (const module of modules) {
for (const dependency of [
...module.dependencies,
...module.implicitlyLoadedBefore
]) {
if (
!(dependency instanceof ExternalModule) &&
!dependency.isExecuted &&
(dependency.info.moduleSideEffects ||
module.implicitlyLoadedBefore.has(dependency)) &&
!visitedModules.has(dependency.id)
) {
dependency.isExecuted = true;
visitedModules.add(dependency.id);
modules.push(dependency);
}
}
}
}
class Module {
getReexports(): string[] {
if (this.transitiveReexports) {
return this.transitiveReexports;
}
// to avoid infinite recursion when using circular `export * from X`
this.transitiveReexports = [];
const reexports = new Set(this.reexportDescriptions.keys());
for (const module of this.exportAllModules) {
if (module instanceof ExternalModule) {
reexports.add(`*${module.id}`);
} else {
for (const name of [
...module.getReexports(),
...module.getExports()
]) {
if (name !== 'default') reexports.add(name);
}
}
}
return (this.transitiveReexports = [...reexports]);
}
includeAllExports(includeNamespaceMembers: boolean): void {
if (!this.isExecuted) {
markModuleAndImpureDependenciesAsExecuted(this);
this.graph.needsTreeshakingPass = true;
}
for (const exportName of this.exports.keys()) {
if (
includeNamespaceMembers ||
exportName !== this.info.syntheticNamedExports
) {
const variable = this.getVariableForExportName(exportName)[0];
if (!variable) {
return error(logMissingEntryExport(exportName, this.id));
}
variable.deoptimizePath(UNKNOWN_PATH);
if (!variable.included) {
this.includeVariable(variable);
}
}
}
for (const name of this.getReexports()) {
const [variable] = this.getVariableForExportName(name);
if (variable) {
variable.deoptimizePath(UNKNOWN_PATH);
if (!variable.included) {
this.includeVariable(variable);
}
if (variable instanceof ExternalVariable) {
variable.module.reexported = true;
}
}
}
if (includeNamespaceMembers) {
this.namespace.setMergedNamespaces(
this.includeAndGetAdditionalMergedNamespaces()
);
}
}
}- 若模块未标记为已执行,则标记当前模块及其依赖的模块为已执行(即
module.isExecuted = true)。 - 遍历当前模块的
exports和reexports,将所有 导出变量 和 重导出变量 对应的Ast Node节点标记为 包括 (即variable.included = true)。
这里有一个有意思的点是通过 getVariableForExportName 来获取导出和重导出变量所对应的 Ast Node,来看看是如何实现的吧。
AST 节点副作用判定体系
上述 includeStatements 流程中,判断 statement 是否需要保留在最终产物中的核心方法是 shouldBeIncluded:
class NodeBase {
shouldBeIncluded(context: InclusionContext): boolean {
return this.included || (!context.brokenFlow && this.hasEffects(createHasEffectsContext()));
}
}hasEffects() 是一个递归下降的分析过程,默认实现递归检查所有子节点:
class NodeBase {
hasEffects(context: HasEffectsContext): boolean {
if (!this.deoptimized) this.applyDeoptimizations();
for (const key of childNodeKeys[this.type]) {
const value = (this as GenericEsTreeNode)[key];
if (value === null) continue;
if (Array.isArray(value)) {
for (const child of value) {
if (child?.hasEffects(context)) return true;
}
} else if (value.hasEffects(context)) return true;
}
return false;
}
}rollup 为不同的 AST 节点类型覆写了 hasEffects() 方法,实现了精细化的副作用判定。以下按照判定结果的确定性分为三类进行分析。
恒定有副作用的节点
以下节点类型的 hasEffects() 始终返回 true,无论上下文如何均会被保留。
ThrowStatement
// src/ast/nodes/ThrowStatement.ts
hasEffects(): boolean {
return true;
}throw 语句改变了程序的控制流,属于不可消除的副作用。
throw new Error('x'); // 始终保留DebuggerStatement
// src/ast/nodes/DebuggerStatement.ts
hasEffects(): boolean {
return true;
}debugger 语句会中断程序执行。
// 输入
debugger;
const unused = 1;
// Rollup 输出
debugger;
// unused 被移除,但 debugger 始终保留AwaitExpression
// src/ast/nodes/AwaitExpression.ts
hasEffects(): boolean {
return true;
}await 暂停执行并触发微任务调度,rollup 保守地认为它始终有副作用。
// 输入
export async function main() {
await Promise.resolve();
const unused = 42;
return 1;
}
// Rollup 输出
async function main() {
await Promise.resolve();
return 1;
}
// await 表达式始终保留,unused 被移除
export { main };ImportExpression(动态 import)
// src/ast/nodes/ImportExpression.ts
hasEffects(): boolean {
return true;
}import() 触发模块加载(网络请求或文件 I/O),属于运行时副作用。
// 输入
const unused = import('./module');
// Rollup 输出
import('./module');
// 即使 unused 未被使用,import() 调用也会被保留(副作用)
// 但赋值目标 unused 被移除ForOfStatement
// src/ast/nodes/ForOfStatement.ts
hasEffects(): boolean {
if (!this.deoptimized) this.applyDeoptimizations();
// Placeholder until proper Symbol.Iterator support
return true;
}for...of 会调用 Symbol.iterator 协议,rollup 目前尚未实现对迭代器协议的精确追踪,因此保守地标记为有副作用。源码中的注释也明确说明这是一个临时的占位实现。
// 输入
const arr = [1, 2, 3];
for (const item of arr) {
// 空循环体
}
const unused = 1;
// Rollup 输出
const arr = [1, 2, 3];
for (const item of arr) {
}
// for...of 始终保留(可能调用自定义 Symbol.iterator)
// unused 被移除UnknownNode
// src/ast/nodes/UnknownNode.ts
hasEffects(): boolean {
return true;
}无法识别的节点类型,保守处理以确保正确性。
恒定无副作用的节点
以下节点类型的 hasEffects() 始终返回 false,不会产生任何副作用。
ImportDeclaration
// src/ast/nodes/ImportDeclaration.ts
hasEffects(): boolean {
return false;
}import 声明本身不产生副作用,模块的副作用在模块级别通过 moduleSideEffects 单独处理。
import { foo } from './bar'; // 始终无副作用ExportAllDeclaration
// src/ast/nodes/ExportAllDeclaration.ts
hasEffects(): boolean {
return false;
}export * from './foo' 是纯声明性语句,不产生运行时行为。
export * from './utils';
// 声明本身无副作用,是否保留取决于 utils 模块是否有被使用的导出ArrowFunctionExpression
// src/ast/nodes/ArrowFunctionExpression.ts
hasEffects(): boolean {
return false;
}箭头函数的声明本身无副作用,只有调用时才可能产生副作用。这是一个关键区分 —— 函数声明和函数调用的副作用判定是分开的。
const fn = () => console.log('hi'); // 声明无副作用
fn(); // 调用有副作用(取决于 callee 分析)EmptyStatement、TemplateElement、MetaProperty
这些节点不产生任何运行时行为,始终返回 false。
条件性副作用节点
以下节点类型的副作用判定取决于其子节点、上下文配置或运行时语义,是 rollup tree-shaking 精度的核心所在。
CallExpression — 函数调用
函数调用是最常见的副作用来源,rollup 的判定逻辑包含四个层次:
// src/ast/nodes/CallExpression.ts
hasEffects(context: HasEffectsContext): boolean {
if (!this.deoptimized) this.applyDeoptimizations();
for (const argument of this.arguments) {
if (argument.hasEffects(context)) return true; // ① 参数有副作用
}
if (this.annotationPure) {
return false; // ② #__PURE__ 注解
}
return (
this.callee.hasEffects(context) || // ③ callee 本身有副作用
this.callee.hasEffectsOnInteractionAtPath( // ④ 调用 callee 有副作用
EMPTY_PATH, this.interaction, context
)
);
}判定优先级:
- 参数副作用:先检查每个参数表达式是否有副作用。例如
foo(bar())中,若bar()有副作用,则整个调用有副作用。 #__PURE__注解:若调用标记了/*#__PURE__*/,跳过后续检查,直接认为无副作用。callee自身副作用:检查callee表达式是否有副作用(如计算属性访问)。- 调用交互副作用:通过
hasEffectsOnInteractionAtPath递归分析函数体内是否有副作用。
示例 1 — #__PURE__ 注解:
const result = /*#__PURE__*/ createApp();
// annotationPure = true → 无副作用 → 若 result 未被使用,整个调用可移除示例 2 — 已知无副作用的函数体:
function add(a, b) { return a + b; }
add(1, 2);
// hasEffectsOnInteractionAtPath 分析函数体 → return a + b 无副作用 → 整个调用无副作用示例 3 — 未知函数:
const fn = getHandler();
fn();
// fn 的值无法静态确定 → 保守认为有副作用对应的 #__PURE__ 注解收集逻辑在 CallExpression.initialise() 中:
// src/ast/nodes/CallExpression.ts
initialise() {
super.initialise();
if (
this.annotations &&
(this.scope.context.options.treeshake as NormalizedTreeshakingOptions).annotations
) {
this.annotationPure = this.annotations.some(comment => comment.type === 'pure');
}
}FunctionBase.hasEffectsOnInteractionAtPath — 函数被调用时的副作用
当 CallExpression 的 callee 是一个可追踪的函数时,rollup 会深入分析函数体来判断调用是否有副作用:
// src/ast/nodes/shared/FunctionBase.ts
hasEffectsOnInteractionAtPath(
path: ObjectPath,
interaction: NodeInteraction,
context: HasEffectsContext
): boolean {
if (path.length > 0 || interaction.type !== INTERACTION_CALLED) {
return this.getObjectEntity().hasEffectsOnInteractionAtPath(path, interaction, context);
}
if (this.hasCachedEffects) {
return true;
}
// async 函数:检查返回值的 .then 是否有副作用
if (this.async) {
const { propertyReadSideEffects } = this.scope.context.options.treeshake;
const returnExpression = this.scope.getReturnExpression();
if (
returnExpression.hasEffectsOnInteractionAtPath(
['then'], NODE_INTERACTION_UNKNOWN_CALL, context
) ||
(propertyReadSideEffects && (
propertyReadSideEffects === 'always' ||
returnExpression.hasEffectsOnInteractionAtPath(
['then'], NODE_INTERACTION_UNKNOWN_ACCESS, context
)
))
) {
this.hasCachedEffects = true;
return true;
}
}
// 检查每个参数的解构是否有副作用
const { propertyReadSideEffects } = this.scope.context.options.treeshake;
for (let index = 0; index < this.params.length; index++) {
const parameter = this.params[index];
if (
parameter.hasEffects(context) ||
(propertyReadSideEffects &&
parameter.hasEffectsWhenDestructuring(
context, EMPTY_PATH, interaction.args[index + 1] || UNDEFINED_EXPRESSION
))
) {
this.hasCachedEffects = true;
return true;
}
}
return false;
}这段逻辑有几个值得关注的点:
async函数的隐式副作用:async函数返回Promise,rollup会检查返回值的.then访问或调用是否有副作用。若返回值存在自定义then方法(thenable 对象),则认定有副作用。- 参数解构副作用:若参数包含解构模式(如
function({ a })),且propertyReadSideEffects为true,则检查解构操作是否可能触发 getter。 - 缓存机制:
hasCachedEffects用于缓存副作用判定结果,对于已经确认有副作用的函数不再重复分析。
示例 1 — 纯函数调用可消除:
// 输入
function add(a, b) { return a + b; }
const unused = add(1, 2);
// Rollup 输出
// (空,整段代码被移除)
// add 函数体只有 return a + b,无副作用
// unused 未被使用 → add(1, 2) 调用也可移除示例 2 — async 函数返回 thenable 对象:
// 输入
async function fetchData() {
return { then() { console.log('thenable'); } };
}
fetchData();
// Rollup 输出
async function fetchData() {
return { then() { console.log('thenable'); } };
}
fetchData();
// 返回值具有 then 方法 → Promise 决议时会调用 → 有副作用 → 保留示例 3 — 参数解构触发 getter:
// 输入(propertyReadSideEffects: true)
function process({ name }) { return name; }
process({ name: 'test' });
// Rollup 输出
// (空,整段代码被移除)
// 参数 { name } 解构的来源是对象字面量 { name: 'test' },属性读取无副作用
// 但如果:
function process({ name }) { return name; }
process(unknownObj);
// unknownObj 不可追踪 → 解构可能触发 getter → 有副作用 → 保留AssignmentExpression — 赋值表达式
// src/ast/nodes/AssignmentExpression.ts
hasEffects(context: HasEffectsContext): boolean {
const { deoptimized, isConstReassignment, left, operator, right } = this;
if (!deoptimized) this.applyDeoptimizations();
// MemberExpressions do not access the property before assignments if the
// operator is '='.
return (
isConstReassignment || // ① const 重新赋值
right.hasEffects(context) || // ② 右侧有副作用
left.hasEffectsAsAssignmentTarget(context, operator !== '=') || // ③ 赋值目标有副作用
this.left.hasEffectsWhenDestructuring?.(context, EMPTY_PATH, right) // ④ 解构有副作用
);
}示例 1 — 简单赋值无副作用:
let x;
x = 42; // right(42) 无副作用,left(x) 是本地变量 → 无副作用示例 2 — 对象属性赋值有副作用:
obj.foo = bar; // left 是 MemberExpression → 可能触发 setter → 有副作用示例 3 — 复合赋值操作符:
x += getValue();
// operator !== '=' → hasEffectsAsAssignmentTarget 的 checkAccess = true
// 需要先读取 x 的值(可能触发 getter)→ 有副作用示例 4 — const 重新赋值:
// src/ast/nodes/AssignmentExpression.ts
initialise(): void {
super.initialise();
if (this.left instanceof Identifier) {
const variable = this.scope.variables.get(this.left.name);
if (variable?.kind === 'const') {
this.isConstReassignment = true;
// ...
}
}
this.left.setAssignedValue(this.right);
}rollup 在初始化阶段就检测 const 变量的重新赋值,标记为 isConstReassignment = true,运行时必然抛出 TypeError,因此始终有副作用。
MemberExpression — 属性访问
// src/ast/nodes/MemberExpression.ts
hasEffects(context: HasEffectsContext): boolean {
if (!this.deoptimized) this.applyDeoptimizations();
return (
this.property.hasEffects(context) || // ① 计算属性有副作用
this.object.hasEffects(context) || // ② 对象本身有副作用
this.hasAccessEffect(context) // ③ 属性访问有副作用
);
}其中 hasAccessEffect 的逻辑是核心:
// src/ast/nodes/MemberExpression.ts
private hasAccessEffect(context: HasEffectsContext) {
const { propertyReadSideEffects } = this.scope.context.options.treeshake;
return (
!(this.variable || this.isUndefined) &&
propertyReadSideEffects &&
(propertyReadSideEffects === 'always' ||
this.object.hasEffectsOnInteractionAtPath(
[this.getDynamicPropertyKey()], this.accessInteraction, context
))
);
}关键配置项:treeshake.propertyReadSideEffects
- 若
variable已绑定(如 namespace 导入),则跳过属性访问副作用检查。 - 若
propertyReadSideEffects为false,则假设所有属性读取无副作用。 - 若
propertyReadSideEffects为'always',则所有未绑定变量的属性读取都有副作用。 - 默认值
true时,通过hasEffectsOnInteractionAtPath递归判断对象在该属性路径上的读取是否有副作用。
示例 1 — propertyReadSideEffects: true(默认):
const x = unknownObj.foo;
// unknownObj 不可追踪 → 可能存在 getter/Proxy → 有副作用示例 2 — 已知 namespace 变量:
import * as ns from './foo';
ns.bar;
// variable 已在 bind 阶段绑定 → hasAccessEffect 被跳过 → 无副作用IfStatement — 分支消除
// src/ast/nodes/IfStatement.ts
hasEffects(context: HasEffectsContext): boolean {
if (this.test.hasEffects(context)) {
return true;
}
const testValue = this.getTestValue();
if (typeof testValue === 'symbol') {
// 无法确定值 → 两个分支都检查
const { brokenFlow } = context;
if (this.consequent.hasEffects(context)) return true;
const consequentBrokenFlow = context.brokenFlow;
context.brokenFlow = brokenFlow;
if (this.alternate === null) return false;
if (this.alternate.hasEffects(context)) return true;
context.brokenFlow = context.brokenFlow && consequentBrokenFlow;
return false;
}
// 可确定值 → 只检查会执行的分支
return testValue
? this.consequent.hasEffects(context)
: !!this.alternate?.hasEffects(context);
}rollup 通过 getTestValue() 尝试在编译时确定条件表达式的值:
// src/ast/nodes/IfStatement.ts
private getTestValue(): LiteralValueOrUnknown {
if (this.testValue === unset) {
return (this.testValue = tryCastLiteralValueToBoolean(
this.test.getLiteralValueAtPath(EMPTY_PATH, SHARED_RECURSION_TRACKER, this)
));
}
return this.testValue;
}若条件值可以在编译时确定,则只分析会执行的分支。否则两个分支都需要分析。同时 rollup 在分析分支时维护了 brokenFlow 上下文,用于追踪 break、continue、return 等控制流中断的场景。
示例 1 — 编译时常量条件(DCE):
if (false) { console.log('dead'); }
// testValue = false → 只检查 alternate(null)→ 无副作用 → 整条语句可移除示例 2 — 替换后的编译时常量:
// 经过 replace 插件替换后
if ("production" !== "production") { enableDevTools(); }
// test 值为 false → 只检查 alternate → 无副作用示例 3 — 运行时条件:
if (userInput) { sideEffect(); }
// testValue 无法确定 → 两个分支都检查 → sideEffect() 有副作用 → trueConditionalExpression — 三元表达式
与 IfStatement 采用相同的分支优化逻辑:
// src/ast/nodes/ConditionalExpression.ts
hasEffects(context: HasEffectsContext): boolean {
if (this.test.hasEffects(context)) return true;
const usedBranch = this.getUsedBranch();
if (!usedBranch) {
return this.consequent.hasEffects(context) || this.alternate.hasEffects(context);
}
return usedBranch.hasEffects(context);
}通过 getUsedBranch() 尝试确定使用的分支:
// src/ast/nodes/ConditionalExpression.ts
private getUsedBranch() {
if (this.isBranchResolutionAnalysed) {
return this.usedBranch;
}
this.isBranchResolutionAnalysed = true;
const testValue = tryCastLiteralValueToBoolean(
this.test.getLiteralValueAtPath(EMPTY_PATH, SHARED_RECURSION_TRACKER, this)
);
return typeof testValue === 'symbol'
? null
: (this.usedBranch = testValue ? this.consequent : this.alternate);
}示例:
const x = true ? pureFunction() : sideEffect();
// usedBranch = consequent → 只分析 pureFunction()LogicalExpression — 逻辑短路
// src/ast/nodes/LogicalExpression.ts
hasEffects(context: HasEffectsContext): boolean {
if (this.left.hasEffects(context)) {
return true;
}
if (this.getUsedBranch() !== this.left) {
return this.right.hasEffects(context);
}
return false;
}rollup 会根据逻辑操作符(||、&&、??)和左侧的编译时值来确定短路行为:
// src/ast/nodes/LogicalExpression.ts
private getUsedBranch() {
if (!this.isBranchResolutionAnalysed) {
this.isBranchResolutionAnalysed = true;
const leftValue = this.left.getLiteralValueAtPath(EMPTY_PATH, SHARED_RECURSION_TRACKER, this);
const booleanOrUnknown = tryCastLiteralValueToBoolean(leftValue);
if (
typeof booleanOrUnknown === 'symbol' ||
(this.operator === '??' && typeof leftValue === 'symbol')
) {
return null;
} else {
this.usedBranch =
(this.operator === '||' && booleanOrUnknown) ||
(this.operator === '&&' && !booleanOrUnknown) ||
(this.operator === '??' && leftValue != null)
? this.left
: this.right;
}
}
return this.usedBranch;
}示例 1 — || 短路:
const x = true || sideEffect();
// left = true, operator = '||' → usedBranch = left → 不检查 right → 无副作用示例 2 — && 传播:
const x = condition && sideEffect();
// condition 不确定 → usedBranch = null → 两侧都检查 → sideEffect() 有副作用UnaryExpression — 一元操作符
// src/ast/nodes/UnaryExpression.ts
hasEffects(context: HasEffectsContext): boolean {
if (!this.deoptimized) this.applyDeoptimizations();
if (this.operator === 'typeof' && this.argument instanceof Identifier) return false;
return (
this.argument.hasEffects(context) ||
(this.operator === 'delete' &&
this.argument.hasEffectsOnInteractionAtPath(
EMPTY_PATH, NODE_INTERACTION_UNKNOWN_ASSIGNMENT, context
))
);
}三个关键规则:
typeof+Identifier:typeof undeclaredVar不会抛出ReferenceError,因此直接返回false。delete操作:delete obj.prop修改了对象结构,通过赋值交互检查判定为有副作用。- 其他操作符(
!、+、-、void、~):只递归检查操作数。
示例 1 — typeof 特殊处理:
typeof undeclaredVar; // typeof + Identifier → false示例 2 — delete 操作:
delete obj.prop; // delete → 检查赋值交互 → 有副作用示例 3 — void 操作:
void someExpression; // 递归检查 someExpressionBinaryExpression — 隐式类型转换
// src/ast/nodes/BinaryExpression.ts
hasEffects(context: HasEffectsContext): boolean {
// support some implicit type coercion runtime errors
if (
this.operator === '+' &&
this.parent instanceof ExpressionStatement &&
this.left.getLiteralValueAtPath(EMPTY_PATH, SHARED_RECURSION_TRACKER, this) === ''
) {
return true;
}
return super.hasEffects(context);
}rollup 对 '' + obj 模式进行了特殊处理:当 + 操作符的左侧为空字符串且整个表达式作为 ExpressionStatement 使用时,可能调用 obj.toString() 或 obj[Symbol.toPrimitive](),因此认定为有副作用。
'' + obj; // 可能触发隐式类型转换 → 有副作用
1 + 2; // 字面量运算 → 递归检查子节点 → 无副作用TryStatement — try/catch 去优化
// src/ast/nodes/TryStatement.ts
hasEffects(context: HasEffectsContext): boolean {
return (
((this.scope.context.options.treeshake as NormalizedTreeshakingOptions).tryCatchDeoptimization
? this.block.body.length > 0 // 有内容即认为有副作用
: this.block.hasEffects(context)) || // 否则正常递归分析
!!this.finalizer?.hasEffects(context)
);
}当 tryCatchDeoptimization 为 true(默认)时,只要 try 块非空就认为有副作用。这是因为 try 块中的代码若抛出异常,catch 块会改变控制流,rollup 难以精确追踪异常传播路径。
try {
pureFunctionCall(); // 即使函数体无副作用,try 块非空 → 有副作用
} catch (e) {}同时在 include 阶段,tryCatchDeoptimization 还会影响 try 块内的 include 策略:
// src/ast/nodes/TryStatement.ts
include(context: InclusionContext, includeChildrenRecursively: IncludeChildren): void {
const tryCatchDeoptimization = (
this.scope.context.options.treeshake as NormalizedTreeshakingOptions
)?.tryCatchDeoptimization;
// ...
if (!this.directlyIncluded || !tryCatchDeoptimization) {
this.included = true;
this.directlyIncluded = true;
this.block.include(
context,
tryCatchDeoptimization ? INCLUDE_PARAMETERS : includeChildrenRecursively
);
// ...
}
// ...
}当 tryCatchDeoptimization 为 true 时,try 块中的语句以 INCLUDE_PARAMETERS 模式进行 include,这意味着在 try 块中被使用的函数参数不会被优化掉。这也与前面 Try Catch Deoptimization 配置项所展示的行为一致:try 语句中使用的参数会被保留。
VariableDeclarator — 变量声明
// src/ast/nodes/VariableDeclarator.ts
hasEffects(context: HasEffectsContext): boolean {
const initEffect = this.init?.hasEffects(context);
this.id.markDeclarationReached();
return (
initEffect || // ① 初始化表达式有副作用
this.isUsingDeclaration || // ② using 声明
this.isAsyncUsingDeclaration || // ③ await using 声明
this.id.hasEffects(context) || // ④ 声明模式有副作用
((this.scope.context.options.treeshake as NormalizedTreeshakingOptions)
.propertyReadSideEffects &&
this.id.hasEffectsWhenDestructuring( // ⑤ 解构有副作用
context, EMPTY_PATH, this.init || UNDEFINED_EXPRESSION
))
);
}示例 1 — 简单声明:
const x = 42; // init 为字面量 42 无副作用,id 为 Identifier → 无副作用示例 2 — 函数调用初始化:
const config = getConfig(); // getConfig() 有副作用 → 有副作用示例 3 — using 声明(TC39 Explicit Resource Management):
using handle = getResource();
// isUsingDeclaration = true → 有副作用(运行时会调用 Symbol.dispose)示例 4 — 解构赋值与 propertyReadSideEffects:
const { a } = unknownObj;
// propertyReadSideEffects = true → hasEffectsWhenDestructuring 检查属性读取 → 有副作用ClassNode — class 声明
// src/ast/nodes/shared/ClassNode.ts
hasEffects(context: HasEffectsContext): boolean {
if (!this.deoptimized) this.applyDeoptimizations();
const initEffect = this.superClass?.hasEffects(context) || this.body.hasEffects(context);
this.id?.markDeclarationReached();
return initEffect || super.hasEffects(context) || checkEffectForNodes(this.decorators, context);
}class 声明的副作用来源有三个方面:
extends表达式:若父类是函数调用结果(如class Foo extends getBase()),则有副作用。- 类体内容:静态属性初始化、计算属性名等可能有副作用。
- 装饰器:装饰器本质上是函数调用,因此有副作用。
示例 1 — 纯 class 声明:
class Foo {
method() { return 1; }
}
// 无 superClass,body(MethodDefinition)无副作用 → 无副作用示例 2 — 有 extends:
class Bar extends getBase() { }
// superClass = getBase() → 函数调用 → 有副作用示例 3 — 装饰器:
@decorator
class Qux { }
// decorator 是函数调用 → checkEffectForNodes(this.decorators, context) → 有副作用交互级副作用判定(hasEffectsOnInteractionAtPath)
rollup 不仅判断节点自身的副作用,还判断对节点进行特定操作时是否有副作用。这是一个独立于 hasEffects() 的分析维度,通过 hasEffectsOnInteractionAtPath 方法实现。
三种交互类型定义在 NodeInteractions 中:
INTERACTION_ACCESSED:属性读取(可能触发 getter)INTERACTION_CALLED:函数调用INTERACTION_ASSIGNED:赋值(可能触发 setter)
这种设计使得 rollup 能够区分声明和使用:
function pure() { return 1; } // 声明 → hasEffects = false
pure(); // 调用 → hasEffectsOnInteractionAtPath(CALLED)
// → 递归分析函数体 → return 1 无副作用以 MemberExpression 的交互级判定为例:
// src/ast/nodes/MemberExpression.ts
hasEffectsOnInteractionAtPath(
path: ObjectPath,
interaction: NodeInteraction,
context: HasEffectsContext
): boolean {
if (this.variable) {
return this.variable.hasEffectsOnInteractionAtPath(path, interaction, context);
}
if (this.isUndefined) {
return true;
}
if (path.length < MAX_PATH_DEPTH) {
return this.object.hasEffectsOnInteractionAtPath(
[this.getDynamicPropertyKey(), ...path], interaction, context
);
}
return true;
}当路径深度未超过 MAX_PATH_DEPTH 时,rollup 会将属性键拼接到路径中,递归委托给对象表达式判断。超过深度限制则保守返回 true,防止无限递归。
具体示例:
示例 1 — 调用交互(INTERACTION_CALLED):
// 输入
function greet() { return 'hello'; }
const obj = { greet };
obj.greet();
// Rollup 输出
// (空,整段代码被移除)
// obj.greet() → MemberExpression.hasEffectsOnInteractionAtPath(['greet'], CALLED)
// → 追踪到 ObjectEntity → 找到 greet 函数 → 分析函数体 → 无副作用示例 2 — 属性读取交互(INTERACTION_ACCESSED):
// 输入(propertyReadSideEffects: true)
const obj = { get name() { console.log('accessed'); return 'x'; } };
const unused = obj.name;
// Rollup 输出
const obj = { get name() { console.log('accessed'); return 'x'; } };
obj.name;
// obj.name 读取触发 getter → getter 内有 console.log → 有副作用 → 保留示例 3 — 赋值交互(INTERACTION_ASSIGNED):
// 输入
const obj = { set value(v) { console.log('set', v); } };
obj.value = 42;
// Rollup 输出
const obj = { set value(v) { console.log('set', v); } };
obj.value = 42;
// obj.value = 42 触发 setter → setter 内有 console.log → 有副作用 → 保留延迟去优化机制(applyDeoptimizations)
在上述多个节点的 hasEffects 实现中都能看到开头的 if (!this.deoptimized) this.applyDeoptimizations(); 调用。这是 rollup 的一个关键优化:延迟去优化(lazy deoptimization)。
applyDeoptimizations 的默认实现:
// src/ast/nodes/shared/Node.ts
applyDeoptimizations() {
this.deoptimized = true;
for (const key of childNodeKeys[this.type]) {
const value = (this as GenericEsTreeNode)[key];
if (value === null) continue;
if (Array.isArray(value)) {
for (const child of value) {
child?.deoptimizePath(UNKNOWN_PATH);
}
} else {
value.deoptimizePath(UNKNOWN_PATH);
}
}
this.scope.context.requestTreeshakingPass();
}不同节点类型覆写了 applyDeoptimizations 以实现特定的去优化逻辑:
| 节点类型 | applyDeoptimizations 的关键行为 |
|---|---|
CallExpression | 将参数信息传播给 callee(deoptimizeArgumentsOnInteractionAtPath) |
AssignmentExpression | 将右侧值传播给左侧目标(deoptimizeAssignment) |
MemberExpression | 若 propertyReadSideEffects 为 true,通知对象属性被访问 |
IdentifierBase | 标记变量被使用,触发所在模块的执行标记 |
ForOfStatement | 去优化左侧声明和右侧表达式的路径 |
ClassNode | 去优化所有非静态方法的返回值 |
UnaryExpression(delete) | 去优化被删除的路径 |
所有 applyDeoptimizations 的最后都会调用 this.scope.context.requestTreeshakingPass(),即 this.graph.needsTreeshakingPass = true。这意味着去优化过程可能改变其他节点的副作用判定结果,因此需要额外的 tree-shaking 轮次来处理这些变化。
以 IdentifierBase 为例:
// src/ast/nodes/shared/IdentifierBase.ts
applyDeoptimizations() {
this.deoptimized = true;
if (this.variable instanceof LocalVariable) {
// When accessing a variable from a module without side effects, this
// means we use an export of that module and therefore need to potentially
// include it in the bundle.
if (!this.variable.module.isExecuted) {
markModuleAndImpureDependenciesAsExecuted(this.variable.module);
}
this.variable.consolidateInitializers();
this.scope.context.requestTreeshakingPass();
}
if (this.isVariableReference) {
this.variable!.addUsedPlace(this);
this.scope.context.requestTreeshakingPass();
}
}当一个标识符首次被分析到时,若其引用了一个来自标记为无副作用模块的变量,rollup 会将该模块及其不纯的依赖标记为已执行(isExecuted = true),从而在下一轮 tree-shaking 中分析这些新激活模块的副作用语句。
端到端示例 — 多轮 tree-shaking 中的延迟去优化:
// utils.js(sideEffects: false)
export function pure() { return 1; }
export function impure() { console.log('effect'); }
// main.js
import { pure } from './utils';
const result = pure();tree-shaking 过程:
第 1 轮:分析
main.js的顶层语句import { pure } from './utils'→hasEffects = false(ImportDeclaration 始终无副作用)const result = pure()→ 检查pure()是否有副作用pure标识符首次被分析 → 触发applyDeoptimizationsutils.js标记为sideEffects: false,但pure被使用 →markModuleAndImpureDependenciesAsExecuted(utils.js)requestTreeshakingPass()→ 请求下一轮
result未被使用 →pure()函数体无副作用 → 整条语句不保留
第 2 轮:
utils.js现在标记为isExecuted = true- 检查
utils.js中的顶层语句 pure和impure均为函数声明 → 无副作用- 无新增的 include → 收敛
- 检查
// Rollup 输出
// (空,整段代码被移除)
// result 未被使用,pure() 无副作用,impure() 未被引用对比 — 若 result 被使用:
// main.js
import { pure } from './utils';
export const result = pure();
// Rollup 输出
function pure() { return 1; }
const result = pure();
export { result };
// result 被导出(使用),pure() 需要保留
// impure 仍然被移除(未引用)设计取舍总结
| 决策 | 选择 | 权衡 |
|---|---|---|
| 未知节点 | 有副作用(UnknownNode) | 保证正确性,牺牲少量 tree-shake 机会 |
for...of | 始终有副作用 | 正确但保守,未来可改进 Iterator 协议追踪 |
try-catch | 默认去优化 | 安全处理运行时异常,可通过配置关闭 |
| 属性读取 | 默认可能有副作用 | 防止 getter/Proxy 带来的隐性副作用 |
#__PURE__ / #__NO_SIDE_EFFECTS__ | 用户手动标注 | 将判定权交给用户,弥补静态分析的局限性 |
| 分支消除 | 编译时常量才优化 | 保证正确性,与 DCE(Dead Code Elimination)互补 |
| 延迟去优化 | 按需触发 | 减少不必要的分析开销,但增加了多轮 tree-shaking 的可能性 |
| 交互级分析 | 区分读取/调用/赋值 | 提升精度(如函数声明无副作用但调用可能有),增加实现复杂度 |
rollup 的副作用判定本质上是一个保守的抽象解释器:宁可多保留代码(false positive),也不误删有副作用的逻辑(false negative)。用户可以通过 #__PURE__、#__NO_SIDE_EFFECTS__、sideEffects: false、treeshake 的各项配置来手动放宽约束,在正确性和产物体积之间取得平衡。
树摇流程
tree-sharking 的执行是在 Chunk 的 render 阶段,此时已经明确了哪些模块需要被执行,哪些 statements 需要保留在最终的产物中。
class Chunk {
// This method changes properties on the AST before rendering and must not be async
private renderModules(fileName: string) {
// 省略其他逻辑
for (const module of orderedModules) {
const renderedLength = 0;
let source: MagicString | undefined;
if (module.isIncluded() || includedNamespaces.has(module)) {
const rendered = module.render(renderOptions);
// 省略其他逻辑
}
}
// 省略其他逻辑
}
async render(): Promise<ChunkRenderResult> {
const {
accessedGlobals,
indent,
magicString,
renderedSource,
usedModules,
usesTopLevelAwait
} = this.renderModules(preliminaryFileName.fileName);
// 省略其他逻辑
}
}由 生成 chunks 这篇文章可知,一个 chunk 对应至少一个 module。因此上述的源码逻辑中可以看出 Rollup 会依次遍历 chunk 中所有的 module,然后依次调用每一个模块的 render 方法来生成最终的代码。
注意
chunk 中所包含的 所有模块 是按照模块执行顺序依次排序的,存储在 orderedModules 数组中。
const compareExecIndex = <T extends OrderedExecutionUnit>(
unitA: T,
unitB: T
) => (unitA.execIndex > unitB.execIndex ? 1 : -1);
function sortByExecutionOrder(units: OrderedExecutionUnit[]): void {
units.sort(compareExecIndex);
}
class Bundle {
private async generateChunks(): Promise<void> {
// 省略其他逻辑
for (const { alias, modules } of inlineDynamicImports
? [{ alias: null, modules: includedModules }]
: preserveModules
? includedModules.map(module => ({
alias: null,
modules: [module]
}))
: getChunkAssignments(
this.graph.entryModules,
manualChunkAliasByEntry,
experimentalMinChunkSize,
this.inputOptions.onLog
)) {
sortByExecutionOrder(modules);
const chunk = new Chunk(
modules,
this.inputOptions,
this.outputOptions,
this.unsetOptions,
this.pluginDriver,
this.graph.modulesById,
chunkByModule,
externalChunkByModule,
this.facadeChunkByModule,
this.includedNamespaces,
alias,
getHashPlaceholder,
bundle,
inputBase,
snippets
);
chunks.push(chunk);
}
// 省略其他逻辑
}
// 省略其他逻辑
}可以看到 tree-sharking 的执行时机是在 module.render(renderOptions) 函数中。再深入研究一下 module.render(renderOptions) 函数的逻辑。
class Module {
render(options: RenderOptions): {
source: MagicString;
usesTopLevelAwait: boolean;
} {
const source = this.magicString.clone();
this.ast!.render(source, options);
source.trim();
const { usesTopLevelAwait } = this.astContext;
if (
usesTopLevelAwait &&
options.format !== 'es' &&
options.format !== 'system'
) {
return error(logInvalidFormatForTopLevelAwait(this.id, options.format));
}
return { source, usesTopLevelAwait };
}
}从这里可以了解到 Rollup 中的 TLA 语法并不支持非 ESM 和 system 的输出格式。关于 TLA 语法的具体实现方案可以参考 TLA 的详细讲解与实现。
除此之外 module.render(renderOptions) 函数中会调用 module.ast.render(source, options) 方法,从而执行 tree-sharking 操作。
this.ast!.render(source, options) 方法的逻辑如下:
class Program {
render(code: MagicString, options: RenderOptions): void {
let start = this.start;
if (code.original.startsWith('#!')) {
start = Math.min(code.original.indexOf('\n') + 1, this.end);
code.remove(0, start);
}
if (this.body.length > 0) {
// Keep all consecutive lines that start with a comment
while (
code.original[start] === '/' &&
/[*/]/.test(code.original[start + 1])
) {
const firstLineBreak = findFirstLineBreakOutsideComment(
code.original.slice(start, this.body[0].start)
);
if (firstLineBreak[0] === -1) {
break;
}
start += firstLineBreak[1];
}
renderStatementList(this.body, code, start, this.end, options);
} else {
super.render(code, options);
}
}
}逻辑很简单,依次 render 当前模块对应的 Ast 的 body 节点,通过 renderStatementList 方法来渲染每一条语句。换句话说 renderStatementList 方法就是 tree-sharking 的核心逻辑所在。
function renderStatementList(
statements: readonly StatementNode[],
code: MagicString,
start: number,
end: number,
options: RenderOptions
): void {
let currentNode, currentNodeStart, currentNodeNeedsBoundaries, nextNodeStart;
let nextNode = statements[0];
let nextNodeNeedsBoundaries = !nextNode.included || nextNode.needsBoundaries;
if (nextNodeNeedsBoundaries) {
nextNodeStart =
start +
findFirstLineBreakOutsideComment(
code.original.slice(start, nextNode.start)
)[1];
}
for (let nextIndex = 1; nextIndex <= statements.length; nextIndex++) {
currentNode = nextNode;
currentNodeStart = nextNodeStart;
currentNodeNeedsBoundaries = nextNodeNeedsBoundaries;
nextNode = statements[nextIndex];
nextNodeNeedsBoundaries =
nextNode === undefined
? false
: !nextNode.included || nextNode.needsBoundaries;
if (currentNodeNeedsBoundaries || nextNodeNeedsBoundaries) {
nextNodeStart =
currentNode.end +
findFirstLineBreakOutsideComment(
code.original.slice(
currentNode.end,
nextNode === undefined ? end : nextNode.start
)
)[1];
if (currentNode.included) {
if (currentNodeNeedsBoundaries) {
currentNode.render(code, options, {
end: nextNodeStart,
start: currentNodeStart
});
} else {
currentNode.render(code, options);
}
} else {
treeshakeNode(currentNode, code, currentNodeStart!, nextNodeStart);
}
} else {
currentNode.render(code, options);
}
}
}renderStatementList 方法中会根据 statement 的 Ast Node 是否需要(node.included) 来决定是否调用 node.render 方法还是 treeshakeNode 方法。
若节点不需要包裹在最终产物中(即
node.included = false)那么就会通过
treeshakeNode方法来执行删除这块代码的逻辑操作。tsfunction treeshakeNode( node: Node, code: MagicString, start: number, end: number ): void { code.remove(start, end); node.removeAnnotations(code); }若节点需要包裹在最终产物中(即
node.included = true)那么就会通过
node.render方法递归 Ast 树并做细节处理。逻辑主要包含对于源代码的细节调整,比如替换变量名、删除未使用的代码块(递归调用renderStatementList)、去除所需的export ast节点对应的源码的export关键字、句末添加分号等。