There’s a library called Polished. It’s a utility collection for writing styles in JavaScript.

And it had a 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';

About tree-shaking even despite the Polished’s bundle is built with ES modules and tree-shaking is enabled.

Let’s find out what causes this.

1. Verify the entry point#

[email protected] and [email protected] ( [email protected] gives the same result) Environment:andgives the same result) 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?

Side effect is when a function changes something outside of itself – e.g. writes a value to a global variable or initiates a network request 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 [email protected]

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.

To decrease the bundle size, we should do one of the following things:

Pure function is a function that doesn’t produce side effects tell UglifyJS that it’s safe to remove curry() calls because it’s pure

calls because it’s pure or move currying into the functions instead of wrapping them.

Another option is passing compressor: { pure_funcs: ['curry'] } to the UglifyJS options, but Polished can’t control this To 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:

We can’t just add the /*#__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’s polished/src/helpers/em.js and polished/src/helpers/rem.js . To optimize them, we should similarly add the /*#__PURE__*/ annotations.

Like with , there’re two other places where the export is wrapped into a function. It’s and . To optimize them, we should similarly add the 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’s defineProperty function. Because of this, UglifyJS can’t remove them. To solve the problem, we should either move these objects into the normalize() 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.

Related