There’s a library called Polished. It’s a utility collection for writing styles in JavaScript.
And it had a problem.
Problem#
A story in three tweets:
Seems that import foo from "pkg/foo" still more effective than import {foo} from "pkg" with wepback 2 even in simple cases 🙁
— Valentin Semirulnik (@7rulnik) June 18, 2017
Yep. For example: polished. It's one file with export {}. 4kb vs 8kb.
— Valentin Semirulnik (@7rulnik) June 18, 2017
What side effects are you seeing in the bundle that might lead to this, would love to try to get the destructured import smaller.
— Brian Hough (@b_hough) June 19, 2017
So, this code:
import { opacify, transparentize } from 'polished';
generates a much larger bundle than this code:
import opacify from 'polished/lib/color/opacify.js';
import transparentize from 'polished/lib/color/transparentize.js';
even despite the Polished’s bundle is built with ES modules and tree-shaking is enabled.
Let’s find out what causes this.
Investigation#
1. Verify the entry point#
At first, let’s check that import { ... } from 'polished'
picks up a file written with ES exports. If it doesn’t, webpack can’t do any tree-shaking at all.
When you import a package, webpack understands what exact file to use by looking into specific fields in package.json
. Polished’s package.json
has two of them:
{
"name": "polished",
"description": "A lightweight toolset for writing styles in Javascript.",
"main": "lib/index.js", // This one
"module": "dist/polished.es.js", // And this one
...
}
Webpack prefers module
over main
. module
points to dist/polished.es.js
, and this file does have an ES export:
// polished/dist/polished.es.js
...
export { adjustHue$1 as adjustHue, ... };
This point is OK.
2. Check if there’s unused code that’s unnecessarily kept#
polished/dist/polished.es.js
is written with ES exports. This means that tree-shaking should work properly, and the unused imports shouldn’t be included into the bundle. Then why different imports produce different file sizes?
The most possible reason is that polished/dist/polished.es.js
contains some code that’s absent in our polished/lib/...
files and that can’t be simply dropped by the tree-shaker. This is the code that could cause side-effects. E.g. if a file includes a top-level function call, the tree-shaker can’t remove the function even if its result isn’t used. The function could be causing side effects, and removing it could break the app.
Let’s compare the bundles that we have after importing polished
in two different ways and verify this case.
To do this, I create a package:
# Shell
mkdir polished-test && cd polished-test
npm init -y
npm install polished webpack@2
Add two files that import Polished in two different ways:
console.log()
helps finding the index.js
file in the bundle + prevents webpack from removing the imports as unused// index-import-package.js
import { opacify, transparentize } from 'polished';
console.log('polished', opacify, transparentize);
// index-import-files.js
import opacify from 'polished/lib/color/opacify.js';
import transparentize from 'polished/lib/color/transparentize.js';
console.log('polished', opacify, transparentize);
Add a special webpack configuration that emits two bundles:
// webpack.config.js
const webpack = require('webpack');
module.exports = {
entry: {
// We’ll compare two different bundles,
// thus two different entry points
'bundle-import-package': './index-import-package.js',
'bundle-import-files': './index-import-files.js',
},
output: {
filename: '[name].js',
path: __dirname,
},
plugins: [
// We need to run UglifyJS to remove the dead code
// (this will do tree-shaking), but prevent it
// from uglifying the code (so it’s easier to read the bundle)
new webpack.optimize.UglifyJsPlugin({
// Disable several optimizations so that the bundle
// is easier to read
compress: { sequences: false, properties: false, conditionals: false, comparisons: false, evaluate: false, booleans: false, loops: false, hoist_funs: false, hoist_vars: false, if_return: false, join_vars: false, cascade: false },
// Beautify the bundle after uglifying it
beautify: true,
// Don’t rename the variables
mangle: false,
}),
]
}
And run the build:
./node_modules/.bin/webpack
Now, I have two bundles, each with a different approach to importing stuff. I open them in my editor and switch to the structure view to their content. And here’s what I see:
bundle-import-package.js
has more methods than bundle-import-files.js
. Most likely, they are kept because of calls with side effects. Let’s dig deeper.
3. Find the exact cause of the problem#
So, bundle-import-package.js
has a lot of functions that aren’t used but are still included. If we look through the file to see their usages, we’ll see a large snippet of code like this:
// bundle-import-package.js
// ...
function opacify(amount, color) {
// ...
}
var opacify$1 = curry(opacify);
function desaturate(amount, color) {
// ...
}
curry(desaturate);
function lighten(amount, color) {
// ...
}
curry(lighten);
// ...
Here, desaturate
and lighten
are those unused functions, and opacify
is a function we import in the client code.
This code comes to bundle-import-package.js
from polished/dist/polished.es.js
. The corresponding code in that file looks like this:
// polished/dist/polished.es.js
// ...
function opacify(amount, color) {
// ...
}
var opacify$1 = curry(opacify);
function desaturate(amount, color) {
// ...
}
var desaturate$1 = curry(desaturate);
function lighten(amount, color) {
// ...
}
var lighten$1 = curry(lighten);
// ...
And this code comes into polished/dist/polished.es.js
from the library sources. This is how it looks:
// polished/src/color/opacify.js
function opacify(amount: number, color: string): string {
// ...
}
export default curry(opacify);
// polished/src/color/desaturate.js
function desaturate(amount: number, color: string): string {
// ...
}
export default curry(desaturate);
// polished/src/color/lighten.js
function lighten(amount: number, color: string): string {
// ...
}
export default curry(lighten);
So what happens here? dist/polished.es.js
is built with Rollup. When the library authors do a build, Rollup grabs all the modules and converts exports (export default curry(lighten)
) into variable assignments (var lighten$1 = curry(lighten)
).
When we do import { opacify, transparentize } from 'polished'
, webpack tries to compile dist/polished.es.js
and drop the unused code. It removes the desaturate$1
and lighten$1
variables because they aren’t exported, but it can’t drop the curry(darken)
calls because curry
could produce side-effects. And because functions like desaturate
and lighten
are passed into curry()
, they are also kept in the bundle.
Solution#
To decrease the bundle size, we should do one of the following things:
- tell UglifyJS that it’s safe to remove
curry()
calls because it’s pure - or move currying into the functions instead of wrapping them.
compressor: { pure_funcs: ['curry'] }
to the UglifyJS options, but Polished can’t control thisTo tell UglifyJS that curry()
calls are safe to remove, we have to mark each call with the /*#__PURE__*/
annotation. This way, the minifier will understand that this call is pure and will be able to optimize it:
/*#__PURE__*/
annotation after export default
. Rollup seems to remove comments if they are placed in that position// polished/src/color/lighten.js
function lighten(amount: number, color: string): string {
// ...
}
- export default curry(lighten);
+ const curriedLighten = /*#__PURE__*/curry(lighten);
+ export default curriedLighten;
The second approach is to move currying into the functions body. With it, we should do something like this:
// polished/src/color/lighten.js
- function lighten(amount: number, color: string): string {
- // method body
- }
+ function lighten(...args) {
+ return applyCurried(function (amount: number, color: string): string {
+ // method body
+ }, args);
+ }
- export default curry(lighten);
+ export default lighten;
I prefer the first approach because it (almost) doesn’t complicate the code.
After adding the /*#__PURE__*/
annotations, minified bundle-import-package.js
goes from 16 down to 11.8 kB. But that’s not the end – bundle-import-files.js
is still smaller (9.86 kB). This is because there’re a few other places that should be optimized.
I’ll skip the part where I find them and jump right to the solution.
- Change 1 and 2. Like with
curry()
, there’re two other places where the export is wrapped into a function. It’spolished/src/helpers/em.js
andpolished/src/helpers/rem.js
. To optimize them, we should similarly add the/*#__PURE__*/
annotations. - Change 3. In
polished/src/mixins/normalize.js
, there’re two global objects that use computed object properties. When they are compiled, Babel transforms them to call the Babel’sdefineProperty
function. Because of this, UglifyJS can’t remove them. To solve the problem, we should either move these objects into thenormalize()
function that uses them or wrap them into getter functions.
And, when we apply these additional optimizations, we’ll have this:
Asset Size Chunks Chunk Names
bundle-import-files.js 9.87 kB 0 [emitted] bundle-import-files
bundle-import-package.js 7.76 kB 1 [emitted] bundle-import-package
bundle-import-package.js
is now even smaller than bundle-import-files.js
! Great.
I’ve submitted the pull request.
Interesting read! The followup questions from me would be:
1. is there any advantage in shipping module build as a single rolluped bundle? the only one i can imagine right now is that its creating a so called flat bundle – therefore keeping internals out of reach
2. would be nice to have explanation why the package is smaller (significantly) from the files bundle after your tweaks
as to my 1st question – it also allows rollup to inline babel helpers only once, instead of once per file
As to the first question.
This depends on the build approach we compare flat bundles with.
If we compare a “flat bundle” (like a bundle produced by Rollup) with a “non-flat bundle” (like a bundle produced by webpack), then the flat bundle will have an advantage in size and speed of execution. Webpack wraps each module into a function, and this adds extra boilerplate code.
If we compare a “flat bundle” with “non-bundled code” (like in the
polished/lib
directory), then, yes, the only advantage of the flat bundle I can think of is hiding the internals.Generally, from what I’ve seen, library maintainers prefer flat bundles because 1) for them, size and speed matters and 2) bundling code allows using the library in the browser.
As to the second question.
Looks like
bundle-import-package.js
is smaller thanbundle-import-files.js
because the former doesn’t include all the boilerplate code that’s present in the latter. The boilerplate code is additional function wrappers around modules (bundle-import-files.js
consists of multiple modules), additional internal imports (__webpack_require__
to import stuff between modules), etc. You can see the diff yourself.