The story about a few imports

Eryk Napierała - May 20 '20 - - Dev Community

We all care about the performance of web applications that we build. We try to keep production bundles small and loading times low. That's a good thing! It definitely is for the user, who gets a great experience. But is it good for the developer? When we want the app to work fast, does it mean that creating it has to be slow? Can we still use external libraries and packages from NPM? Or do we have to write everything from scratch, counting each line of code?

Making a webpage fast may seem like a sacrifice from the developer's point of view. How could you keep a JavaScript bundle below 50 kB when almost any popular library or framework takes half of that budget or even exceeds it? There is a way to find a compromise and keep both performance and easiness given by the ecosystem. Everything we need is the right set of tools.

The story

Let's say we're writing dev.to news listing. For each article fetched from API we're supposed to display a title, beginning of the content, and a list of tags. Tags are returned from the API as a string, using a comma as a separator, so there are some transformations needed to parse and render them.

This code is fairly simple, but it may be even more readable when written using predefined functions, like those form lodash library. For many developers lodash is the very first choice when it comes to finding a comprehensive set of useful functions speeding-up the development.

import _ from "lodash/fp";

const renderTags = _.pipe(
  _.split(","),
  _.map(_.trim),
  _.reject(_.isEmpty),
  _.map(tag => <li className={styles.tag}>{tag}</li>)
);
Enter fullscreen mode Exit fullscreen mode

That looks quite neat! But there is a problem - bundle size increased from 12.5 kB to almost 94 kB 😱 Even if code quality could be considered as significantly better, such change would be unacceptable because it simply harms the user.

When we dig into the production bundle in Webpack Stats Explorer, we can see a few modules were added, but there is one that should attract our attention - lodash.min.js. It takes almost 70 kB, the majority of our bundle!

Webpack Stats Explorer showing added lodash library in the final bundle
Click on the screenshot to launch an interactive version of Webpack Stats Explorer

It turns out that by default, no matter how many functions we actually use from lodash, the whole library is sent down to the user. How to fix it? Named imports are the answer. Instead of importing the whole _ object, we could specify each function we use by name. In the process called "tree shaking", Webpack will extract only the code that we need.

Webpack Stats Explorer showing default import changed to named

There is some issue with this solution, though. lodash isn't really tree-shaking-friendly package, so by default switching to named imports changes nothing. To make it work as expected, we have to import each function from separate file.

import { pipe, map } from 'lodash/fp';
Enter fullscreen mode Exit fullscreen mode

becomes

import pipe from 'lodash/fp/pipe';
import map from 'lodash/fp/map';
Enter fullscreen mode Exit fullscreen mode

But this is a huge sacrifice, isn't it? The code doesn't look concise anymore and we start relying on the internal structure of lodash package instead of public API. Fortunately, instead of altering the code manually, it's enough to add a dedicated Babel plugin - babel-plugin-lodash and everything just works. We can keep using the named imports syntax.

{
  "presets": [
    "@babel/preset-env",
    "babel-preset-preact"
  ],
  "plugins": ["babel-plugin-lodash"]
}
Enter fullscreen mode Exit fullscreen mode

The plugin does the trick - bundle size goes down by 34 kB. Webpack Stats Explorer shows, that instead of one big file, the bundle contains a lot of small modules. And those are the only ones we actually need.

Webpack Stats Explorer showing the bundle shrinking after adding babel-plugin-lodash

So the bundle is now 57 kB. Is that good enough? Comparing to 12,5 kB we had before - not necessarily. There is another tool that may help - lodash-webpack-plugin.

const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
const WebpackEnhancedStatsPlugin = require('webpack-enhanced-stats-plugin');

module.exports = {
  plugins: [
    new LodashModuleReplacementPlugin({ currying: true }),
    new WebpackEnhancedStatsPlugin({
      filename: 'stats.json'
    }),
Enter fullscreen mode Exit fullscreen mode

Without any changes to the application code, it shrinks the bundle by another 23 kB. What kind of sorcery is this?! The whole trick is based on substituting some of the internal library functions with simpler alternatives or even no-ops. There are plenty of options available but as our code is fairly simple, we need nothing more than currying.

Webpack Stats Explorer showing how lodash-webpack-plugin wipes some internal modules out

After all those exertions, we managed to shrink the bundle to 34 kB - that's not bad. But it was 12,5 kB before. Is tripling the bundle size justified by better code readability and extensibility? I doubt! Fortunately, we can do better than that. lodash isn't the only library containing utility functions available on NPM and definitely not the tiniest one. nanoutils may be a very decent drop-in replacement. This library helped me a lot in my daily job and I can recommend it to all searching for a utility package that doesn't damage the user experience.

Webpack Stats Explorer showing lodash replaced with nanoutils

When we simply remove lodash with all build-time plugins and use raw nanoutils, the package shrinks by 4 kB. That's already a success, but not so impressive! We can do more than that. Similarly to lodash, by default nanoutils isn't tree-shakeable so we can shrink the bundle even more with a Babel plugin.

{
  "presets": [
    "@babel/preset-env",
    "babel-preset-preact"
  ],
  "plugins": [
    ["babel-plugin-transform-imports", {
      "nanoutils": {
        "transform": "nanoutils/lib/${member}",
        "preventFullImport": true
      }
    }]
  ]
}
Enter fullscreen mode Exit fullscreen mode

Finally, the bundle has a size no bigger than 13,26 kB. It's only 700 B increase when comparing to the very first version which doesn't use any library. That looks more like a cost we can afford to increase code quality and not feel guilty about breaking user experience.

Webpack Stats Explorer showing results of applying Babel plugin enabling tree-shaking for nanoutils library

Conclusions

What lesson does the story tell to us? It is possible to have both performant (at least in terms of bundle size) and elegant code. There are a couple of things I wish you to remember.

Tree-shaking and named imports

Tree-shaking is one of the greatest ideas since the sliced bread, at least in the world of web bundlers. It's supported by Webpack, but also Rollup and Parcel. To take advantage of tree-shaking, you should use named imports in favor of default one. Unless API of the library requires otherwise (ex. because it uses this under the hood), always write

import { foo } from 'lib';

foo();
Enter fullscreen mode Exit fullscreen mode

instead of

import obj from 'lib';

obj.foo();
Enter fullscreen mode Exit fullscreen mode

Make this syntax your new default.

Build and analytic tools

A vast amount of modern libraries published to NPM is tree-shaking friendly. Unfortunately, for many of them, it isn't enabled by default. Use tools like Webpack Bundle Analyzer and Webpack Stats Explorer to dig deep inside your production bundle and get to know what's exactly in it. If you find modules or pieces of code you suspect you don't need, try to use plugins like babel-plugin-transform-imports to get rid of them.

Drop-in library replacements

For many packages, it's easy to find significantly smaller counterparts with similar functionality and API surface. It's very often the case for utility libraries, but also view frameworks. Think of Preact created to substitute React. To estimate the size of the package before adding it to your project, you can use Bundlephobia. For some libraries, the bottom section provides a shortlist of alternatives, which is also super helpful!

That's it! I hope you enjoyed the article and will have an opportunity to apply the described ideas to real web applications. Feel free to reach me in the comments if you have any questions!

Disclaimer

I am a creator of Webpack Stats Explorer - a free-to-use, open-source tool for developers who care about the performance of their web applications. Recently I also made some minor contributions to nanoutils.

. . . . . . . . . . .