A few months ago, I was tasked with upgrading our group’s vue.js project build configuration to Webpack 4. One of our main goals was to take advantage of tree-shaking, where Webpack reduces package size by removing code that is not actually used. Now, the benefits of tree-shaking will vary depending on your code base. Because of several architectural decisions we made, we pulled a lot of code from other libraries within the company, and we only used a fraction of it.
I wrote this article because optimizing Webpack properly is not easy. At first I thought it was simple magic, but then I spent a month searching the Internet for answers to a series of questions I came across. I hope this article will make it easier for others to deal with similar issues.
Say good first
Before getting into the technical details, let me summarize the benefits. Different applications will see different levels of benefit. The primary determinant is the amount of dead code in the application. If you don’t have a lot of dead code, then you don’t see much benefit from tree-shaking. There’s a lot of dead code in our project.
In our department, the biggest problem is the number of shared libraries. From simple custom component libraries, to enterprise standard component libraries, to large amounts of code that are inexplicably crammed into a library. A lot of it is technical debt, but a big problem is that all of our applications are importing all of these libraries, when in reality each application only needs a fraction of them
In general, once tree-shaking was implemented, our application was reduced from 25% to 75%, depending on the application. The average reduction rate was 52%, driven primarily by these large shared libraries, which are the main code in small applications.
Again, the specifics will vary, but if you feel like you might have a lot of unnecessary code in your package, here’s how to eliminate it.
There is no sample code repository
Sorry guys, the project I do is the property of the company, so I can’t share the code to the GitHub repository. However, I’ll provide simplified code examples in this article to illustrate my point.
So without further ado, let’s take a look at how to write the best WebPack 4 configuration for tree-shaking.
What is dead code
It’s simple: Webpack doesn’t see the code you’re using. Webpack keeps track of import/export statements for the entire application, so if it sees something imported that doesn’t end up being used, it thinks it’s “dead code” and tree-shaking it.
Dead code is not always clear cut. Here are some examples of dead and “live” code that hopefully will make sense to you. Keep in mind that in some cases Webpack treats something as dead code, even though it isn’t. See the side effects section to see how to deal with it.
// Import and assign values to JavaScript objects, which are then used in the following code // This is considered "live" code, not tree-shaking import Stuff from'./stuff';
doSomething(Stuff); // Import and assign values to JavaScript objects that are not used in the following code. // This is treated as "dead" code, and is tree-shaking import Stuff from'./stuff';
doSomething(); // Import but do not assign values to JavaScript objects, nor use them in code // This is considered "dead" code, and would be tree-shaking import'./stuff';
doSomething(); // Imports the entire library, but does not assign to JavaScript objects or use it in code. Strangely enough, this is considered "live" code, since Webpack treats library imports differently than native code imports. import'my-lib';
doSomething();
Copy the code
Write imports in tree-shaking support
The import approach is very important when writing code that supports tree-shaking. You should avoid importing the entire library into a single JavaScript object. When you do this, you’re telling Webpack that you need the entire library, and Webpack won’t shake it.
Take the popular Lodash library for example. It’s a big mistake to import the entire library at once, but it’s much better to import individual modules. Of course, There are other steps Lodash needs to take to do tree-shaking, but this is a good starting point.
// Import all (tree-shaking not supported) import _ from'lodash'; Import {debounce} from'lodash'; // Import debounce from modules directly (tree-shaking supported)'lodash/lib/debounce';
Copy the code
Basic Webpack configuration
The first step in tree-shaking with Webpack is to write a Webpack configuration file. You can do a lot of customization for your Webpack, but if you want to tree-shaking your code, you need the following.
First, you have to be in production mode. Webpack is tree-shaking only when it is compressing code, and this only happens in production mode.
Second, the optimization option “usedExports” must be set to true. This means that Webpack will identify code that it thinks is not being used and mark it up during the initial packaging step.
Finally, you need to use a compressor that supports removing dead code. This compressor will recognize how Webpack marks code that it believes is not being used and strip it away. The TerserPlugin supports this feature and is recommended.
Here is the basic configuration for Webpack to enable tree-shaking:
// Base Webpack Config for Tree Shaking
const config = {
mode: 'production',
optimization: {
usedExports: true,
minimizer: [
new TerserPlugin({...})
]
}
};
Copy the code
What are the side effects?
Just because Webpack can’t see a piece of code in use doesn’t mean it’s safe to tree-shaking. Some module imports, once introduced, can have a significant impact on an application. A good example is a global stylesheet, or JavaScript file that sets global configuration.
Webpack considers such files to have “side effects.” Files with side effects should not be tree-shaking, as this will break the entire application. The designers of Webpack are well aware of the risks of packaging code without knowing which files have side effects, so by default all code is treated as having side effects. This protects you from deleting necessary files, but it means that the default behavior of Webpack is actually not tree-shaking.
Fortunately, we can configure our project to tell Webpack that it has no side effects and can be tree-shaking.
How do I tell Webpack that your code has no side effects
Package. json has a special property, sideEffects, that exists for this purpose. It has three possible values:
True is the default if no other value is specified. This means that all files have the side effect of not having a file to tree-shaking.
False tells Webpack that no files have side effects and that all files are tree-shaking.
The third value […] Is an array of file paths. It tells WebPack that there are no side effects for any of your files other than those contained in the array. Therefore, except for the specified files, all files can be tree-shaking safely.
Each project must have the sideEffects property set to false or an array of file paths. At my company, our base application and all of the shared libraries I mentioned require the sideEffects flag to be configured correctly.
Here are some code examples of the sideEffects flag. Despite the JavaScript comments, this is the JSON code:
// All files have side effects, all are not tree-shaking {"sideEffects": true} // No files have side effects, all can tree-shaking {"sideEffects": false} // Only these files have side effects, all other files can be tree-shaking, but these files are kept {"sideEffects": [
"./src/file1.js"."./src/file2.js"]}Copy the code
Global CSS with side effects
First, let’s define global CSS in this context. Global CSS is a style sheet (CSS, SCSS, and so on) imported directly into a JavaScript file. It’s not being converted into CSS modules or anything like that. Basically, the import statement looks like this:
// Import global CSS import'./MyStylesheet.css';
Copy the code
Therefore, if you make the side effect changes mentioned above, you will immediately notice a thorny problem when running the WebPack build. Any style representations imported in the above manner will be deleted from the output. This is because such imports are considered dead code by WebPack and removed.
Fortunately, there is a simple solution to this problem. Webpack uses its modular rules system to control the loading of various types of files. Each rule for each file type has its own sideEffects flag. This overrides any sideEffects flags previously set for files that match the rules.
So, to preserve the global CSS file, we just need to set the special sideEffects flag to true, like this:
// Webpack configuration related to global CSS side rules const config = {module: {rules: [{test: /regex/,
use: [loaders],
sideEffects: true}}};Copy the code
All module rules in Webpack have this property. Rules dealing with global stylesheets must use it, including but not limited to CSS/SCSS/LESS/, etc.
What is a module and why is a module important
Now we begin to enter the secret. On the surface, compiling the right module type may seem like a simple step, but as the next sections explain, this is an area that can lead to many complex problems. That’s the part that took me a long time to figure out.
First, we need to look at modules. Over the years, JavaScript has developed the ability to effectively import/export code as “modules” between files. There are many different JavaScript module standards that have existed for many years, but for the purposes of this article, we’ll focus on two. One is “CommonJS” and the other is “ES2015”. Here’s what they look like in code:
// Commonjs
const stuff = require('./stuff');
module.exports = stuff;
// es2015
import stuff from './stuff';
export default stuff;
Copy the code
By default, Babel assumes that we write code using es2015 modules and transform JavaScript code to use commonJS modules. This is done for broad compatibility with server-side JavaScript libraries that are typically built on top of NodeJS (NodeJS only supports CommonJS modules). However, Webpack does not support tree-shaking using the CommonJS module.
Now, there are plug-ins (such as common-shake-plugin) that claim to give Webpack the ability to tree-shaking commonJS modules, but in my experience these plug-ins either don’t work or when running on ES2015 modules, The impact on tree-shaking is minimal. I don’t recommend these plug-ins.
Therefore, in order to tree-shaking, we need to compile the code into the ES2015 module.
Es2015 module Babel configuration
As far as I know, Babel does not support compiling other module systems into ES2015 modules. However, if you’re a front-end developer, you’re probably already writing code using es2015 modules, as this is the fully recommended approach.
So, in order for our compiled code to use es2015 modules, all we need to do is tell Babel to leave them alone. To do this, we simply add the following to our babel.config.js (in this article, you’ll see that I prefer the JavaScript configuration to the JSON configuration) :
// es2015 module Babel configuration const config = {presets: [['[@babel/preset-env](http://twitter.com/babel/preset-env)',
{
modules: false}}]].Copy the code
Setting modules to false tells Babel not to compile module code. This will allow Babel to retain our existing ES2015 import/export statements.
** Highlight: ** All tree-shaking code must be compiled this way. Therefore, if you have libraries to import, you must compile them into ES2015 modules for tree-shaking. If they are compiled as CommonJS, they cannot be tree-shaking and will be packaged into your application. Many libraries support partial imports. A good example is LoDash, which is itself a CommonJS module, but has a version of LoDash-ES that uses the ES2015 module.
In addition, if you use internal libraries in your application, you must also compile using es2015 modules. To reduce the size of the application package, all of these internal libraries must be modified to compile in this way.
Sorry, Jest is on strike
Other testing frameworks are similar, and we used Jest.
Anyway, if you get this far, you’ll find that Jest tests start to fail. You’ll see all sorts of weird errors in the log, just like I did. Don’t panic. I’ll take you step by step.
The reason for this is simple: NodeJS. Jest is based on NodeJS, which does not support ES2015 modules. There are ways to configure Node for this, but it doesn’t work on Jest. So, we’re stuck here: Webpack needs ES2015 for tree shaking, but Jest can’t perform tests on these modules.
That’s why I say I’ve entered the “secret realm” of modular systems. This was the part of the process that took me the most time to figure out. I encourage you to read this section and the following sections carefully, as I will give you solutions.
There are two main parts to the solution. The first part is the code for the project itself, which is the code to run the tests. This part is the easy part. The second part is for library code, that is, code from other projects that is compiled into es2015 modules and introduced into the current project. This part is more complicated.
Resolve project native Jest code
For our problem, Babel has a very useful feature: environment options. It can be configured to run in different environments. Here, we need es2015 modules for development and production environments, and commonJS modules for test environments. Fortunately, Babel is very easy to configure:
// Babel const config = {env: {development: {presets: [['[@babel/preset-env](http://twitter.com/babel/preset-env)',
{
modules: false
}
]
]
},
production: {
presets: [
[
'[@babel/preset-env](http://twitter.com/babel/preset-env)',
{
modules: false}}]].test: {
presets: [
[
'[@babel/preset-env](http://twitter.com/babel/preset-env)',
{
modules: 'commonjs'
}
]
],
plugins: [
'transform-es2015-modules-commonjs' // Not sure this is required, but I had added it anyway
]
}
}
};
Copy the code
Once set up, all project native code compiles normally and Jest tests run. However, third-party library code using es2015 modules still does not run.
Address library code in Jest
The reason why the library code runs wrong is obvious, as can be seen from a glance at the node_modules directory. The library code here uses ES2015 module syntax for tree-shaking. These libraries have already been compiled this way, so when Jest tries to read the code in unit tests, it blows up. Notice that we’ve already asked Babel to enable commonJS modules in our test environment. Why doesn’t it work with these libraries? This is because Jest (especially babel-Jest) ignores any code from node_modules by default when compiling code before running tests.
That’s actually a good thing. If Jest had to recompile all the libraries, this would have greatly increased the test processing time. However, while we don’t want it to recompile all the code, we do want it to recompile libraries that use ES2015 modules so it can be used in unit tests.
Fortunately, Jest provides a solution in its configuration. I have to say, this part did give me a lot of thought, and I didn’t feel like it needed to be so complicated, but it was the only solution I could think of.
Configure Jest to recompile the library code
// recompile Jest configuration of library code const path = require('path');
const librariesToRecompile = [
'Library1'.'Library2'
].join('|');
const config = {
transformIgnorePatterns: [
`[\\\/]node_modules[\\\/](?!(${librariesToRecompile})).*$`
],
transform: {
'^.+\.jsx? $': path.resolve(__dirname, 'transformer.js')}};Copy the code
The above configuration is required for Jest to recompile your library. There are two main parts, and I’ll explain them one by one.
TransformIgnorePatterns is a feature of the Jest configuration, which is an array of regular strings. Any code that matches these regular expressions will not be recompiled by babel-jest. The default is a string “node_modules”. This is why Jest does not recompile any library code.
When we provide custom configuration, we tell Jest how to ignore the code when recompiling. That’s why the perverted regular expression you just saw has a negative-ahead assertion in it, in order to match all code except the library. In other words, we tell Jest to ignore all code in node_modules except for the specified library.
This proves once again that the JavaScript configuration is better than the JSON configuration, because I can easily insert array concatenations of library names into regular expressions through string manipulation.
The second is the Transform configuration, which points to a custom Babel-Jest converter. I’m not 100% sure this is required, but I added it anyway. Set it up to load our Babel configuration when all the code is recompiled.
Const babelJest = require(const 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);
Copy the code
With all this configured, your test code should run again. Keep in mind that any ES2015 module that uses the library needs to be configured this way, otherwise the test code won’t run.
Npm/Yarn Link is the devil
Next comes another pain point: link libraries. The process of using the NPM/YARN link is to create a symbolic link to the local project directory. It turns out that Babel throws a lot of errors when recompiling libraries linked in this way. One of the reasons it took me so long to figure out what happened to Jest was that I kept linking my library this way and made a bunch of mistakes.
The solution is: do not use NPM/YARN Link. With a tool like “YALC,” it can connect to local projects while emulating the normal NPM installation process. Not only does it have no Babel recompilation problems, it also handles transitive dependencies better.
Optimizations for specific libraries.
If you do all of the above, your application is basically implementing a fairly robust Tree shaking. However, there are a few things you can do to further reduce package sizes. I’ll list a few library-specific optimizations, but by no means all of them. In particular, it can inspire us to do something cooler.
MomentJS is a notoriously large library. Fortunately, it can eliminate multilingual packages to reduce volume. In the following code example, I have excluded all of Momentjs’s multilingual packages, leaving only the basic parts, which are significantly smaller.
// Remove multilingual package const {IgnorePlugin} from with IgnorePlugin'webpack';
const config = {
plugins: [
new IgnorePlugin(/^\.\/locale$/, /moment/)
]
};
Copy the code
Moment-timezone is The older cousin of MomentJS and a big guy. Its size is basically the result of a very large JSON file with time zone information. I found that by keeping the year data from this century, I could reduce the volume by 90%. This requires a special Webpack plug-in.
// MomentTimezone Webpack Plugin
const MomentTimezoneDataPlugin = require('moment-timezone-data-webpack-plugin');
const config = {
plugins: [
new MomentTimezoneDataPlugin({
startYear: 2018,
endYear: 2100
})
]
};
Copy the code
Lodash is another big bloat that causes file packages to swell. Fortunately, there is an alternative package, LoDash-es, which is compiled as an ES2015 module and bears the sideEffects logo. Replacing Lodash with it reduces the package size even further.
Additionally, Lodash-es, React-Bootstrap, and other libraries can be slimmed down with the help of the Babel Transform Imports plug-in. The plug-in reads import statements from the library’s index.js file and points them to specific files in the library. This makes it easier for WebPack to tree shaking libraries when parsing module trees. The following example demonstrates how it works.
// Babel Transform Imports
// Babel config
const 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}}]]}; // These libraries no longer support full imports, otherwise an error is reported with import _ from'lodash-es'; // Named imports still support import {debounce} from'loash-es'; // But these named imports are compiled by Babel like this // import debounce from'lodash-es/debounce';
Copy the code
conclusion
This is the end of the article. This optimization can greatly reduce the size of the package. As front-end architectures take on new directions (such as microfronts), maintaining package size optimization is more important than ever. Hopefully this article will help those of you who are tree shaking applications.
The original
communication
Welcome to scan the code to follow the wechat public number “1024 translation station”, to provide you with more technical dry goods.