Build the column series catalog entry
Zuo Lin, front-end development engineer of Wedoctor Front-end Technology Department. In the Internet wave, love life and technology.
The version of the Rollup packaging tool used in this article is rollup V2.47.0.
From WebPack2.x to tree-shaking with plugins, to the recent hot Vite build tools that use rollup’s packaging capabilities, Vue and React are known to use rollup as well. Especially when we are creating packages for libraries like function libraries, utility libraries, etc., the first choice is rollup! So what’s the magic that keeps Rollup going? The answer may be in tree-shaking!
Understanding tree-shaking
1. What is tree-shaking?
The concept of tree-shaking has been around for a long time, but it’s only since rollup that it has been taken seriously. In the spirit of curiosity, let’s take a look at tree-shaking from rollup
So, what does Kangkang Tree-shaking do?
Tree-shaking in the packaging tool, implemented earlier by Rich_Harris’s rollup, is officially standard: essentially eliminating useless JS code. That is, when I introduce a module, I don’t introduce all the code in the module, I introduce only the code I need, and the useless code I don’t need is “shaken” away.
Webpack also implements tree-shaking. Specifically, in a Webpack project, there is an entry file that is like the trunk of a tree, and the entry file has many modules that depend on it, like branches of the tree. In reality, although our functionality files depend on a module, we use only some of the functionality, not all of it. With tree-shaking, modules that are not in use are shaken off so that useless code can be removed.
So we know that tree-shaking is a way to eliminate useless code!
It should be noted, however, that tree-shaking can eliminate useless code, but only for THE ES6 module syntax, which uses static analysis, analyzing the code literally. He has no idea what to do with CommonJS dynamic analysis modules that have to be executed before you know what to refer to, but we can use plugins to support CommonJS turning to ES6 and then doing treeshaking. As long as the idea doesn’t slide, it’s more difficult than it is.
In summary, rollup.js uses the ES module standard by default, but it can be made to support the CommonJS standard through the rollup-plugin-CommonJS plugin.
2. Why do you need tree-shaking?
Today’s Web applications can be very bulky, especially JavaScript code, but JavaScript is very resource-intensive for browsers to process. If we can get rid of the useless code and provide only valid code for browsers to process, we can greatly reduce the burden on browsers. And Tree-Shaking helps us do that.
From this perspective, the tree-shaking functionality falls into the category of performance optimization.
After all, reducing JavaScript garbage in a Web project means reducing the size of the file, which reduces the time it takes to load the file resources, thereby enhancing the user experience by reducing the waiting time for the user to open the page.
Second, in-depth understanding of tree-shaking principle
We’ve seen that the essence of tree-shaking is to eliminate useless JS code. So what is useless code? How to eliminate useless code? Let’s take a look at the mystery of DCE and find out
1. Elimination of dead code with DCE
Useless code is so common in our code that its elimination has its own term, dead code elimination (DCE). In effect, the compiler can figure out which code doesn’t affect the output and then eliminate it.
Tree-shaking is a new implementation of DCE. Unlike traditional programming languages, Javascript is mostly loaded over a network and then executed. The smaller the file size is loaded, the shorter the overall execution time is. Makes more sense for javascript. Tree-shaking is also different from traditional DCE, which eliminates code that is impossible to execute, whereas tree-shaking focuses on eliminating code that is not used.
DCE
- Code will not be executed and is unreachable
- The results of code execution are not used
- The code only affects dead variables and is written, not read
Traditional compiled predictions are made by the compiler removing Dead Code from the AST (abstract syntax tree). So how does tree-shaking eliminate useless javascript code?
Tree-shaking is more focused on eliminating modules that are referenced but not used, a principle that relies on the module features of ES6. So let’s take a look at the ES6 module features:
ES6 Module
- Appears only as a statement at the top level of the module
- Import module names can only be string constants
- Import binding is immutable
With these premises in mind, let’s test them in code.
2. The Tree – shaking
The use of tree-shaking has already been described. In the next experiment, the index.js is created as the entry file and the generated code is packaged into bundle.js. In addition, the a.js, util.js and other files are referenced as dependency modules.
1) Eliminate variables
As you can see from the figure above, the variables b and c we defined are not used. They do not appear in the packaged file.
2) Elimination function
As you can see from the figure above, the util1() and util2() function methods, which are only introduced but not used, are not packaged.
3) to eliminate class
When only references are added but no calls are made
When referring to the class file Mixer.js but not using any of the menu methods and variables in the actual code, we can see that the elimination of the class methods has been implemented in the new version of Rollup!
4) side effects
However, not all side effects are being rolled up. See the relevant article. Rollup has a big advantage over Webpack in eliminating side effects. But rollup can’t help with side effects in the following cases:
2) Variables defined in the module affect global variables
You can see the results clearly by referring to the figure below, and you can go there yourselfPlatform provided by the rollup websitePut your hands into practice:
summary
As we can see from the above packaging results, the Rollup tool is very lightweight and concise for packaging, keeping only the required code from importing the dependent modules from the entry file to the output of the packaged bundle. In other words, no additional configuration is required in rollup packaging, as long as your code conforms to the ES6 syntax, you can implement tree-shaking. Nice!
So, tree-shaking in this packaging process can be roughly understood as having two key implementations:
- ES6’s module introduction is statically analyzed to determine exactly what code has been loaded at compile time.
- Analyze the program flow, determine which variables are being used, referenced, and package the code.
The core of Tree-Shaking is contained in this process of analyzing program flows: Based on scope, an object record is formed for a function or global object in the AST process, and then the identification of the import is matched in the whole formed scope chain object. Finally, only the matched code is packaged, and the code that is not matched is deleted.
But at the same time, we should also pay attention to two points:
- Write as little code as possible that contains side effects, such as operations that affect global variables.
- Referring to a class instantiation and calling a method on that instance can also have side effects that rollup cannot handle.
So how does this generate records and match identifiers in the program flow analysis process work?
Next take you into the source code, find out!
Third, tree-shaking implementation process
Before we can parse the tree-shaking implementation in the process, we need to know two things:
- Tree-shaking in Rollup uses Acorn for traversal parsing of an AST abstract syntax tree. Acorn is the same as Babel, but Acorn is much lighter. Before that, AST workflows must also be understood.
- Rollup uses the magic-String tool to manipulate strings and generate source-map.
Let’s start from the source code and describe the process in detail according to the core principles of Tree-Shaking:
- In the rollup() phase, the source code is parses, the AST tree is generated, each node on the AST tree is traversed, the include is determined, the tag is determined, the chunks are generated, and finally the chunks are exported.
- The generate()/write() phase, which collects code based on the markup made in the rollup() phase, and finally generates the actual code used.
Get the source code debug up ~
// perf-debug.js
loadConfig().then(async config => // Get the collection configuration
(await rollup.rollup(config)).generate(
Array.isArray(config.output) ? config.output[0] : config.output
)
);
Copy the code
This is probably the code you’re most concerned with when you’re debugging. In a nutshell, you’re packaging input into output, which corresponds to the process above.
export async function rollupInternal(
rawInputOptions: GenericConfigObject, // Pass in the parameter configuration
watcher: RollupWatcher | null
) :Promise<RollupBuild> {
const { options: inputOptions, unsetOptions: unsetInputOptions } = awaitgetInputOptions( rawInputOptions, watcher ! = =null
);
initialiseTimers(inputOptions);
const graph = new Graph(inputOptions, watcher); // The graph contains entries and dependencies, operations, caching, etc. The AST transformation is implemented inside the instance, which is the core of rollup
constuseCache = rawInputOptions.cache ! = =false; // Select whether to use caching from the configuration
delete inputOptions.cache;
delete rawInputOptions.cache;
timeStart('BUILD'.1);
try {
// Call the plug-in driver method, call the plug-in, provide the context for the plug-in environment, etc
await graph.pluginDriver.hookParallel('buildStart', [inputOptions]);
await graph.build();
} catch (err) {
const watchFiles = Object.keys(graph.watchFiles);
if (watchFiles.length > 0) {
err.watchFiles = watchFiles;
}
await graph.pluginDriver.hookParallel('buildEnd', [err]);
await graph.pluginDriver.hookParallel('closeBundle'[]);throw err;
}
await graph.pluginDriver.hookParallel('buildEnd'[]); timeEnd('BUILD'.1);
const result: RollupBuild = {
cache: useCache ? graph.getCache() : undefined.closed: false.async close() {
if (result.closed) return;
result.closed = true;
await graph.pluginDriver.hookParallel('closeBundle'[]); },// generate - Generate new code by processing the traversal tags as output from the abstract syntax tree
async generate(rawOutputOptions: OutputOptions) {
if (result.closed) return error(errAlreadyClosed());
// The first parameter isWrite is false
return handleGenerateWrite(
false,
inputOptions,
unsetInputOptions,
rawOutputOptions as GenericConfigObject,
graph
);
},
watchFiles: Object.keys(graph.watchFiles),
// write - Generate new code by processing traversal tags through the abstract syntax tree as output
async write(rawOutputOptions: OutputOptions) {
if (result.closed) return error(errAlreadyClosed());
// The first parameter isWrite is true
return handleGenerateWrite(
true,
inputOptions,
unsetInputOptions,
rawOutputOptions asGenericConfigObject, graph ); }};if (inputOptions.perf) result.getTimings = getTimings;
return result;
}
Copy the code
From this piece of code alone, of course, we can’t see anything, let’s read the source code together to comb the rollup packaging process and explore the concrete implementation of Tree-shaking, in order to understand the packaging process more simply and directly, we will skip the plug-in configuration in the source code, only analyze the core process of functional process implementation.
1. Module parsing
Gets the absolute file path
The resolveId() method resolves the address of the file to get the absolute path of the file. Getting the absolute path is our main purpose, and the details are not analyzed here.
export async function resolveId(
source: string,
importer: string | undefined,
preserveSymlinks: boolean,) {
// Non-entry modules that do not begin with a. Or/are skipped in this step
if(importer ! = =undefined && !isAbsolute(source) && source[0]! = ='. ') return null;
// Call path.resolve to change the valid file path to an absolute path
return addJsExtensionIfNecessary(
importer ? resolve(dirname(importer), source) : resolve(source),
preserveSymlinks
);
}
/ / addJsExtensionIfNecessary () implementation
function addJsExtensionIfNecessary(file: string, preserveSymlinks: boolean) {
let found = findFile(file, preserveSymlinks);
if (found) return found;
found = findFile(file + '.mjs', preserveSymlinks);
if (found) return found;
found = findFile(file + '.js', preserveSymlinks);
return found;
}
/ / findFile () implementation
function findFile(file: string, preserveSymlinks: boolean) :string | undefined {
try {
const stats = lstatSync(file);
if(! preserveSymlinks && stats.isSymbolicLink())return findFile(realpathSync(file), preserveSymlinks);
if ((preserveSymlinks && stats.isSymbolicLink()) || stats.isFile()) {
const name = basename(file);
const files = readdirSync(dirname(file));
if(files.indexOf(name) ! = = -1) returnfile; }}catch {
// suppress}}Copy the code
A rollup (phase)
The Rollup () phase does a lot of work, including collecting configurations and standardizing them, analyzing files and compiling the source to generate the AST, generating modules and resolving dependencies, and finally generating chunks. To figure out exactly where tree-shaking works, we need to parse the code that is processed more internally.
First, find the module definition of the entry file by starting from its absolute path, and get all the dependent statements of the entry module and return everything.
private async fetchModule(
{ id, meta, moduleSideEffects, syntheticNamedExports }: ResolvedId,
importer: string | undefined.// Import the reference module for this module
isEntry: boolean // Whether to enter the path) :Promise<Module> {
...
// Create a Module instance
const module: Module = new Module(
this.graph, // The Graph is a globally unique Graph that contains entries and dependencies, operations, caching, etc
id,
this.options,
isEntry,
moduleSideEffects, // Module side effects
syntheticNamedExports,
meta
);
this.modulesById.set(id, module);
this.graph.watchFiles[id] = true;
await this.addModuleSource(id, importer, module);
await this.pluginDriver.hookParallel('moduleParsed'[module.info]);
await Promise.all([
// Handle static dependencies
this.fetchStaticDependencies(module),
// Handle dynamic dependencies
this.fetchDynamicDependencies(module)]);module.linkImports();
// Return the current module
return module;
}
Copy the code
The dependent module is further processed in fetchStaticDependencies(module) and fetchDynamicDependencies(module), respectively, and the contents of the dependent module are returned.
private fetchResolvedDependency(
source: string,
importer: string,
resolvedId: ResolvedId
): Promise<Module | ExternalModule> {
if (resolvedId.external) {
const { external, id, moduleSideEffects, meta } = resolvedId;
if (!this.modulesById.has(id)) {
this.modulesById.set(
id,
new ExternalModule( // Create an external Module instance
this.options, id, moduleSideEffects, meta, external ! = ='absolute' && isAbsolute(id)
)
);
}
const externalModule = this.modulesById.get(id);
if(! (externalModuleinstanceof ExternalModule)) {
return error(errInternalIdCannotBeExternal(source, importer));
}
// Return the dependent module contents
return Promise.resolve(externalModule);
} else {
// If there is an external reference imported into the module, we recursively retrieve all the dependent statements of the entry module
return this.fetchModule(resolvedId, importer, false); }}Copy the code
Each file is a Module, and each Module has a Module instance. In the Module instance, the code of the Module file is parsed into an AST syntax tree by traversing Acorn’s parse method.
const ast = this.acornParser.parse(code, { ... (this.options.acorn asacorn.Options), ... options });Copy the code
Finally, the source is parsed and set to the current module, the conversion from file to module is completed, and the ES Tree node and the syntax trees of various types contained within it are parsed.
setSource({ alwaysRemovedCode, ast, code, customTransformCache, originalCode, originalSourcemap, resolvedIds, sourcemapChain, transformDependencies, transformFiles, ... moduleOptions }: TransformModuleJSON & { alwaysRemovedCode? : [number, number][]; transformFiles? : EmittedFile[] |undefined;
}) {
this.info.code = code;
this.originalCode = originalCode;
this.originalSourcemap = originalSourcemap;
this.sourcemapChain = sourcemapChain;
if (transformFiles) {
this.transformFiles = transformFiles;
}
this.transformDependencies = transformDependencies;
this.customTransformCache = customTransformCache;
this.updateOptions(moduleOptions);
timeStart('generate ast'.3);
this.alwaysRemovedCode = alwaysRemovedCode || [];
if(! ast) { ast =this.tryParse();
}
this.alwaysRemovedCode.push(... findSourceMappingURLComments(ast,this.info.code));
timeEnd('generate ast'.3);
this.resolvedIds = resolvedIds || Object.create(null);
this.magicString = new MagicString(code, {
filename: (this.excludeFromSourcemap ? null: fileName)! .// Do not include helper plug-ins in Sourcemap
indentExclusionRanges: []
});
for (const [start, end] of this.alwaysRemovedCode) {
this.magicString.remove(start, end);
}
timeStart('analyse ast'.3);
// Ast context, wrapper some methods, such as dynamic import, export, etc., a lot of things, take a look at the overview
this.astContext = {
addDynamicImport: this.addDynamicImport.bind(this), // Dynamic import
addExport: this.addExport.bind(this),
addImport: this.addImport.bind(this),
addImportMeta: this.addImportMeta.bind(this),
code,
deoptimizationTracker: this.graph.deoptimizationTracker,
error: this.error.bind(this),
fileName,
getExports: this.getExports.bind(this),
getModuleExecIndex: () = > this.execIndex,
getModuleName: this.basename.bind(this),
getReexports: this.getReexports.bind(this),
importDescriptions: this.importDescriptions,
includeAllExports: () = > this.includeAllExports(true), // Include related method markup determines whether it is tree-shaking
includeDynamicImport: this.includeDynamicImport.bind(this), // include...
includeVariableInModule: this.includeVariableInModule.bind(this), // include...
magicString: this.magicString,
module: this.moduleContext: this.context,
nodeConstructors,
options: this.options,
traceExport: this.getVariableForExportName.bind(this),
traceVariable: this.traceVariable.bind(this),
usesTopLevelAwait: false.warn: this.warn.bind(this)};this.scope = new ModuleScope(this.graph.scope, this.astContext);
this.namespace = new NamespaceVariable(this.astContext, this.info.syntheticNamedExports);
// Instantiate Program to assign the AST context to the ast attribute of the current module
this.ast = new Program(ast, { type: 'Module'.context: this.astContext }, this.scope);
this.info.ast = ast;
timeEnd('analyse ast'.3);
}
Copy the code
2. Mark whether the module can be tree-shaking
Continue to process the current module and introduce the module and ES Tree node according to the status of isExecuted and related configuration of Treeshakingy. IsExecuted true means that the module has been added, and there is no need to add it again in the future. Finally, according to isExecuted, all required modules are collected to implement tree-shaking.
// For example, includeVariable() and includeAllExports() methods are not listed in one
private includeStatements() {
for (const module of [...this.entryModules, ...this.implicitEntryModules]) {
if (module.preserveSignature ! = =false) {
module.includeAllExports(false);
} else {
markModuleAndImpureDependenciesAsExecuted(module); }}if (this.options.treeshake) {
let treeshakingPass = 1;
do {
timeStart(`treeshaking pass ${treeshakingPass}`.3);
this.needsTreeshakingPass = false;
for (const module of this.modules) {
// Mark according to isExecuted
if (module.isExecuted) {
if (module.info.hasModuleSideEffects === 'no-treeshake') {
module.includeAllInBundle();
} else {
module.include(); / / tag
}
}
}
timeEnd(`treeshaking pass ${treeshakingPass++}`.3);
} while (this.needsTreeshakingPass);
} else {
for (const module of this.modules) module.includeAllInBundle();
}
for (const externalModule of this.externalModules) externalModule.warnUnusedImports();
for (const module of this.implicitEntryModules) {
for (const dependant of module.implicitlyLoadedAfter) {
if(! (dependant.info.isEntry || dependant.isIncluded())) { error(errImplicitDependantIsNotIncluded(dependant)); }}}}Copy the code
Module. include is an ES tree node, and the initial NodeBase include is false, so there is a second condition to determine whether the node has side effects. Whether this has side effects depends on the implementation of the various Node subclasses that inherit from NodeBase, and whether it affects the whole world. Different types of ES nodes within Rollup implement different hasEffects implementations. In the continuous optimization process, side effects of class references are handled and unused classes are eliminated. This can be further understood in the context of tree-shaking elimination in Chapter 2.
include(): void{/ include() implementationconst context = createInclusionContext();
if (this.ast! .shouldBeIncluded(context))this.ast! .include(context,false);
}
Copy the code
3. TreeshakeNode () method
TreeshakeNode () is a method in the source code to remove code that is not useful, and it is clearly noted when called – to prevent repeated declarations of the same variables/nodes, and to indicate whether the node code is included, if so, tree-shaking, The removeAnnotations() method is also provided to remove unwanted commented code.
// Eliminate useless nodes
export function treeshakeNode(node: Node, code: MagicString, start: number, end: number) {
code.remove(start, end);
if (node.annotations) {
for (const annotation of node.annotations) {
if(! annotation.comment) {continue;
}
if (annotation.comment.start < start) {
code.remove(annotation.comment.start, annotation.comment.end);
} else {
return; }}}}// Remove comment nodes
export function removeAnnotations(node: Node, code: MagicString) {
if(! node.annotations && node.parent.type === NodeType.ExpressionStatement) { node = node.parentas Node;
}
if (node.annotations) {
for (const annotation of node.annotations.filter((a) = > a.comment)) {
code.remove(annotation.comment!.start, annotation.comment!.end);
}
}
}
Copy the code
When you call the treeshakeNode() method is important! Tree-shaking and recursively render before rendering.
render(code: MagicString, options: RenderOptions, nodeRenderOptions? : NodeRenderOptions) {
const { start, end } = nodeRenderOptions as { end: number; start: number };
const declarationStart = getDeclarationStart(code.original, this.start);
if (this.declaration instanceof FunctionDeclaration) {
this.renderNamedDeclaration(
code,
declarationStart,
'function'.'('.this.declaration.id === null,
options
);
} else if (this.declaration instanceof ClassDeclaration) {
this.renderNamedDeclaration(
code,
declarationStart,
'class'.'{'.this.declaration.id === null,
options
);
} else if (this.variable.getOriginalVariable() ! = =this.variable) {
// tree-shaking prevents repeated declarations of variables
treeshakeNode(this, code, start, end);
return;
// included 标识做 tree-shaking
} else if (this.variable.included) {
this.renderVariableDeclaration(code, declarationStart, options);
} else {
code.remove(this.start, declarationStart);
this.declaration.render(code, options, {
isCalleeOfRenderedParent: false.renderedParentType: NodeType.ExpressionStatement
});
if (code.original[this.end - 1]! = ='; ') {
code.appendLeft(this.end, '; ');
}
return;
}
this.declaration.render(code, options);
}
Copy the code
There are several places like this where tree-shaking shines!
// Sure enough, we saw "included" again.if(! node.included) { treeshakeNode(node, code, start, end);continue; }...if (currentNode.included) {
currentNodeNeedsBoundaries
? currentNode.render(code, options, {
end: nextNodeStart,
start: currentNodeStart
})
: currentNode.render(code, options);
} else {
treeshakeNode(currentNode, code, currentNodeStart!, nextNodeStart);
}
...
Copy the code
4. Generate code (string) with chunks and write it to a file
During the generate()/write() phase, the generated code is written to a file, and the handleGenerateWrite() method internally generates the bundle instance for processing.
async function handleGenerateWrite(.) {...// Generate the Bundle instance, which is a packaged object that contains all the module information
const bundle = new Bundle(outputOptions, unsetOptions, inputOptions, outputPluginDriver, graph);
// Call the generate method of the instance bundle to generate the code
const generated = await bundle.generate(isWrite);
if (isWrite) {
if(! outputOptions.dir && ! outputOptions.file) {return error({
code: 'MISSING_OPTION'.message: 'You must specify "output.file" or "output.dir" for the build.'
});
}
await Promise.all(
// Here's the key: generate the code through chunkId and write it to a file
Object.keys(generated).map(chunkId= > writeOutputFile(generated[chunkId], outputOptions))
);
await outputPluginDriver.hookParallel('writeBundle', [outputOptions, generated]);
}
return createOutput(generated);
}
Copy the code
summary
The bottom line is: start with the entry file, find all the variables it reads, find out where the variable is defined, include the definition statement, discard all irrelevant code, and get what you want.
conclusion
In this paper, based on the tree-shaking principle of the rollup source code in the packaging process, it can be found that for the simple packaging process, the source code does not do additional mysterious operations on the code. I just made the traversal tag to use the collection and package the output of the collected code and included the tag node treeshakeNode to avoid repeated declarations.
Of course, the most important part is the internal static analysis and collection of dependencies, which is a complicated process, but the core of the process is to walk through the node: find the variables that the current node depends on, the variables that are accessed, and the statements for those variables.
As a lightweight and fast packaging tool, Rollup has the advantage of being easy to package function libraries. Thanks to its code-handling advantages, the source code volume is also much lighter than Webpack, but I still find reading source code a boring process…
But! If only in line with the purpose of understanding the principle, might as well first only focus on the core code process, the details of the corners in the back, may be able to enhance the reading pleasure experience, accelerate the pace of the source code!
The resources
- Tree-shaking with invalid code elimination
- Tree-Shaking performance optimization practice – principles
- Your tree-shaking doesn’t work for eggs
- Rollup is as simple as tree Shaking