Image for post
Image for post

How to Fully Optimize Webpack 4 Tree Shaking

We reduced our bundle sizes by an average of 52%

Intro

A few months back I was tasked with upgrading my team’s React build configuration to Webpack 4. One of our main goals was to take advantage of tree-shaking, where Webpack strips out code that you’re not actually using to reduce your bundle size. Now, the benefits of tree shaking will vary depending on your codebase. Because of several architectural decisions in ours, we had a LOT of code being pulled in from other libraries created at our company, of which we were only using a small fraction of.

The Benefits

Before going into the technical details, let me summarize the benefits. Different applications will see different levels of benefits with this exercise. The main deciding factor is the amount of dead, tree-shakable code in your application. If you don’t have much, then you won’t see much of a benefit from tree-shaking. We had a LOT of dead code in ours.

No Code Repo

I’m so sorry folks, the project I worked on is the property of my company, so I cannot share a link to a GitHub repo with the code. However, I will be providing stripped-down code examples throughout this article to illustrate the points I am making.

What is Considered Dead Code

This is simple: it’s code that Webpack can’t see you using. Webpack follows the trail of import/export statements throughout the application, so if it sees something being imported but ultimately not being used, it considers that to be “dead code”, and will tree-shake it.

// Importing, assigning to a JavaScript object, and using it in the code below
// This is considered "live" code and will NOT be tree-shaken
import Stuff from './stuff';doSomething(Stuff);// Importing, assigning to a JavaScript object, but NOT using it in the code below
// This is considered "dead" code and will be tree shaken
import Stuff from './stuff';doSomething();// Importing but not assigning to a JavaScript object and not using it in the code
// This is considered to be "dead" code and will be tree shaken
import './stuff';doSomething();// Importing an entire library, but not assigning to a JavaScript object and not using it in the code
// Oddly enough, this is considered to be "live" code, as Webpack treats library imports differently from local code imports
import 'my-lib';doSomething();

Writing Tree-Shakable Imports

When writing tree-shakable code, your imports are important. You should avoid importing entire libraries into a single JavaScript object. When you do so, you are telling Webpack that you need the whole library, and Webpack will not tree shake it.

// Import everything (NOT TREE-SHAKABLE)import _ from 'lodash';// Import named export (CAN BE TREE SHAKEN)import { debounce } from 'lodash';// Import the item directly (CAN BE TREE SHAKEN)import debounce from 'lodash/lib/debounce';

Basic Webpack Configuration

The first step to tree-shaking with Webpack is to write up your webpack config file. There are lots of customizations you can make to your webpack configuration, but the following items are required if you want to tree-shake your code.

// Base Webpack Config for Tree Shakingconst config = {
mode: 'production',
optimization: {
usedExports: true,
minimizer: [
new TerserPlugin({...})
]
}
};

What Are Side Effects

Just because Webpack can’t see a piece of code being used, doesn’t mean it can be safely tree-shaken. Some imports, simply by being included in the bundle, have an important impact on the application. A good example of this are global stylesheets, or a JavaScript file that sets up some global configuration.

How to Tell Webpack Your Code is Side-Effect Free

There is a special property in the package.json called “sideEffects” that exists just for this purpose. It has three possible values:

// All files have side effects, and none can be tree-shaken{
"sideEffects": true
}
// No files have side effects, all can be tree-shaken{
"sideEffects": false
}
// Only these files have side effects, all other files can be tree-shaken, but these must be kept{
"sideEffects": [
"./src/file1.js",
"./src/file2.js"
]
}

Global CSS and Side Effects

First, let’s define global CSS in this context. Global CSS is a stylesheet (could be CSS, SCSS, LESS, etc) that is directly imported into a JavaScript file. It is NOT converted into a CSS Module or anything like that. Basically, the import statement looks like this:

// Global CSS importimport './MyStylesheet.css';
// Webpack config for global CSS side effects ruleconst config = {
module: {
rules: [
{
test: /regex/,
use: [loaders],
sideEffects: true
}
]
}
};

What Are Modules, and Why Do They Matter

Now we start to enter the realm of “there be dragons”. On the surface, compiling to the right module type seems like an easy step, but as the next several sections will explain this is an area that leads to many complications. This is the part of the process that took me the longest to figure out.

// Commonjs import/export module statementsconst stuff = require('./stuff');module.exports = stuff;// es2015 import/export module statementsimport stuff from './stuff';export default stuff;

Configuring Babel For es2015 Modules

Babel does not, to my knowledge, support compiling other module systems into es2015 modules. However, I would say if you’re a frontend developer reading this, you’re probably already writing code using es2015 modules, as it is the recommended way across the board.

// Basic babel config for es2015 modulesconst config = {
presets: [
[
'@babel/preset-env',
{
modules: false
}
]
]
};

And Now Jest Blows Up

This may apply to other testing frameworks, but Jest is what we were using.

Fixing Your Local Code for Jest

Babel has a helpful feature for our problem: environments. It lets us configure settings to run in different environments. In this case, we need es2015 modules in development and production, but commonjs modules in test. Fortunately, this is very easy to configure in our Babel config:

// Babel config for separate environments, we move the "preset" to the "env" sectionconst config = {
env: {
development: {
presets: [
[
'@babel/preset-env',
{
modules: false
}
]
]
},
production: {
presets: [
[
'@babel/preset-env',
{
modules: false
}
]
]
},
test: {
presets: [
[
'@babel/preset-env',
{
modules: 'commonjs'
}
]
],
plugins: [
'transform-es2015-modules-commonjs' // Not sure this is required, but I had added it anyway
]
}
}
};

Fixing Your Library Code for Jest

The reason the libraries are problematic is obvious with a single glance at our node_modules folder. Looking at the library code there, we will see the es2015 module syntax that we need for tree shaking. These libraries have already been compiled in this way, so when Jest tries to read them to run a unit test for one of our local code files, it will blow up. Interestingly, we’ve been able to enable commonjs modules for tests with Babel for our local code, yet that isn’t affecting these libraries. This is because, by default, Jest (specifically babel-jest) will ignore any code from node_modules when it is compiling our code prior to running the tests.

Configuring Jest to Recompile Libraries

// Jest configuration to recompile librariesconst path = require('path');const librariesToRecompile = [
'Library1',
'Library2'
].join('|');
const config = {
transformIgnorePatterns: [
`[\\\/]node_modules[\\\/](?!(${librariesToRecompile})).*$`
],
transform: {
'^.+\.jsx?$': path.resolve(__dirname, 'transformer.js')
}
};
// Babel-Jest Transformerconst babelJest = require('babel-jest');
const path = require('path');
const cwd = process.cwd();
const babelConfig = require(path.resolve(cwd, 'babel.config'));
module.exports = babelJest.createTransformer(babelConfig);

Npm/Yarn Link = The Devil

Now for the next pain point: linking libraries. The process of using npm/yarn link to link a library simply creates a symlink to the local project directory. It turns out that Babel throws a lot of errors when recompiling libraries that are symlinked this way. One of the reasons it took me so long to figure out the Jest stuff is because I had been linking all my libraries and seeing those errors.

Library-Specific Optimizations

If you do all of the above actions, you will have an application that is starting to really implement some robust tree shaking. However, to get even more out of your bundle size, there are other things you can do. I’m going to include some of the optimizations for specific libraries that I implemented here, but this is by no means an exhaustive list. It can especially serve as inspiration for some of the cool things we can do.

// MomenJS with locales stripped out by IgnorePluginconst { IgnorePlugin } from 'webpack';const config = {
plugins: [
new IgnorePlugin(/^\.\/locale$/, /moment/)
]
};
// MomentTimezone Webpack Pluginconst MomentTimezoneDataPlugin = require('moment-timezone-data-webpack-plugin');const config = {
plugins: [
new MomentTimezoneDataPlugin({
startYear: 2018,
endYear: 2100
})
]
};
// Babel Transform Imports// Babel configconst config = {
plugins: [
[
'transform-imports',
{
'lodash-es': {
transform: 'lodash/${member}',
preventFullImport: true
},
'react-bootstrap': {
transform: 'react-bootstrap/es/${member}', // The es folder contains es2015 module versions of the files
preventFullImport: true
}
}
]
]
};
// Full imports of these libraries are no longer allowed and will cause errorsimport _ from 'lodash-es';// Named imports are still allowedimport { debounce } from 'loash-es';// However, these named imports get compiled by Babel to look like this// import debounce from 'lodash-es/debounce';

Conclusion

And there you have it. Optimizations like these can lead to drastically smaller bundle sizes. As frontend architecture starts moving in new directions (such as micro-frontends), keeping your bundle size optimized becomes more important than ever. I hope this guide proves helpful to folks seeking to implement tree shaking in your own applications.

Written by

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store