Tree Shaking
Related Configuration
According to the official documentation, the main tree-shaking configuration options in rollup are as follows:
Annotations
The default value is true, indicating that rollup will determine whether to keep certain code based on annotations. The annotation format is @__PURE__, #__PURE__ or @__NO_SIDE_EFFECTS__, #__NO_SIDE_EFFECTS__. rollup will determine whether to keep certain code based on these annotations.
The
@__PURE__or#__PURE__comment marks specific function calls or constructor calls as side-effect free. This meansrollupwill performtree-shakingoptimization, i.e., remove the calls unless the return value is used in some code that is nottree-shakingoptimized. These annotations need to be placed immediately before the call to take effect. The following code will be completely tree-shaken unless this option is set tofalse.js/*@__PURE__*/ console.log('side-effect'); class Impure { constructor() { console.log('side-effect'); } } /*@__PURE__ There may be additional text in the comment */ new Impure();The
@__NO_SIDE_EFFECTS__or#__NO_SIDE_EFFECTS__comment marks the function declaration itself as side-effect free. When a function is marked as having no side effects, all calls to that function will be considered side-effect free. The following code will be completely tree-shaken.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
Implementation of annotations in rollup
rollup internally implements an ast class node for each node. After parsing the ast generated by native swc, it recursively instantiates each required ast class node based on the generated ast structure. The instantiated ast tree is what rollup operates on in subsequent steps, meaning all subsequent operations are based on the instantiated ast tree.
export default class Module {
async setSource({
ast,
code,
customTransformCache,
originalCode,
originalSourcemap,
resolvedIds,
sourcemapChain,
transformDependencies,
transformFiles,
...moduleOptions
}: TransformModuleJSON & {
resolvedIds?: ResolvedIdMap;
transformFiles?: EmittedFile[] | undefined;
}): Promise<void> {
// Omitted code
this.ast = new nodeConstructors[ast.type](
programParent,
this.scope
).parseNode(ast) as Program;
// Omitted code
}
}It's important to note that all ast node class inherit from NodeBase. In the constructor of ast node class, parseNode is called to further parse ast children node class. At this point, super.parseNode(esTreeNode); is called to invoke the parseNode method of NodeBase.
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[];
}
}
}
}
}At this point, it collects the comments before the ast node and assigns them to this.annotations.
compat estree ast
It's worth noting that standard estree ast itself does not include comments.
rollup uses swc's capabilities in the rust layer to ultimately generate a compat estree ast. This ast structure contains all the information required by estree ast and extends some properties, such as _rollupAnnotations, which includes comment node information before statements. For details, refer to Optimize Ast Compatibility.
Manual Pure Functions
The default value is true, indicating that rollup will determine whether to keep certain code based on manually set pure functions.
Module Side Effects
Plays an important role in tree shaking. When set to false, tree shaking will be performed on unreferenced other modules and external dependency modules.
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);If an external or internal dependency module with side effects is specified, and the dependency module's reference is not used, it is equivalent to resolving to:
import { a } from 'external-a';
// ==>
import 'external-b';TIP
CSS modules are typically loaded using:
import './style.css';If style.css is specified as having no side effects, the style.css module will be removed during tree-shaking, which is not reasonable.
Therefore, style.css is typically specified as having side effects to ensure that the side effects of the style.css module are preserved during tree-shaking.
If the reference variable of the dependency module is used, whether to scan for side effects in re-exported modules depends on how the variable is re-exported.
// Input file a.js
import { foo } from './b.js';
import { demo } from 'demo';
console.log(foo, demo);
// Input file b.js
// Direct re-export will ignore side effects
export { foo } from './c.js';
console.log('this side-effect is ignored');
// Input file c.js
// Non-direct re-export will include side effects
import { foo } from './d.js';
foo.mutated = true;
console.log('this side-effect and the mutation are retained');
export { foo };
// Input file d.js
console.log('d.js');
export const foo = 42;This means that for directly re-exported modules that are specified as having no side effects, rollup will ignore their side effects, while for non-directly re-exported modules, rollup will preserve the module's side effects, even if the module is specified as having no side effects. This is different from the default value of moduleSideEffects being true, which preserves side effects of all reachable modules.
// Output when 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);
// Output when 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
The moduleSideEffects configuration can be specified to rollup in two ways:
- Plugin hooks (
resolveId,load,transform): Specify side effects for local modules, with the highest priority, can override thetreeshake.moduleSideEffectsconfiguration. - Through the
treeshake.moduleSideEffectsconfiguration: Specify side effects for global modules, with the lowest priority. By default,treeshake.moduleSideEffects = true, meaningrollupassumes all modules have side effects.
moduleSideEffects Notes
moduleSideEffects is essentially equivalent to the sideEffects field in package.json, but rollup itself does not respect the sideEffects field in package.json, see: rollup/rollup#2593.
The official @rollup/plugin-node-resolve plugin respects the sideEffects field in package.json, but this is essentially classified as side effects provided by the plugin.
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
The moduleSideEffects configuration is common and affects side effects at the module level. Since rollup assumes by default that all top-level statements of all modules contain side effects, the moduleSideEffects configuration essentially tells rollup whether to assume that the top-level statements of specified modules have side effects.
Entry modules all have side effects and cannot be overridden by the sideEffects configuration, so the side effects of entry modules themselves will always be included in the final output.
When a module has no side effects, for rollup, it adopts a lazy evaluation optimization strategy, meaning it will only analyze and include the module's own side effects when the reference of the side-effect-free module is actually used. In detail, during the first tree-shaking, rollup will not include side effects of modules specified as having no side effects, only including side effects of modules specified as having side effects. When analyzing side effects of side-effect modules, if the side-effect statements depend on reference variables from modules specified as having no side effects, it determines that the side-effect-free module's side effects and related statements dependent on the side-effect module's references also have side effects, and activates side effect detection for the side-effect-free module. During the second tree-shaking, it will include side effects of the side-effect-free module and related statements dependent on the references.
When a module has side effects, even if the references exposed by the module with side effects are not actually used, it will further analyze and include its own side effects.
Property Read Side Effects
The default value is true
Indicates that rollup will determine whether to keep certain code based on property read side effects.
Try Catch Deoptimization
The default value is true
For workflows that rely on error throwing for feature detection, rollup will disable tree-shaking optimization in try statements by default, meaning rollup will keep all code in try statements. If function parameters are used in try statements, those parameters will not be optimized either.
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);After tree-shaking optimization
function a(d, w) {
w = w + 1;
try {
const b = 1;
const c = w;
} catch (_) {}
}
const param1 = 1;
const param2 = 2;
a(param1, param2);In the above example, in function a, since parameter w is used in the try statement, rollup determines that statements related to w have side effects, while parameter d is used outside the try statement, so rollup will optimize away the side effects of parameter d.
AST Generation
rollup uses swc's capabilities in the rust side to generate babel ast, then translates it to compat ast, and finally passes the compat ast to the javascript side through an arraybuffer structure. For implementation details, refer to Native Parser.
rollup internally implements a class for each ast node. After obtaining the estree ast, rollup recursively instantiates each ast node class based on the estree ast structure, ultimately generating a complete ast instance. Subsequent tree-shaking operations are performed on the ast instance.
After the build is complete, it will cache the estree ast. In watch mode, by using the cache to reuse the estree ast, it skips communication with the rust side, thereby improving incremental build performance.

For changes related to switching from acorn parser to swc parser, refer to Native Parser.
Preparation
AST Context
rollup will instantiate ast for each module and also provide an astContext object to help rollup collect module information.
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 will recursively instantiate ast node classes based on the obtained compat estree ast structure, ultimately generating an ast instance tree. During this process, rollup will call methods in the astContext object to collect information about the current module.
For example:
- Collect module's
dynamic importinformation through theaddDynamicImportmethod in the context object. - Collect
importinformation through theaddImportmethod in the context object. - Collect
exportandreexportsinformation through theaddExportmethod in the context object. - Collect
import.metainformation through theaddImportMetamethod in the context object. - Determine whether the current module uses top-level
awaitand other information by checking theaststructure.
Let's look at how module information is collected through the compat estree ast structure.
Collect Module Info
Dynamic DependenciesWhen the
initialisemethod is executed in theImportExpressioninstance, it means that childast nodeclasses have all been instantiated.jsclass ImportExpression extends NodeBase { initialise() { super.initialise(); this.scope.context.addDynamicImport(this); } parseNode(esTreeNode: GenericEsTreeNode): this { this.sourceAstNode = esTreeNode.source; return super.parseNode(esTreeNode); } }Call the
addDynamicImportmethod provided by theastcontext to store theImportExpressionnode information in thedynamicImportsproperty of the currentModuleinstance.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 DependenciesSame as above, when all child nodes of
ImportDeclarationare instantiated, theinitialisemethod of theImportDeclarationinstance will be called.jsclass ImportDeclaration extends NodeBase { initialise(): void { super.initialise(); this.scope.context.addImport(this); } }It will call the
addImportmethod to store theImportDeclarationnode information in theimportDescriptionsproperty of the currentModuleinstance.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 }); } } }Since there are multiple ways to import, including default imports, named imports, and namespace imports, their
ast nodeare different, so the storage method inimportDescriptionsneeds to be specifically distinguished.The
ast nodefor default imports is theImportDefaultSpecifiernode.jsimport demo from 'demo.js'; module.importDescriptions.set('demo', { module: null, name: 'default', source: 'demo.js', start: 7 });Use
defaultas the identifier inimportDescriptions.The
ast nodefor named imports is theImportSpecifiernode.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 });Store
imported.nameorimported.valueinimportDescriptionsThe
ast nodefor namespace imports is theImportNamespaceSpecifiernode.jsimport * as demo from 'demo.js'; module.importDescriptions.set('demo', { module: null, name: '*', source: 'demo.js', start: 36 });Use
*as the identifier inimportDescriptions.
Export Statement And Reexports Statementsexportinvolves the following three types ofast node- Default export (
ExportDefaultDeclaration) - Named export (
ExportNamedDeclaration) - Namespace export (
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';The three types of
export ast nodewill call theaddExportmethod during initialization to add the current module's export information to theexports,reexportDescriptions, andexportAllSourcesproperties of theModuleinstance.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 }); } } } }It can be seen that for
reexport,rollupspecifically distinguishes it in theModuleinstance through thereexportDescriptionsandexportAllSourcesproperties.The abstract parsing results for the above
export-statement.jsexample are as follows: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
rollupperforms syntax analysis here. The reason for this can be found in theOptimize Syntax Analysisarticle. Bothimportandexportcheck for duplicate variable names.exportuses theassertUniqueExportNamemethod to detect duplicate variable names.tsfunction assertUniqueExportName(name: string, nodeStart: number) { if (this.exports.has(name) || this.reexportDescriptions.has(name)) { this.error(logDuplicateExportError(name), nodeStart); } }It checks whether the exported variable already exists in the
exportsorreexportDescriptionsproperties of theModuleinstance to determine if it's a duplicate declaration.importchecks inaddImportwhether the imported reference variable exists in scope-declared variables (scope.variables) or theimportDescriptionsproperty of theModuleinstance to determine if it's a duplicate declaration.tsif ( this.scope.variables.has(localName) || this.importDescriptions.has(localName) ) { this.error(logRedeclarationError(localName), specifier.local.start); }This means that
rollupdoes not allow duplicate variable declarations in the same module, including top-level scope declared variables andimportexported variables.If duplicate reference declarations occur, it will throw an error and terminate the build.
Summary
The information recorded about
importandexportnodes duringastinstantiation is essentially the mapping relationship between thelocalNameused in the current module's scope and thelocalNameexported in the dependent module's scope.Whether it's
importorexport, the dependent modules are not filled in during the collection process, for the simple reason that the corresponding dependent modules have not started loading at this point, so they cannot be obtained.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 });When the child dependency module is loaded, the
linkImportsmethod will be called, and theaddModulesToImportDescriptionsmethod will be used to fill the child dependency module's references into theimportermodule'simportDescriptionsandreexportDescriptions.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); } }When the
linkImportsmethod is executed, all dependency modules of the current module have been resolved, so the references to all dependency modules of the current module can be obtained inthis.graph.modulesById.At this point, all dependent module references corresponding to the
importDescriptionsandreexportDescriptionsproperties of the current module can be filled in. Thesourcestored inexportAllSourcesalso finds the corresponding dependent module throughthis.graph.modulesByIdand adds it tothis.exportAllModules.Through layer-by-layer backtracking and binding collection, all dependent modules that were not filled in during the
astinstantiation phase have been completely filled in.- Default export (
Import MetaThe
ast nodenode related toimport.metaisMetaProperty. This is a declarative node, and since it's impossible to determine whetherimport.metanode information is needed during theastinstantiation phase.Therefore, node information is not processed during
astinstantiation, but rather collected through theaddImportMetamethod after confirming thatimport.metanodes need to be included during thetree shakingphase.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); } }TLAThis is to mark whether the current module contains
Top Level Await. The implementation ofTop Level Awaitin differentbundlershas been explained in detail in Detailed Explanation And Implementation Of TLA, so it won't be repeated here.The
ast noderelated toTop Level AwaitisAwaitExpression.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); } }Like the
import.metanode,AwaitExpressionnode information is also collected through theaddAwaitExpressionmethod after confirming thatAwaitExpressionnodes need to be included during thetree shakingphase.The way to determine whether a module contains
AwaitExpressionnodes is simple: traverse up to the parent level until encountering aFunctionNodeorArrowFunctionExpressionnode. If none are encountered, it means the current module hasTop Level Await, and theusesTopLevelAwaitproperty of the current module instance will be marked astrue.js// usesTopLevelAwait: true await 1; // usesTopLevelAwait: false (async () => { await 1; })(); // usesTopLevelAwait: false (function () { await 1; })();
All this information is collected during the ast instantiation phase. The includeAllExports, includeDynamicImport, and includeVariableInModule methods come into play during the tree shaking phase.
Determine Ast Node Scope
rollup will create a scope for each ast node node. Scopes are divided into global scope, function scope, module scope, parameter scope, catch scope, and with scope.
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 creates a ModuleScope instance (i.e., module scope) for the module, inheriting from the GlobalScope instance (i.e., global scope, which is the value of this.graph.scope).
During initialization, the this variable is added to the ModuleScope instance as the first variable in the current module scope.
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'
)
);
}
}Next, we will use an example to explain how scope plays a role in the AST instantiation process.
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);Since all AST nodes inherit from the NodeBase class, instantiating an AST node will trigger the execution of the NodeBase constructor, creating a scope for the current AST node.
class NodeBase extends ExpressionEntity {
constructor(parent, parentScope) {
super();
this.parent = parent;
this.scope = parentScope;
this.createScope(parentScope);
}
createScope(parentScope) {
this.scope = parentScope;
}
}Let's take the statement const localVariable = 123; as an example. After the VariableDeclaration node is initialized (meaning all AST nodes within the VariableDeclaration node have been initialized), the initialise method of the VariableDeclaration node will be called.
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);
}
}
}In the initialise method of the VariableDeclaration node, a scope is created for the current VariableDeclaration node via the super.initialise() method.
Iterate through the declarations array of the VariableDeclaration node (each declared variable, for example, in const a = 1, b = 2;, the declarations array contains two VariableDeclarator nodes), and call the declareDeclarator method of the VariableDeclarator node.
class VariableDeclarator extends NodeBase {
declareDeclarator(kind, isUsingDeclaration) {
this.isUsingDeclaration = isUsingDeclaration;
this.id.declare(kind, this.init || UNDEFINED_EXPRESSION);
}
}In the declareDeclarator method, the declare method of the Identifier node (this.id) will be triggered, which in turn adds the Identifier node to scope.variables via the this.scope.addDeclaration method.
Since a scope was created in the VariableDeclaration node and no new scope was generated during the instantiation of the Identifier node, the scope of the Identifier node is consistent with the scope of the VariableDeclaration node, meaning the scope of the Identifier node is 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)];
}
}So, for the statement const localVariable = 123;, the localVariable variable will eventually be added to the scope.variables of ModuleScope.
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);
}
}Since the module scope contains already declared variables, it is easy to perform syntax checks for duplicate declarations.
If a variable declared in the current module scope conflicts with an imported variable declaration, a duplicate declaration exception is thrown.
If a variable not declared with the var keyword (e.g., let, const, etc.) is re-declared, an exception is thrown. After the check passes, the LocalVariable class is instantiated based on the Identifier's AST node information, and the instantiated variable is added to the current scope's scope.variables, preparing for subsequent syntax analysis and tree shaking.
Creating Scope
Previously, we discussed scopes created in top-level syntax, which belong to ModuleScope (module scope). It has also been mentioned that scopes are not limited to ModuleScope and GlobalScope; there are also FunctionScope, ParameterScope, CatchBodyScope, FunctionBodyScope, etc.
Function Scope
When a function declaration statement is encountered, the createScope method is called within the FunctionNode to create a new scope for the current FunctionNode.
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))
);
}
}In FunctionScope, a new FunctionScope that inherits from ModuleScope is created for the current function node.
In the new FunctionScope, this and arguments variables are created as initial variables for the current scope. Simultaneously, the new function scope is collected for the parent ModuleScope and stored in the children array of the parent ModuleScope.
class ChildScope extends Scope {
constructor(parent, context) {
super();
this.parent = parent;
this.context = context;
this.accessedOutsideVariables = new Map();
parent.children.push(this);
}
}WARNING
Variables declared within a function scope are not added to FunctionScope's scope.variables, but rather to FunctionScope.bodyScope's 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);
}So, where does bodyScope come from?
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 {}It can be seen that when creating a function scope, a FunctionBodyScope is created as the bodyScope of the current function scope, and the parent of FunctionBodyScope is FunctionScope. Subsequently, variables declared in the function body will all be added to FunctionBodyScope's scope.variables.
Parameters of a function expression are added to FunctionScope's scope.variables via the scope.addParameterVariables method.
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);
}Simultaneously, parameters are also added to the hoistedVariables of the function body scope (bodyScope, where bodyScope.parent is FunctionScope) under the function scope (FunctionScope), serving as hoisted variables.
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;
}
}The purpose of hoistedVariables is to detect conflicts between identifiers and parameter variable declarations within the function body.
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;
}
}It can be seen that if a variable declared with a keyword other than var or function has the same name as a parameter variable, an exception is thrown: Identifier xxx has already been declared. Therefore, during the AST instantiation process, duplicate variable declarations in the function body can be detected.
Instantiate AST
As mentioned above, module.ast is an instance of an AST node class implemented by Rollup, with a structure compatible with ESTree AST. Subsequent tree shaking operations are all performed on the module.ast instance.
So, the core question is: how does Rollup instantiate its implemented AST node class instances based on the compat ESTree AST structure?
Rollup internally implements a corresponding AST node class for each AST node type, and uses nodeConstructors to instantiate the corresponding AST node class.
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;
}
}Each AST node class is instantiated recursively via DFS based on the compat ESTree AST structure.
AST Node Operations
getVariableForExportName
WARNING
During the generateModuleGraph phase, Module instantiation is performed, and the corresponding AST tree is parsed from the source code. It is important to note that at this point, the dependency modules of the module have not yet been instantiated. Therefore, references to child dependency modules cannot be obtained during AST tree parsing. As a result, the imported and re-exported variables collected in importDescriptions and reexportDescriptions do not have their dependency module references filled and are in an unfilled state.
Once all dependency modules have been instantiated, the module.linkImports() method is executed to fill in the dependency module references for importDescriptions and 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);
}
}During the tree-shaking phase, the module references for dependency items in importDescriptions and reexportDescriptions have already been filled. Therefore, in the tree-shaking phase, the module reference of a dependency can be obtained as follows.
// 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;Let's continue to analyze the implementation of the getVariableForExportName method.
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];
}
}It can be seen that the implementation differs slightly for acquiring different types of exports. The following sections will introduce the different implementation methods for obtaining Rollup AST Nodes for various export types.
Retrieving the
Rollup ASTNode for Re-exported VariablesIt can be seen that acquiring the
Rollup ASTvariable declaration node for re-exports involves calling thegetVariableForEportNameRecursivemethod.tsconst reexportDeclaration = this.reexportDescriptions.get(name); if (reexportDeclaration) { const [variable] = getVariableForExportNameRecursive( // Re-exported dependency module reexportDeclaration.module, reexportDeclaration.localName, importerForSideEffects, false, searchedNamesAndModules ); // Other logic omitted }Inside the
getVariableForExportNameRecursivemethod, thegetVariableForExportNamemethod of the re-exported dependency module is called recursively to search if the target variable is declared in the dependency module. If not, the recursive call continues until theRollup AST Nodeof the target variable is found.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 }); }Retrieving the
Rollup ASTNode for Exported VariablesTo get the
Rollup AST Nodefor anexported variable: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] { // Other logic omitted 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]; } } // Other logic omitted }It can be seen that the
traceVariablemethod is used to get the exported variable declared in the target module.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; } }However, two special cases might need consideration:
The exported variable is declared in the current module.
js// target.js export const a = 1;This is a recursive exit point. For this case, the
Rollup AST Nodedeclared in the current scope can be obtained directly viathis.scope.variables.get(name).jsconst localVariable = this.scope.variables.get(name); if (localVariable) { return localVariable; }The exported variable is declared in a dependency module.
js// target.js import { a } from './other'; export { a };This is essentially the same as a re-export. Therefore, the
getVariableForExportNameRecursivemethod is used to recursively search other modules to obtain the variable declaration contained in the target module's scope.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; }
Example
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;If the
getVariableForExportNamemethod is used inmain.jsto get theRollup AST Nodefor variablea, it will recursively callgetVariableForExportNameRecursiveuntil variableais found. This means it will ultimately obtain theRollup AST Nodefor variableadeclared in theother-next-nextmodule, and themoduleproperty of thisRollup AST Nodewill be a reference to theother-next-nextmodule.:::
Determining if Statements Have Side Effects
INFO
Each module contains a moduleSideEffects property (module.info.moduleSideEffects). This property is used to mark whether the current module has side effects, thereby influencing Rollup's tree-shaking behavior for the module.
interface ModuleOptions {
attributes: Record<string, string>;
meta: CustomPluginOptions;
moduleSideEffects: boolean | 'no-treeshake';
syntheticNamedExports: boolean | string;
}**The `moduleSideEffects` value can be one of the following three:**
1. `moduleSideEffects = true`
Indicates that the module has side effects. This is Rollup's default behavior; `Rollup` assumes all modules have side effects.
```ts{8-10,25-26}
class 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
};
}
}
```
When executing Rollup's `resolveId` plugin hook, the `moduleSideEffects` property returned by the hook can be used to specify whether a module has side effects, thereby influencing Rollup's `tree-shaking` behavior for the module. If the hook does not return the `moduleSideEffects` property, the module is assumed to have side effects by default (i.e., `moduleSideEffects = true`). Of course, the `options.treeshake.moduleSideEffects` property can also be used to specify this default behavior.
During the `tree-shaking` phase, `tree-shaking` operations are performed based on whether `statements` have side effects (`hasEffects`).
```ts{4}
class Module {
include(): void {
const context = createInclusionContext();
if (this.ast!.shouldBeIncluded(context)) this.ast!.include(context, false);
}
}
```
2. `moduleSideEffects = false`: Indicates that the module has no side effects. The `tree-shaking` phase will directly remove this module.
3. `moduleSideEffects = 'no-treeshake'`: Indicates that this module does not require `tree-shaking`. The content of this module and all its dependencies will be fully preserved.
```ts{12}
class Graph {
private includeStatements(): void {
// 省略上方逻辑 (Logic above omitted)
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);
}
// 省略下方逻辑 (Logic below omitted)
}
}
```
It can be seen that if a module needs to be executed and its `moduleSideEffects` property is set to `'no-treeshake'`, Rollup will preserve all content in that module by executing the `module.includeAllInBundle()` method. A detailed explanation of `includeAllInBundle` can be found below.
The `moduleSideEffects` property value can also be specified by the plugin's **`load`** and **`transform`** hooks to indicate whether the current module has side effects. After the plugin execution, Rollup will update the module's `moduleSideEffects` property via the `updateOptions` method.
```ts{7-9}
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);
}
}
}
```After completing Generate Module Graph and Sort Module Execution Order, the process officially enters the tree-shaking phase. The specific logic is implemented in the includeStatements method.
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 uses the user's configuration (i.e., options.input) and implicitlyLoadedBefore as entry points. It traverses all dependencies of each entry module using a BFS algorithm. The traversed dependency modules need to be executed (i.e., 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);
}
// 省略下方逻辑 (Logic below omitted)
}
}After marking which modules need to be executed, the process officially enters the tree-shaking phase.
class Graph {
private includeStatements(): void {
// 省略上方逻辑 (Logic above omitted)
if (this.options.treeshake) {
// 省略 tree-sharking 逻辑 (Tree-shaking logic omitted)
} else {
for (const module of this.modules) module.includeAllInBundle();
}
// 省略下方逻辑 (Logic below omitted)
}
}Based on the user's configuration (i.e., options.treeshake), it is determined whether to enable tree-shaking. If tree-shaking is not required, the includeAllInBundle method is executed for all modules. Let's first look at the logic of the includeAllInBundle method.
class Module {
includeAllInBundle(): void {
this.ast!.include(createInclusionContext(), true);
this.includeAllExports(false);
}
}The includeAllInBundle method does two things. Before that, one thing needs to be clarified: if an AST Node is confirmed to be included in the final output, node.include will be executed. In the subsequent ast.render phase, these not included AST nodes will be skipped, thus achieving the tree-shaking effect.
Call the
ast.includemethod to mark the current module'sASTnode as included.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); } } } }The second parameter of
ast.include,includeChildrenRecursively, being marked astruemeans that when recursing through the module'sASTnodes, all descendantAST Nodes will be recursively included (confirming execution ofnode.include). If this flag is not present, whether anAST Nodeneeds to be included in the final code is determined by whether theAST Nodehas side effects.jsclass NodeBase { shouldBeIncluded(context: InclusionContext): boolean { return this.included || (!context.brokenFlow && this.hasEffects(createHasEffectsContext())); } }Call the
includeAllExportsmethod.
The includeAllExports method also primarily does two things:
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()
);
}
}
}- If the module is not marked as executed, mark the current module and its dependent modules as executed (i.e.,
module.isExecuted = true). - Iterate through the current module's
exportsandreexports, marking theAST Nodes corresponding to all exported variables and re-exported variables as included (i.e.,variable.included = true).
An interesting point here is the use of getVariableForExportName to obtain the AST Nodes corresponding to exported and re-exported variables. Let's see how it is implemented.
AST Node Side-Effect Determination System
In the includeStatements flow described above, the core method for determining whether a statement should be retained in the final output is shouldBeIncluded:
class NodeBase {
shouldBeIncluded(context: InclusionContext): boolean {
return this.included || (!context.brokenFlow && this.hasEffects(createHasEffectsContext()));
}
}hasEffects() is a recursive descent analysis process. The default implementation recursively checks all child nodes:
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 overrides the hasEffects() method for different AST node types, implementing fine-grained side-effect determination. The following analysis categorizes them into three groups based on the certainty of their determination results.
Nodes with Constant Side Effects
The following node types have hasEffects() that always returns true, and are retained regardless of context.
ThrowStatement
// src/ast/nodes/ThrowStatement.ts
hasEffects(): boolean {
return true;
}A throw statement alters the program's control flow and constitutes a non-eliminable side effect.
throw new Error('x'); // Always retainedDebuggerStatement
// src/ast/nodes/DebuggerStatement.ts
hasEffects(): boolean {
return true;
}A debugger statement interrupts program execution.
// Input
debugger;
const unused = 1;
// Rollup Output
debugger;
// unused is removed, but debugger is always retainedAwaitExpression
// src/ast/nodes/AwaitExpression.ts
hasEffects(): boolean {
return true;
}await pauses execution and triggers microtask scheduling. rollup conservatively considers it to always have side effects.
// Input
export async function main() {
await Promise.resolve();
const unused = 42;
return 1;
}
// Rollup Output
async function main() {
await Promise.resolve();
return 1;
}
// The await expression is always retained, unused is removed
export { main };ImportExpression (Dynamic import)
// src/ast/nodes/ImportExpression.ts
hasEffects(): boolean {
return true;
}import() triggers module loading (network requests or file I/O), which constitutes a runtime side effect.
// Input
const unused = import('./module');
// Rollup Output
import('./module');
// Even though unused is never used, the import() call is retained (side effect)
// But the assignment target unused is removedForOfStatement
// src/ast/nodes/ForOfStatement.ts
hasEffects(): boolean {
if (!this.deoptimized) this.applyDeoptimizations();
// Placeholder until proper Symbol.Iterator support
return true;
}for...of invokes the Symbol.iterator protocol. rollup has not yet implemented precise tracking of the iterator protocol, so it conservatively marks it as having side effects. The comment in the source code also explicitly states that this is a temporary placeholder implementation.
// Input
const arr = [1, 2, 3];
for (const item of arr) {
// Empty loop body
}
const unused = 1;
// Rollup Output
const arr = [1, 2, 3];
for (const item of arr) {
}
// for...of is always retained (may invoke a custom Symbol.iterator)
// unused is removedUnknownNode
// src/ast/nodes/UnknownNode.ts
hasEffects(): boolean {
return true;
}An unrecognized node type, handled conservatively to ensure correctness.
Nodes with No Side Effects
The following node types have hasEffects() that always returns false and never produce any side effects.
ImportDeclaration
// src/ast/nodes/ImportDeclaration.ts
hasEffects(): boolean {
return false;
}An import declaration itself does not produce side effects. A module's side effects are handled separately at the module level through moduleSideEffects.
import { foo } from './bar'; // Always side-effect-freeExportAllDeclaration
// src/ast/nodes/ExportAllDeclaration.ts
hasEffects(): boolean {
return false;
}export * from './foo' is a purely declarative statement that produces no runtime behavior.
export * from './utils';
// The declaration itself has no side effects; whether it is retained depends on whether the utils module has any used exportsArrowFunctionExpression
// src/ast/nodes/ArrowFunctionExpression.ts
hasEffects(): boolean {
return false;
}The declaration of an arrow function itself has no side effects; side effects may only occur when it is invoked. This is a critical distinction — the side-effect determination for function declarations and function calls is handled separately.
const fn = () => console.log('hi'); // Declaration has no side effects
fn(); // Invocation has side effects (depends on callee analysis)EmptyStatement, TemplateElement, MetaProperty
These nodes produce no runtime behavior and always return false.
Conditional Side-Effect Nodes
The side-effect determination for the following node types depends on their child nodes, context configuration, or runtime semantics. They represent the core of rollup's tree-shaking precision.
CallExpression — Function Calls
Function calls are the most common source of side effects. rollup's determination logic involves four layers:
// 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
)
);
}Priority of determination:
- Argument side effects: Each argument expression is checked for side effects first. For example, in
foo(bar()), ifbar()has side effects, the entire call is considered to have side effects. #__PURE__annotation: If the call is marked with/*#__PURE__*/, subsequent checks are skipped, and the call is directly considered side-effect-free.calleeown side effects: Checks whether thecalleeexpression itself has side effects (e.g., computed property access).- Call interaction side effects: Recursively analyzes whether the function body has side effects via
hasEffectsOnInteractionAtPath.
Example 1 — #__PURE__ annotation:
const result = /*#__PURE__*/ createApp();
// annotationPure = true → 无副作用 → 若 result 未被使用,整个调用可移除Example 2 — Function body known to be side-effect-free:
function add(a, b) { return a + b; }
add(1, 2);
// hasEffectsOnInteractionAtPath 分析函数体 → return a + b 无副作用 → 整个调用无副作用Example 3 — Unknown function:
const fn = getHandler();
fn();
// fn 的值无法静态确定 → 保守认为有副作用The corresponding #__PURE__ annotation collection logic is in 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 — Side Effects When a Function Is Called
When a CallExpression's callee is a traceable function, rollup performs deep analysis of the function body to determine whether the call has side effects:
// 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;
}There are several noteworthy aspects in this logic:
- Implicit side effects of
asyncfunctions:asyncfunctions return aPromise, androllupchecks whether the.thenaccess or call on the return value has side effects. If the return value has a customthenmethod (a thenable object), it is considered to have side effects. - Parameter destructuring side effects: If a parameter contains a destructuring pattern (e.g.,
function({ a })), andpropertyReadSideEffectsistrue,rollupchecks whether the destructuring operation might trigger a getter. - Caching mechanism:
hasCachedEffectscaches the side-effect determination result, avoiding repeated analysis for functions already confirmed to have side effects.
Example 1 — Pure function call can be eliminated:
// 输入
function add(a, b) { return a + b; }
const unused = add(1, 2);
// Rollup 输出
// (空,整段代码被移除)
// add 函数体只有 return a + b,无副作用
// unused 未被使用 → add(1, 2) 调用也可移除Example 2 — async function returning a thenable object:
// 输入
async function fetchData() {
return { then() { console.log('thenable'); } };
}
fetchData();
// Rollup 输出
async function fetchData() {
return { then() { console.log('thenable'); } };
}
fetchData();
// 返回值具有 then 方法 → Promise 决议时会调用 → 有副作用 → 保留Example 3 — Parameter destructuring triggering a 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 — Assignment Expressions
// 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) // ④ 解构有副作用
);
}Example 1 — Simple assignment with no side effects:
let x;
x = 42; // right(42) 无副作用,left(x) 是本地变量 → 无副作用Example 2 — Object property assignment has side effects:
obj.foo = bar; // left 是 MemberExpression → 可能触发 setter → 有副作用Example 3 — Compound assignment operators:
x += getValue();
// operator !== '=' → hasEffectsAsAssignmentTarget 的 checkAccess = true
// 需要先读取 x 的值(可能触发 getter)→ 有副作用Example 4 — const reassignment:
// 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 detects const variable reassignment during the initialization phase, marking it as isConstReassignment = true. Since this will inevitably throw a TypeError at runtime, it is always considered to have side effects.
MemberExpression — Property Access
// 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) // ③ 属性访问有副作用
);
}The hasAccessEffect logic is the core:
// 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
))
);
}Key configuration option: treeshake.propertyReadSideEffects
- If
variableis already bound (e.g., a namespace import), the property access side-effect check is skipped. - If
propertyReadSideEffectsisfalse, all property reads are assumed to be side-effect-free. - If
propertyReadSideEffectsis'always', all property reads on unbound variables are considered to have side effects. - With the default value
true,hasEffectsOnInteractionAtPathis used to recursively determine whether reading the object at that property path has side effects.
Example 1 — propertyReadSideEffects: true (default):
const x = unknownObj.foo;
// unknownObj 不可追踪 → 可能存在 getter/Proxy → 有副作用Example 2 — Known namespace variable:
import * as ns from './foo';
ns.bar;
// variable 已在 bind 阶段绑定 → hasAccessEffect 被跳过 → 无副作用IfStatement — Branch Elimination
// 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 attempts to determine the value of the conditional expression at compile time via 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;
}If the condition value can be determined at compile time, only the branch that will execute is analyzed. Otherwise, both branches must be analyzed. Rollup also maintains a brokenFlow context during branch analysis to track control flow interruptions such as break, continue, and return.
Example 1 — Compile-time constant condition (DCE):
if (false) { console.log('dead'); }
// testValue = false → only checks alternate (null) → no side effects → entire statement can be removedExample 2 — Compile-time constant after replacement:
// 经过 replace 插件替换后
if ("production" !== "production") { enableDevTools(); }
// test 值为 false → 只检查 alternate → 无副作用Example 3 — Runtime condition:
if (userInput) { sideEffect(); }
// testValue 无法确定 → 两个分支都检查 → sideEffect() 有副作用 → trueConditionalExpression — Ternary Expression
Uses the same branch optimization logic as 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);
}It attempts to determine the used branch via 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);
}Example:
const x = true ? pureFunction() : sideEffect();
// usedBranch = consequent → only analyzes pureFunction()LogicalExpression — Logical Short-Circuiting
// 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 determines short-circuit behavior based on the logical operator (||, &&, ??) and the compile-time value of the left operand:
// 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;
}Example 1 — || short-circuit:
const x = true || sideEffect();
// left = true, operator = '||' → usedBranch = left → right not checked → no side effectsExample 2 — && propagation:
const x = condition && sideEffect();
// condition 不确定 → usedBranch = null → 两侧都检查 → sideEffect() 有副作用UnaryExpression — Unary Operators
// 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
))
);
}Three key rules:
typeof+Identifier:typeof undeclaredVardoes not throw aReferenceError, so it directly returnsfalse.deleteoperation:delete obj.propmodifies the object structure and is determined to have side effects through assignment interaction checks.- Other operators (
!,+,-,void,~): Only recursively check the operand.
Example 1 — Special handling of typeof:
typeof undeclaredVar; // typeof + Identifier → falseExample 2 — delete operation:
delete obj.prop; // delete → checks assignment interaction → has side effectsExample 3 — void operation:
void someExpression; // recursively checks someExpressionBinaryExpression — Implicit Type Coercion
// 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 applies special handling for the '' + obj pattern: when the left operand of the + operator is an empty string and the entire expression is used as an ExpressionStatement, it may invoke obj.toString() or obj[Symbol.toPrimitive](), and is therefore considered to have side effects.
'' + obj; // may trigger implicit type coercion → has side effects
1 + 2; // literal arithmetic → recursively checks child nodes → no side effectsTryStatement — try/catch Deoptimization
// 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)
);
}When tryCatchDeoptimization is true (the default), a try block is considered to have side effects as long as it is non-empty. This is because if code in the try block throws an exception, the catch block alters control flow, and Rollup cannot precisely track exception propagation paths.
try {
pureFunctionCall(); // even if the function body has no side effects, non-empty try block → has side effects
} catch (e) {}Additionally, during the include phase, tryCatchDeoptimization also affects the include strategy within the try block:
// 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
);
// ...
}
// ...
}When tryCatchDeoptimization is true, statements in the try block are included with the INCLUDE_PARAMETERS mode, meaning that function parameters used within the try block will not be optimized away. This is consistent with the behavior described in the Try Catch Deoptimization configuration option: parameters used in try statements are preserved.
VariableDeclarator — Variable Declarations
// 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
))
);
}Example 1 — Simple declaration:
const x = 42; // init is literal 42, no side effects; id is Identifier → no side effectsExample 2 — Function call initialization:
const config = getConfig(); // getConfig() has side effects → has side effectsExample 3 — using declaration (TC39 Explicit Resource Management):
using handle = getResource();
// isUsingDeclaration = true → has side effects (Symbol.dispose will be called at runtime)Example 4 — Destructuring assignment with propertyReadSideEffects:
const { a } = unknownObj;
// propertyReadSideEffects = true → hasEffectsWhenDestructuring checks property reads → has side effectsClassNode — Class Declarations
// 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);
}Side effects in class declarations come from three sources:
extendsexpression: If the parent class is the result of a function call (e.g.,class Foo extends getBase()), it has side effects.- Class body contents: Static property initializers, computed property names, etc. may have side effects.
- Decorators: Decorators are essentially function calls and therefore have side effects.
Example 1 — Pure class declaration:
class Foo {
method() { return 1; }
}
// No superClass, body (MethodDefinition) has no side effects → no side effectsExample 2 — With extends:
class Bar extends getBase() { }
// superClass = getBase() → function call → has side effectsExample 3 — Decorators:
@decorator
class Qux { }
// decorator is a function call → checkEffectForNodes(this.decorators, context) → has side effectsInteraction-Level Side Effect Determination (hasEffectsOnInteractionAtPath)
rollup not only determines a node's own side effects but also whether performing a specific operation on a node has side effects. This is an analysis dimension independent of hasEffects(), implemented through the hasEffectsOnInteractionAtPath method.
Three interaction types are defined in NodeInteractions:
INTERACTION_ACCESSED: Property access (may trigger a getter)INTERACTION_CALLED: Function invocationINTERACTION_ASSIGNED: Assignment (may trigger a setter)
This design enables rollup to distinguish between declaration and usage:
function pure() { return 1; } // 声明 → hasEffects = false
pure(); // 调用 → hasEffectsOnInteractionAtPath(CALLED)
// → 递归分析函数体 → return 1 无副作用Taking the interaction-level determination of MemberExpression as an example:
// 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;
}When the path depth has not exceeded MAX_PATH_DEPTH, rollup concatenates the property key into the path and recursively delegates the determination to the object expression. When the depth limit is exceeded, it conservatively returns true to prevent infinite recursion.
Concrete examples:
Example 1 — Call interaction (INTERACTION_CALLED):
// 输入
function greet() { return 'hello'; }
const obj = { greet };
obj.greet();
// Rollup 输出
// (空,整段代码被移除)
// obj.greet() → MemberExpression.hasEffectsOnInteractionAtPath(['greet'], CALLED)
// → 追踪到 ObjectEntity → 找到 greet 函数 → 分析函数体 → 无副作用Example 2 — Property access interaction (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 → 有副作用 → 保留Example 3 — Assignment interaction (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 → 有副作用 → 保留Lazy Deoptimization Mechanism (applyDeoptimizations)
In the hasEffects implementations of the various nodes described above, you can see the if (!this.deoptimized) this.applyDeoptimizations(); call at the beginning. This is a key optimization in rollup: lazy deoptimization.
The default implementation of 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();
}Different node types override applyDeoptimizations to implement specific deoptimization logic:
| Node Type | Key Behavior of applyDeoptimizations |
|---|---|
CallExpression | Propagates argument information to the callee (deoptimizeArgumentsOnInteractionAtPath) |
AssignmentExpression | Propagates the right-hand value to the left-hand target (deoptimizeAssignment) |
MemberExpression | If propertyReadSideEffects is true, notifies that the object property is accessed |
IdentifierBase | Marks the variable as used, triggers execution marking of the containing module |
ForOfStatement | Deoptimizes the paths of the left-hand declaration and right-hand expression |
ClassNode | Deoptimizes the return values of all non-static methods |
UnaryExpression (delete) | Deoptimizes the deleted path |
All applyDeoptimizations methods end with a call to this.scope.context.requestTreeshakingPass(), which sets this.graph.needsTreeshakingPass = true. This means the deoptimization process may change the side effect determination results of other nodes, so additional tree-shaking passes are needed to handle these changes.
Taking IdentifierBase as an example:
// 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();
}
}When an identifier is first analyzed, if it references a variable from a module marked as side-effect-free, rollup marks that module and its impure dependencies as executed (isExecuted = true), so that the next tree-shaking pass can analyze the side-effect statements of these newly activated modules.
End-to-end example — Lazy deoptimization across multiple tree-shaking passes:
// 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 process:
Pass 1: Analyze the top-level statements of
main.jsimport { pure } from './utils'→hasEffects = false(ImportDeclaration is always side-effect-free)const result = pure()→ Check whetherpure()has side effects- The
pureidentifier is analyzed for the first time → triggersapplyDeoptimizations utils.jsis marked assideEffects: false, butpureis used →markModuleAndImpureDependenciesAsExecuted(utils.js)requestTreeshakingPass()→ requests the next pass
- The
resultis not used →pure()function body has no side effects → the entire statement is not retained
Pass 2:
utils.jsis now marked asisExecuted = true- Check the top-level statements of
utils.js - Both
pureandimpureare function declarations → no side effects - No new includes → converges
- Check the top-level statements of
// Rollup 输出
// (空,整段代码被移除)
// result 未被使用,pure() 无副作用,impure() 未被引用Comparison — If result is used:
// main.js
import { pure } from './utils';
export const result = pure();
// Rollup 输出
function pure() { return 1; }
const result = pure();
export { result };
// result 被导出(使用),pure() 需要保留
// impure 仍然被移除(未引用)Design Trade-offs Summary
| Decision | Choice | Trade-off |
|---|---|---|
| Unknown nodes | Has side effects (UnknownNode) | Ensures correctness at the cost of a few missed tree-shake opportunities |
for...of | Always has side effects | Correct but conservative; Iterator protocol tracking could be improved in the future |
try-catch | Deoptimized by default | Safely handles runtime exceptions; can be disabled via configuration |
| Property access | May have side effects by default | Prevents implicit side effects from getters/Proxies |
#__PURE__ / #__NO_SIDE_EFFECTS__ | Manual user annotations | Delegates the decision to users, compensating for the limitations of static analysis |
| Branch elimination | Only optimizes compile-time constants | Ensures correctness; complements DCE (Dead Code Elimination) |
| Lazy deoptimization | Triggered on demand | Reduces unnecessary analysis overhead but introduces the possibility of multiple tree-shaking passes |
| Interaction-level analysis | Distinguishes access/call/assignment | Improves precision (e.g., function declarations have no side effects but calls may), at the cost of increased implementation complexity |
The side effect determination in rollup is essentially a conservative abstract interpreter: it would rather retain extra code (false positives) than incorrectly remove logic with side effects (false negatives). Users can manually relax constraints through #__PURE__, #__NO_SIDE_EFFECTS__, sideEffects: false, and the various treeshake configuration options, striking a balance between correctness and bundle size.
tree-shaking
tree-shaking is executed during the Chunk's render phase. At this point, it has been determined which modules need to be executed and which statements need to be preserved in the final output.
class Chunk {
// This method changes properties on the AST before rendering and must not be async
private renderModules(fileName: string) {
// 省略其他逻辑 (Other logic omitted)
for (const module of orderedModules) {
const renderedLength = 0;
let source: MagicString | undefined;
if (module.isIncluded() || includedNamespaces.has(module)) {
const rendered = module.render(renderOptions);
// 省略其他逻辑 (Other logic omitted)
}
}
// 省略其他逻辑 (Other logic omitted)
}
async render(): Promise<ChunkRenderResult> {
const {
accessedGlobals,
indent,
magicString,
renderedSource,
usedModules,
usesTopLevelAwait
} = this.renderModules(preliminaryFileName.fileName);
// 省略其他逻辑 (Other logic omitted)
}
}As known from the Generate Chunks article, a chunk corresponds to at least one module. Therefore, from the source code logic above, it can be seen that Rollup will iterate through all modules in the chunk and then call each module's render method sequentially to generate the final code.
WARNING
All modules contained in a chunk are sorted according to their execution order and stored in the orderedModules array.
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> {
// 省略其他逻辑 (Other logic omitted)
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);
}
// 省略其他逻辑 (Other logic omitted)
}
// 省略其他逻辑 (Other logic omitted)
}It can be seen that tree-shaking is executed within the module.render(renderOptions) function. Let's delve deeper into the logic of the module.render(renderOptions) function.
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 };
}
}From this, we can understand that TLA (Top-Level Await) syntax in Rollup does not support output formats other than ESM and system. For a detailed implementation plan of TLA syntax, please refer to Detailed Explanation and Implementation of TLA.
Additionally, the module.render(renderOptions) function calls the module.ast.render(source, options) method, thereby performing the tree-shaking operation.
The logic of the this.ast!.render(source, options) method is as follows:
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);
}
}
}The logic is straightforward: sequentially render the body nodes of the current module's AST, using the renderStatementList method to render each statement. In other words, the renderStatementList method contains the core logic of tree-shaking.
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);
}
}
}In the renderStatementList method, it is decided whether to call the node.render method or the treeshakeNode method based on whether the statement's AST Node is needed (node.included).
If the node does not need to be included in the final output (i.e.,
node.included = false):Then, the
treeshakeNodemethod will be used to execute the logic for removing this piece of code.tsfunction treeshakeNode( node: Node, code: MagicString, start: number, end: number ): void { code.remove(start, end); node.removeAnnotations(code); }If the node needs to be included in the final output (i.e.,
node.included = true):Then, the
node.rendermethod will be used to recursively process the AST tree and handle details. The logic mainly involves fine-tuning the source code, such as replacing variable names, deleting unused code blocks (by recursively callingrenderStatementList), removing theexportkeyword from the source code corresponding to requiredexport ASTnodes, and adding semicolons at the end of statements.