However, I rarely see developers transpiling the code of their dependencies because everything seems to work fine without it, right? Wrong! Here’s why...
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.
Adoption of ESM
Before browsers and Node.js got native support for ES modules, an npm package could contain several variants of source code:
package.json(see this discussion for details).
Obviously, not every npm package follows this convention. It’s a choice that every author of a library makes on their own. However, it’s quite common for browser-side and universal libraries to be distributed in two variants.
Web browsers have natively supported ES modules for more than three years already, and Node.js supports them since version 12.20 (released in November 2020). That’s why authors of libraries now include one more variant of source code for execution environments that natively support ES modules, and many packages have completely removed the support for CommonJS.
Perks and features of ESM
It’s important to understand that native ES modules are very much different than modules that have ES6 imports/exports. Here are a few reasons:
The way we are used to using imports/exports will not work natively. Such code is intended for further processing by a bundler or a transpiler.
Native ES modules don’t support
indexfile name and extension resolution, so you have to specify them explicitly in the import path:
If an npm package has ES modules, you have to add
package.jsonand specify the source code location in the
exportsfield (see docs for details).
You can check this blog post by Axel Rauschmayer to learn more about ES modules.
Transpilation of dependencies’ code
In both cases, you need to transpile the code of the dependencies.
Let’s assume that we’re using webpack and babel-loader. Often, the config would look like this:
It’s suggested in the documentation and usage examples for Babel and
babel-loader to exclude
node_modules from transpilation (
exclude: /node_modules/) to optimize the performance.
By removing the
exclude rule, we’ll enable the transpilation of dependencies’ code in exchange for the increased bundling time. By providing a custom function as the
exclude rule, we also can transpile just a subset of all dependencies:
We can also select files by their filename extensions:
Manual transpilation — the benchmark
Let’s check how
babel-loader configuration affects the bundle size and bundling time. Consider an application with three very different dependencies:
- axios (regular ES5 code)
|No way to predict which web browsers will work with this bundle
target: defaults and supports es6-module
|To ES6 code. Private class fields will be downgraded, arrow functions and classes will remain as is
target: defaults with polyfills
|To ES5 code
You can see that the total bundling time for modern browsers and all browsers with
babel-loader is 8.7 s. Please also note that the basic, non-transpiled bundle won’t work with legacy browsers because of
(By the way, I also have a blog post that explains in detail how to build several bundles for different browsers.)
Okay, but what if you don’t want to tinker with configs and specify files and packages to be transpiled manually? Actually, there’s a readily available tool for that!
Transpilation with optimize-plugin
- It will transpile your application’s source code and the code of all dependencies.
- If needed, it will generate two bundles (for modern and legacy browsers) using the module/nomodule pattern.
- On top of that, it can also upgrade ES5 code to ES6 using babel-preset-modernize!
Let’s see what
optimize-plugin will do to our example application with three dependencies:
|To ES6 code. Also, to ES5 code with polyfills
|20 KB for modern browsers. 92 KB for legacy browsers (including 67 KB of polyfills)
The total bundling time with
optimize-plugin is 7.6 s. As you can see,
optimize-plugin is not only faster than
babel-loader, but it also produces a smaller bundle. You can check my results using the code from my optimize-plugin-demo repository.
Why optimize-plugin wins
The performance boost is possible because the code is analyzed and bundled only once. After that,
optimize-plugin transpiles it for modern and legacy browsers.
Smaller bundle size is possible thanks to babel-preset-modernize. Chances are that you use ES6+ features in your application’s code but you never can predict which features are used in the code of the dependencies. Since
optimize-plugin works with the bundle that already has the code of all dependencies, it can transpile it as a whole.
babel-preset-modernize works. Consider this code snippet:
After transpilation to ES6, we’ll get this code:
Here’s what has changed:
- A regular anonymous function was upgraded to an arrow function.
item.pricefield access was replaced with the function argument destructuring.
Code size shrinked from 221 to 180 bytes. Note that we applied only two transformations here but
babel-preset-modernize can do a lot more.
optimize-plugin works really great but it still has some room for improvement. Recently, I’ve contributed a few pull requests, including the support for webpack 5.
optimize-plugin looks promising to you, I encourage you to give it a try in your projects and maybe contribute some improvements as well.
Anyway, starting today, please always transpile the code of the dependencies, whether with
optimize-plugin or not, to make sure that you have full control over your application’s compatibility with modern and legacy browsers. Good luck!