Tree Shaking
Related Configuration
从官方提供的 文档可知,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 Generation
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。
Preparation
AST Context
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
结构来收集模块信息。
Collect Module Info
Dynamic Dependencies
当
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 }); } }
Static Dependencies
与上述一样,当
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 Statements
export
涉及到的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
导出的变量。若出现重复声明引用,则会抛出错误,并终止打包。
Summary
在实例化
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
阶段起作用。
Determine Ast Node Scope
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
做准备。
Creating Scope
前面提到的是在顶层语法中创建的作用域,他们所属的是 ModuleScope
(模块作用域)。前面也有提及到,作用域不仅仅只有 ModuleScope
、GlobalScope
,还有 FunctionScope
、ParameterScope
、CatchBodyScope
、FunctionBodyScope
等。
Function Scope
当遇到函数声明语句时,会在 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
树的过程中,就可以检测到函数体中变量声明的重复问题。
Instantiate 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
,来看看是如何实现的吧。
tree-sharking
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
关键字、句末添加分号等。