The principle behind tree shaking is as follows:
- You declare all of your imports and exports for each of your modules
- Your bundler (Webpack, Rollup, and so on) analyzes your dependency tree during the compilation step
- Any provably unused code is dropped from the final bundle, or ‘tree-shaken’.
Unfortunately, tree-shaking isn’t quite as simple as just enabling an additional flag in your Webpack config and allowing it to take care of everything. There are many things you need to check and consider to ensure the best possible tree-shaking is happening, and to make sure that tree-shaking is not being skipped entirely for any of your modules.
There are many other guides for getting started and setting up tree-shaking. Here’s a good starting point for Webpack.
This article is geared towards Webpack, Babel, and Terser. That said, many of the principles listed here apply across the board, whether you’re using Webpack, Rollup, or anything else.
Use ES6 style imports and exports
export is the first essential step to allow tree-shaking to happen.
Most other module patterns, including commonjs and require.js, are non-deterministic at build time. This makes it impossible for bundlers like Webpack to determine exactly what is imported, exactly what is exported, and by extension, which code can be safely dropped.
With ES6 style
export statements, the capabilities of imports and exports are much more limited:
- You can only import and export at the module level — not inside functions
- Imports and exports must be static strings — no variables are allowed
- Whatever you are importing, must actually be exported from somewhere.
These simplified rules allow bundlers to deterministically figure out exactly who is importing and exporting what code, and by extension, which code is not being used at all.
Don’t allow Babel to transpile imports and exports
The first problem you may run into is: if you’re using Babel to transpile your code, all
export statements are, by default, transpiled down to commonjs. That forces Webpack to de-optimize, and fail to tree-shake.
Fortunately, this is a very straightforward thing to disable in your Babel config.
Once you’ve done that, Webpack will take over and transpile the imports and exports for you.
Make your exports granular and atomic
Webpack will generally leave exports fully intact. So if you’re:
- Exporting an object with many properties and methods
- Exporting a class with many methods
export defaultand including many things at once
Those exports will always be either fully included in the bundle, or fully tree-shaken. That means you may end up including a lot of code which is never used in the final bundle.
Instead, try to keep exports as small and simple as possible:
This gives Webpack more of a mandate to throw away code, because now it can track at build time exactly which of these functions are being imported and used, or not used.
Following this practice has the added benefit of encouraging more of a functional and reusable coding style, along with discouraging the use of classes when they’re not providing explicit value.
Avoid module-level side effects
One big but very subtle problem that many people miss when writing modules is the impact of side-effects at the module scope:
Notice in the above example, that
window.memoize will be called at the time when the module is imported.
Here’s how Webpack sees this:
- OK, they’ve created a pure exported function called
add— maybe I can remove this from the bundle, if nobody uses it later.
- Now they’re calling
window.memoizeand passing in the
- I have no idea what
window.memoizedoes, but I know there’s a possibility it could call
addand trigger a side-effect.
- So, to be safe, I’ll leave
addin the bundle, even if I don’t see it being used anywhere else.
In reality, we probably know that
window.memoize is a 100% pure function, which does not trigger any side-effects, and will only actually call
add if somebody calls
But Webpack does not know that, and so to be safe it must include
add in the bundle.
To be fair: The latest versions of Webpack and Terser do an extremely good job of detecting whether a side effect will actually be triggered or not. If we were to do the following instead:
Now Webpack has enough information to run the following logical steps:
- They’re calling
memoizeat the module level, that could be a problem
memoizeis an ES6 import, so let’s go take a look at that function in
- In reality, it looks like
memoizeis a pure function, so there’s actually no risk of any side-effects here
- So if nobody uses
addelsewhere in this codebase, we can safely drop it from the final bundle
However, in any cases where Webpack does not have enough information to make that decision, it takes the path of most safety, and refuses to tree-shake the function.
Use tooling to predict when a file can not be tree-shaken
There are two mechanisms using tooling I’ve found which really help here.
Firstly, I recommend using Webpack’s module concatenation plugin, which brings significant other performance benefits. That plugin comes with an option to debug concatenation bailouts on every build. Importantly, the same factors which prevent module concatenation (like module level side effects) also prevent tree-shaking. So treat any warnings from this module very seriously, since they will potentially result in bundle size increases.
Secondly, I recommend https://www.npmjs.com/package/eslint-plugin-tree-shaking. I haven’t integrated this module into
grumbler yet, since the last time I checked it doesn’t support flow types; but when I experimented with it, it worked extremely well in picking up tree-shaking bailouts.
Be careful with libraries
When you can, use tree-shakeable versions of libraries. If you’re importing a big bundle of minified code from a library, like
jquery.min.js, chances are that bundle will not be tree-shakeable. Better to find a module which gives you granular importable functions, then use Webpack or Rollup to bundle and minify everything.
Sometimes, you will need the entire bundle from a library. If you’re using React, for example, there’s virtually nothing shipped with the production build that you need to tree shake — everything included in the bundle is already as optimized as it can be.
But if you’re using a library that exports granular utility functions, like lodash, you should absolutely try to only import the functions you need and ensure the rest are tree-shaked.
Use build-time flags
A less well known feature of DefinePlugin in Webpack, is the ability to use it to decide which code to tree-shake during a build.
Now, if I pass
__PRODUCTION__: true to DefinePlugin, not only will the call to
validateOptions be dropped from the bundle, but the entire definition of the
validateOptions function will be tree-shaked too.
This makes it very easy to create different bundles for development and production, and be sure any development-only code and debugging functions are totally dropped from the final production build.
Run a build
As a general rule of thumb: predicting how Webpack will behave for a given module, is extremely difficult to do by eye.
So run a build, create a bundle, check the bundle, and see what actually happened. Go in and look at the final bundle of code, to see if there’s anything in there which should have been tree-shaken but wasn’t.
Got any other good tree-shaking practices? Leave them in the comments, and I’ll add them here!