Replies: 1 comment 4 replies
-
@ahabhgk Have you folks looked at what the Parcel team is doing at all? I saw no mention of it in here, but it is worth investigating. They often come up with innovative and efficient solutions to problems. |
Beta Was this translation helpful? Give feedback.
4 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Tree shaking has become an extremely important and indispensable part of modern front-end bundling. Given the differences in applicable scenarios and focus areas among various bundlers, their implementations of tree shaking also vary. For example, webpack is primarily used for front-end application bundling and emphasizes correctness, with its tree shaking focusing on cross-module-level optimizations. On the other hand, Rollup is mainly used for library bundling and prioritizes optimization efficiency, performing tree shaking at the granularity of AST nodes, which typically results in smaller bundle sizes. However, it may lack guarantees for the execution correctness of certain edge cases.
This article will provide a brief overview of the tree shaking principles of various bundlers and the differences between them.
Tree shaking in webpack / Rspack
webpack's tree shaking consists of three parts:
module-level:
optimization.sideEffects
Remove modules that have no used exports and no side effects.import "./module";
No using any exports and have no side effects, so./module
can be safely removed.re-exports.js
(barrel file), which itself doesn't have any local exports is being used, and have no side effects, so re-exports.js can be safely removedexport-level:
optimization.providedExports
andoptimization.usedExports
, to remove unused exportsoptimization.providedExports
to analyze what exports a module hasoptimization.usedExports
to analyze which exports of a module are actually used, unused exports can be removed during code generation:export const a = 42
=>const a = 42
, and then a minimizer (such as SWC or Terser) can further eliminate the remaining declaration if the variable is also unused within the module itselfcode-level:
optimization.minimize
Use minifiers like SWC or Terser to analyze code through techniques such as inlining and evaluation, removing dead code and performing compression to minimize bundle size as much as possible.optimization.minimizer
plugin, performing post-processing on the bundler's output, which falls outside the bundler's core responsibilitiesIn addition, another important part is static analysis. Both module-level and export-level optimizations require webpack to perform static analysis on the code to determine whether a module has side effects, what exports it contains, and which exports are actually used. This information serves as input for these optimization phases.
Theoretically, as long as webpack's JavaScript parser can statically extract this information, tree shaking can be applied—even to CommonJS and dynamic imports, which are inherently dynamic. If these constructs follow a static pattern and the necessary information can be statically inferred, tree shaking remains possible.
However, currently, webpack only performs static analysis for very limited scenarios involving CJS and dynamic imports. Many analyzable cases remain unoptimized, leaving significant room for improvement.
After these three steps are completed, tree shaking is already functional, but there can still be issues with certain cases:
The export
a
is referenced by another unused exportg
in a different module, preventinga
from being tree-shaken. In this case,optimization.innerGraph
is needed to analyze the dependency relationships between top-level statements inlib.js
. Only when the top-level statement containinga
is used willa
be marked as used; otherwise, it will be considered unused.Some optimizable cases by minifiers cannot be optimized due to webpack's runtime wrapping each module in a function. For example, in the following case:
Generally, this can be resolved via
optimization.concatenateModules
, though in complex scenarios the number of modules eligible for concatenation tends to be quite limited.The minifier can effectively shorten variable names through mangling, but this wrapper function prevents it from mangling exported module variables. This is where
optimization.mangleExports
comes into play to handle the mangling.Additional optimizations, such as experiments.inlineConst introduced in Rspack v1.4.
Tree shaking in esbuild
esbuild's tree shaking involves these steps:
IsLive = true
.IsLive = true
are included; unmarked parts are removed.Splitting top-level statements in advance allows esbuild to inherently solve the innerGraph issue. After splitting, each top-level statement becomes a part, enabling analysis and optimization at the part-level granularity, unlike webpack which operates at the module-level granularity. This approach offers the following key advantages:
It's worth noting that early versions of esbuild also allowed these top-level statements to participate in code splitting, which is what we now call "module splitting". However, since esbuild does not wrap each module in a function like webpack's output—which separates module loading and execution—it struggled to properly handle top-level await. As a result, esbuild later dropped support for module splitting.
Currently, esbuild/docs/architecture/code-splitting still corresponds to the version that supports module splitting. Code splitting is performed at the part-level, with the shared chunk containing only the top-level statements common to both entry points. A replica in the esbuild playground shows that the shared chunk includes modules shared by both entries, with code splitting being done at the module level.
The result that before support module splitting
The result that now no longer support module splitting
Additionally, except top-level await, another complexity in esbuild's module splitting arose from not separating module loading and execution: ES Module's static imports are read-only. This meant esbuild couldn't move variable assignments into a different chunk from the variable's declaration during code splitting. After esbuild dropped module splitting, this issue no longer required special handling.
Tree shaking in Turbopack
Module splitting is actually more suitable for bundlers like webpack, which can separate module loading and execution, inherently ensuring the correctness of module execution without requiring additional handling of variable declarations and usage within modules.
Additionally, it allows applying more module-level optimizations to top-level statements inside modules, such as optimizations that esbuild lacks or underperforms in: code splitting, runtime optimization, chunk splitting, etc., enabling further optimizations.
So, is there a bundler that combines webpack-like runtime (separating module loading and execution) with support for module splitting? Yes: Turbopack.
First, let's examine how Turbopack's output format solves the issue of disallowing variable assignments and declarations across chunks, using the example from esbuild's bundling result:
Turbopack uses an output format similar to webpack, wrapping each module in a function to separate module loading and execution. But unlike webpack, the runtime
__turbopack_context__.s
that defines module exports not only provides a getter for exports but also includes an additional setter. When other parts of the module perform assignment operations on these variables, the corresponding setters are triggered to update the values, ensuring correct execution.For top-level await, like webpack, Turbopack employs a runtime to guarantee the correct execution order of modules containing top-level await and their imported dependencies. For example, after adding
await 1;
to the first line of data.js, the bundled output is as follows:Of course, module splitting also has its drawbacks. After splitting, each top-level statement in the output is wrapped in a function. While this ensures correct execution, it amplifies the downsides of this wrapping approach:
_data_0__TURBOPACK_MODULE__.data
, potentially degrading runtime performance (this still requires modern browser benchmarks for validation).Both issues necessitate greater reliance on scope hoisting for optimization.
Tree shaking in Rollup
If webpack performs tree shaking on export statements and modules, and esbuild does it on top-level statements, then Rollup conducts tree shaking from top to bottom for all statements and even finer-grained AST nodes, with more precise side effects detection.
Rollup performs tree shaking on statements and some finer-grained AST nodes.
Rollup's tree shaking is similar to esbuild's but with slight differences. The process works as follows:
include()
on the module.a. Determine if the AST node has side effects.
b. If yes, call
include()
.c. Continue side effect checks and
include()
on related AST nodes (repeating steps a and b).include()
-ed. If so, trigger a new traversal (steps 1 and 2).Rollup often achieves better tree shaking results compared to other bundlers for the following reasons:
Rollup’s fine-grained approach inherently includes the coarser-grained analysis of export statements and top-level statements. As a result, Rollup can not only remove unused exports across modules but also handle some intra-module DCE. The following discussion will focus on intra-module DCE, using specific cases to illustrate these two points.
Rollup cross-module analysis to eliminate non-top-level dead branch:
Rollup's Define feature is implemented differently from other bundlers. rollup-plugin-replace only replaces matched Define nodes during the transform phase, unlike webpack and esbuild, which perform replacements during the parse phase and also analyze dead branches. Code in dead branches is skipped during analysis, and no dependencies from dead branches are included in the module graph. However, this dead branch analysis during the parse phase cannot span across modules.
Since Rollup's tree shaking operates at the AST node level, even statement nodes inside functions can be analyzed for tree shaking. Therefore, Rollup delegates part of the analysis to the tree shaking phase, and the removal of dead branches also relies on tree shaking.
In this example, it attempts to perform compile-time evaluation on the
DEVELOPMENT
variable inif (DEVELOPMENT)
, which result is a constant, so the else branch can be removed as a dead branch. Additionally, theDEVELOPMENT
variable infile.js
is not marked as used, allowing the final tree shaking to remove theexport const DEVELOPMENT
declaration and thefile.js
module.The downside of this approach is that dependencies introduced in dead branches are still added to the module graph, bringing in more modules and requiring Rollup to handle more work, which reduces performance.
The advantage is that it enables cross-module analysis and removal of dead branches, eliminating more unused code and resulting in a smaller output bundle. Other bundlers rely on scope hoisting to merge modules into a single scope and depend on the minifier to remove these cross-module dead branches. However, for modules where scope hoisting must be bailed out to ensure correct execution, there is no good solution to optimize these modules. Future optimizations may be needed to address this.
Rollup removes unused object properties
When analyzing
console.log(obj.a.ab)
, Rollup marks this statement forinclude()
due to its side effects. Duringinclude(obj.a.ab)
, it triggers theinclude()
of related nodes, including theobj
declaration node, thea:
property node, and theab:
property node. Thanks to AST node-level tree shaking, Rollup can retain only the useda:
andab:
properties while shaking off other unused properties, resulting in a smaller output bundle.Rollup determines side effects based on reassignment
In this case, if you comment out
a = {}
, you'll notice all the code gets tree-shaken. However, once you uncomment it, the tree shaking no longer occurs. This is because Rollup determines whethera.b = 3
has side effects based on whether variablea
is reassigned. This demonstrates Rollup's ability to perform some context-aware side effects analysis, though it does come with a certain performance overhead compared to context-free side effects analysis.That said, this context-aware side effects analysis is relatively simple and typically only works for specific, straightforward scenarios. For example, in the above case, it only checks whether reassignment occurs but does not analyze whether the reassignment actually causes meaningful changes—meaning it avoids overly deep or detailed analysis.
Even if the same variable is reassigned without any actual changes, `a.b = 3` will still be considered to have side effects.
Rollup v3 only supported statement-level tree shaking, but starting with v4, it began experimenting with finer-grained AST node-level tree shaking (such as the object properties tree shaking mentioned above), as well as optimizing the tracking of unused nodes in specific scenarios, for example:
This makes tree shaking possible in more scenarios:
Beta Was this translation helpful? Give feedback.
All reactions