This is a part of the series of posts on best practices where I’m sharing bits of my experience contributing to open source. It’s both my hobby and job at Cube where we create and maintain open source tools for building data applications.
What is tree-shaking?
On the other hand, CommonJS exports are resolved at run time and can be dynamic. That’s why static analysis of used and unused exports is impossible for CommonJS modules.
Let’s compare how Webpack bundles CommonJS and ES6 modules. In this example, we export two functions (
div) from a CommonJS module but only one of them (
sum) is imported and used. (Note that here the code in
bundle.js is shown before minification.)
You can see that Webpack added the
__webpack_require__ function to the bundle to support CommonJS module loading. Also, the
div function is present in the bundle and won’t be removed after minification because it’s assigned to an object property (
exports.div) and will be considered “used”. Compare to an ES6 module:
You can see that the
div function was copied “as is” and is not used in any way. When you run Webpack in production mode, it will remove this function. (Internally, Webpack relies on Terser for minification; Rollup performs tree-shaking and dead code elimination on its own.)
What is tree-shakable code?
In the example above, two functions were exported from the module and only the “used” one made it to the bundle. What if not only functions are exported? (Note that here the code in
bundle.js is shown after minification.)
You might be surprised that all exported entities are present in the bundle, although only the
sum function was imported. Let’s explore why.
Removing unused functions is quite straightforward. If a function is never called, its declaration should be considered “dead code” and removed from the bundle. It works because function declarations have no side effects: they don’t update global variables, don’t work with DOM, etc. (Please don’t confuse function declarations with function calls; the latter may have side effects.)
Consider the first exported entity:
'bar'.toUpperCase(). This is a function call and it may have side effects. Well, we know that
toUpperCase is a pure function, meaning calling it has no side effects. However, minifiers know nothing about the standard library. That’s why the function call wasn't removed from the bundle so the code doesn’t break.
Now consider the second exported entity:
Math.PI * 2. This is an arithmetic operation (a constant is multiplied by two) and should not have any side effects. Now take a closer look: what does
Math.PI do? It denotes access to a property of an object and it may have side effects. Access to a variable is always pure, but access to a property may invoke a getter method that can do anything. Minifiers don’t know how pure that code is so they leave it intact. (Obviously, this logic applied to setters as well.)
How to make pure code tree-shakable?
If you know that some code is pure, there’s a way to tell minifiers about that. By convention, placing the
/* #__PURE__ */ comment before a function call or a constructor call (
new) marks them as pure. If you need to mark access to a property as pure, you can wrap it with a pure function:
Now it looks better, right?
Here are a few real-world examples of code purification from my contributions to open source:
- In Chart.js, tree-shaking didn’t work as expected because of assignments to class properties (replaced with static properties).
- In react-chartjs-2, some exported entities are constructed by calling a factory method (marked as pure).
You can experiment with dead code detection yourself in Terser REPL.
If you’re building a library, you should know about the
sideEffects property in the
package.json file. It's similar to
/* #__PURE__ */ but on a module level instead of a statement level. By setting it to
false, you can mark the entire code of your library as pure:
You can also set it to an array of files that contain side effects:
You can learn more about the
sideEffects property in the Webpack documentation. Please pay attention to this behavior: a package, marked as pure with
sideEffects: false, will be skipped entirely if imported as a whole.
However, if individual entities are imported, everything works as described in the previous section and you still need to use
/* #__PURE__ */:
Things to consider when using
- If your library code is already covered with
/* #__PURE__ */, using
sideEffects: falsewill have no effect.
- If your library provides global style sheets (not CSS modules), they should also be added to the array of files that contain side effects:
It’s required because such style sheets are usually imported directly:
- Beware when developing a library: bundlers will look for
sideEffects: falsenot only in the dependencies of your library but in its
Now we know how tree-shaking works in principle. However, we should also design a library in a tree-shakeable way. For instance, if the entire library is packed in a single class, tree-shaking wouldn’t be able to remove any code.
Here are the rules of tree-shakable design:
- All entities, including class methods, that might not be used should be exported separately (so they can potentially be removed from the bundle).
- Classes and objects should be exported cautiously (because their contents can’t be removed from the bundle).
- All code should be free of side effects by default. Minimal, if any, instances of non-pure code should be permitted.
Consider the following examples.
Imagine we’re writing an internationalization library:
🙅 In the non-tree-shakable variant, a single giant class is exported. The bundle will include all class methods, including unused ones.
🙆 In the tree-shakable variant, potentially unused class methods are exported separately as functions. The bundle will include only used functions.
Imagine we’re writing a data visualization library:
🙅 In the non-tree-shakable variant, all axis types should be imported inside
BarChart so it can use some of them according to the options provided. The bundle will include all classes for all axis types.
🙆 In the tree-shakable variant, only necessary axis types are imported and provided to
BarChart. The bundle will include classes only for used axis types.
(Full disclosure: this is a real-world design decision from Chartist, an open-source data visualization library I maintain and contribute to.)
🙆 In this tree-shakable implementation, dependency injection via
Chart.register is used. Library code is very well split into individual parts that are exported separately. The bundle will include classes only for used entities.
Keeping code tree-shakable
However, you can use the Size Limit library to control the size of a bundle or individual exports. Here’s an example configuration:
Size Limit integrates with CI such as GitHub Actions and will alert you if any pull request drastically increases the bundle size, possibly due to broken tree-shaking.
Please use tree-shaking in your code at all times. If you’d like to learn how tree-shaking works by example, check out this sandbox I’ve built.
If you have an existing application or library:
- make sure it complies with the three rules of tree-shakable design;
- make sure pure functions are marked as such with
/* #__PURE__ */;
- make sure Size Limit is used to notify if tree-shaking brakes.