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 meansrollup
will performtree-shaking
optimization, i.e., remove the calls unless the return value is used in some code that is nottree-shaking
optimized. 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.moduleSideEffects
configuration. - Through the
treeshake.moduleSideEffects
configuration: Specify side effects for global modules, with the lowest priority. By default,treeshake.moduleSideEffects = true
, meaningrollup
assumes 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 import
information through theaddDynamicImport
method in the context object. - Collect
import
information through theaddImport
method in the context object. - Collect
export
andreexports
information through theaddExport
method in the context object. - Collect
import.meta
information through theaddImportMeta
method in the context object. - Determine whether the current module uses top-level
await
and other information by checking theast
structure.
Let's look at how module information is collected through the compat estree ast
structure.
Collect Module Info
Dynamic Dependencies
When the
initialise
method is executed in theImportExpression
instance, it means that childast node
classes 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
addDynamicImport
method provided by theast
context to store theImportExpression
node information in thedynamicImports
property of the currentModule
instance.tsclass Module { readonly dynamicImports: DynamicImport[] = []; private addDynamicImport(node: ImportExpression) { let argument: AstNode | string = node.sourceAstNode; if (argument.type === NodeType.TemplateLiteral) { if ( (argument as TemplateLiteralNode).quasis.length === 1 && typeof (argument as TemplateLiteralNode).quasis[0].value .cooked === 'string' ) { argument = (argument as TemplateLiteralNode).quasis[0].value .cooked!; } } else if ( argument.type === NodeType.Literal && typeof (argument as LiteralStringNode).value === 'string' ) { argument = (argument as LiteralStringNode).value!; } this.dynamicImports.push({ argument, id: null, node, resolution: null }); } }
Static Dependencies
Same as above, when all child nodes of
ImportDeclaration
are instantiated, theinitialise
method of theImportDeclaration
instance will be called.jsclass ImportDeclaration extends NodeBase { initialise(): void { super.initialise(); this.scope.context.addImport(this); } }
It will call the
addImport
method to store theImportDeclaration
node information in theimportDescriptions
property of the currentModule
instance.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 node
are different, so the storage method inimportDescriptions
needs to be specifically distinguished.The
ast node
for default imports is theImportDefaultSpecifier
node.jsimport demo from 'demo.js'; module.importDescriptions.set('demo', { module: null, name: 'default', source: 'demo.js', start: 7 });
Use
default
as the identifier inimportDescriptions
.The
ast node
for named imports is theImportSpecifier
node.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.name
orimported.value
inimportDescriptions
The
ast node
for namespace imports is theImportNamespaceSpecifier
node.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 Statements
export
involves 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 node
will call theaddExport
method during initialization to add the current module's export information to theexports
,reexportDescriptions
, andexportAllSources
properties of theModule
instance.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
,rollup
specifically distinguishes it in theModule
instance through thereexportDescriptions
andexportAllSources
properties.The abstract parsing results for the above
export-statement.js
example 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
rollup
performs syntax analysis here. The reason for this can be found in theOptimize Syntax Analysis
article. Bothimport
andexport
check for duplicate variable names.export
uses theassertUniqueExportName
method 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
exports
orreexportDescriptions
properties of theModule
instance to determine if it's a duplicate declaration.import
checks inaddImport
whether the imported reference variable exists in scope-declared variables (scope.variables
) or theimportDescriptions
property of theModule
instance 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
rollup
does not allow duplicate variable declarations in the same module, including top-level scope declared variables andimport
exported variables.If duplicate reference declarations occur, it will throw an error and terminate the build.
Summary
The information recorded about
import
andexport
nodes duringast
instantiation is essentially the mapping relationship between thelocalName
used in the current module's scope and thelocalName
exported in the dependent module's scope.Whether it's
import
orexport
, 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
linkImports
method will be called, and theaddModulesToImportDescriptions
method will be used to fill the child dependency module's references into theimporter
module'simportDescriptions
andreexportDescriptions
.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
linkImports
method 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
importDescriptions
andreexportDescriptions
properties of the current module can be filled in. Thesource
stored inexportAllSources
also finds the corresponding dependent module throughthis.graph.modulesById
and adds it tothis.exportAllModules
.Through layer-by-layer backtracking and binding collection, all dependent modules that were not filled in during the
ast
instantiation phase have been completely filled in.- Default export (
Import Meta
The
ast node
node related toimport.meta
isMetaProperty
. This is a declarative node, and since it's impossible to determine whetherimport.meta
node information is needed during theast
instantiation phase.Therefore, node information is not processed during
ast
instantiation, but rather collected through theaddImportMeta
method after confirming thatimport.meta
nodes need to be included during thetree shaking
phase.jsclass MateProperty { include() { if (!this.included) { this.included = true; if (this.meta.name === IMPORT) { this.scope.context.addImportMeta(this); const parent = this.parent; const metaProperty = (this.metaProperty = parent instanceof MemberExpression && typeof parent.propertyKey === 'string' ? parent.propertyKey : null); if (metaProperty?.startsWith(FILE_PREFIX)) { this.referenceId = metaProperty.slice(FILE_PREFIX.length); } } } } } class Module { addImportMeta(node) { this.importMetas.push(node); } }
TLA
This is to mark whether the current module contains
Top Level Await
. The implementation ofTop Level Await
in differentbundlers
has been explained in detail in Detailed Explanation And Implementation Of TLA, so it won't be repeated here.The
ast node
related toTop Level Await
isAwaitExpression
.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.meta
node,AwaitExpression
node information is also collected through theaddAwaitExpression
method after confirming thatAwaitExpression
nodes need to be included during thetree shaking
phase.The way to determine whether a module contains
AwaitExpression
nodes is simple: traverse up to the parent level until encountering aFunctionNode
orArrowFunctionExpression
node. If none are encountered, it means the current module hasTop Level Await
, and theusesTopLevelAwait
property 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 import
ed 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 AST
Node for Re-exported VariablesIt can be seen that acquiring the
Rollup AST
variable declaration node for re-exports involves calling thegetVariableForEportNameRecursive
method.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
getVariableForExportNameRecursive
method, thegetVariableForExportName
method 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 Node
of 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 AST
Node for Exported VariablesTo get the
Rollup AST Node
for anexport
ed 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
traceVariable
method 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 Node
declared 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
getVariableForExportNameRecursive
method 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
getVariableForExportName
method is used inmain.js
to get theRollup AST Node
for variablea
, it will recursively callgetVariableForExportNameRecursive
until variablea
is found. This means it will ultimately obtain theRollup AST Node
for variablea
declared in theother-next-next
module, and themodule
property of thisRollup AST Node
will be a reference to theother-next-next
module.:::
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.include
method to mark the current module'sAST
node 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 astrue
means that when recursing through the module'sAST
nodes, all descendantAST Node
s will be recursively included (confirming execution ofnode.include
). If this flag is not present, whether anAST Node
needs to be included in the final code is determined by whether theAST Node
has side effects.jsclass NodeBase { shouldBeIncluded(context: InclusionContext): boolean { return this.included || (!context.brokenFlow && this.hasEffects(createHasEffectsContext())); } }
Call the
includeAllExports
method.
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
exports
andreexports
, marking theAST Node
s 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 Node
s corresponding to exported and re-exported variables. Let's see how it is implemented.
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
treeshakeNode
method 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.render
method 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 theexport
keyword from the source code corresponding to requiredexport AST
nodes, and adding semicolons at the end of statements.