Engineering

Building a tree-shakable library with RollupJS

There are multiple reason to pick RollupJS as your bundler of choice when creating a JavaScript library: a minimalistic configuration, built-in way to compile into various formats, and extensive tree-shaking support. Today we are going to look at tree-shaking from the perspective of a library author, rathen than a consumer.

What is tree-shaking?

Tree-shaking (also known as dead code elimination) is a bundling technique that allows to remove an unused code from the build. The unused code is the one nothing else depends on. That dependency relation is determined by a bundler/compiler on build time, making tree-shaking a build technique.

Tree-shaking is often approached on a consumer's level, meaning that our built application does not ship an unused code. While this is a perfectly valid concern, those third-party tools (i.e. open source libraries) we use must support tree-shaking as well, otherwise there's nothing we can do about a dead code.

This article will refer to any JavaScript application using a library as a consumer.

What do we want?

Let's say we are writing a library called my-lib that exports two functions: funcA and funcB. Neither of those can be tree-shaken when building our library, because we don't know which functions the constumer will require. However, we need to bundle our library the way that everything that the consumer doesn't use gets dropped.

Here's an example of what we expect from the source and built code of the consumer:

1// consumer/src/index.js
2// Although "my-lib" exports both "funcA" and "funcB",
3// since we only rely on "funcA", we expect "funcB"
4// to get tree-shaken (removed) from our application's build.
5import { funcA } from 'my-lib'
6
7funcA()
1// consumer/build/index.js
2;(function (factory) {
3 typeof define === 'function' && define.amd ? define(factory) : factory()
4})(function () {
5 'use strict'
6 function funcA() {
7 console.log('Hello from the module A!')
8 }
9
10 funcA()
11})

Notice how the funcB is nowhere to be found in the application's build. That is our end goal.

As natural as it may look, this is not the default behavior, and it requires a certain setup on both the library's and consumer's sides. So what do we, library authors, need to do in order to ship a tree-shaking support to our users?

Build format

First of all, we need to bundle our library using a proper build format.

Choosing a proper format is crucial, because certain formats make our library's dependencies impossible to statically analyze. For example, if we ship a library in a CommonJS format, a consumer won't be able to figure out which modules can be tree-shaken, because their dependnecies may change on runtime.

1// my-lib/build/cjs/index.js
2function funcA(moduleName) {
3 // Impossible to analyze what "funcA" depends on,
4 // because the "require" statement below will change
5 // depending on the "moduleName" argument.
6 require(`./utils/${moduleName}`)
7}
8
9module.exports = { funcA }

When a bundler (i.e. webpack or Rollup) transforms our library's code, it must meet static import statements (import) to determine which modules can be safely removed. Static import statements are such that cannot change on runtime (thus, "static").

In order for our library to be tree-shakable, it must preserve static import statements.

To preserve those imports we need to distribute our library using ESM format. ESM (ECMAScript Module) format comes with a static module structure, which means that a module's dependencies can be determined by looking at the code, without having to run it.

We can use a format output option in the Rollup configuration to build our library into ESM:

1// my-lib/rollup.config.js
2module.exports = {
3 // ...,
4 output: [
5 {
6 dir: 'library/build',
7 format: 'esm',
8 },
9 ],
10}

However, a right format is not enough for our library to become fully tree-shakable. We also need to specify a proper relation between the modules in our library by configuring its entry points.

Entry points

It's often for a code to be reused between the parts of a library. If such code comes from a module that you would want to tree-shake, it may get problematic. Consider this:

1// my-lib/src/a.js
2// Here we are importing a helper function from the module B,
3// making it a dependency of the module A (current module).
4import { print } from './b'
5
6export function funcA() {
7 print('Hello from module A!')
8}
1// my-lib/src/b.js
2// A helper function that semantically belongs to module B,
3// but can be imported and used in other modules as well.
4export function print(message) {
5 console.log(message)
6}
7
8export const funcB = () => {
9 print('Hello from module B!')
10}

With the a.js module importing the print function from b.js, the latter effectively becomes its dependency. Now, even if our consumer doesn't use funcB, they would still import and ship the entire b.js, because a.js relies on its helper function.

Surely, we can isolate the print function into its own module and reuse it between any other pieces of the library. However, this delegates dependency management into our hands, which makes it prone to mistakes. Instead, we can configure our library's entry points, so that Rollup figures out the cross-module dependencies for us.

To set multiple entries we need to provide a Rollup config with the input option that has a list of modules.

1// my-lib/rollup.config.js
2module.exports = {
3 input: ['src/index.js', 'src/a.js', 'src/b.js'],
4 // ...
5}

Such build configuration will not only generate separate chunks for each given input, but can also figure out their dependency between each other, putting a shared code into common chunks.

With the entry points configured, our build folder structure may look as follows:

1└─build
2 └─esm
3 ├─a.js
4 ├─a-deps.js # `print` from `src/b.js` would be here
5 └─b.js

Now, if the b.js module is never used by the consumer, it will get tree-shaken, because neither consumer, nor internal library's modules rely on it.

Distribution

Although ESM is the future of the modules distribution, we are not quite ready for that future yet. With that in mind, it's not a good decision to specify the ESM bundle as the main entry of your package. Instead, there's a dedicated module property in package.json that modern tools, like webpack and Rollup, can pick up and use.

Provide the path to your ESM build in the module property of your library's package.json:

1// my-lib/package.json
2{
3 // CommonJS build as default
4 "main": "lib/cjs/index.js",
5
6 // ESM build for modern bundlers
7 "module": "lib/esm/index.js"
8}

Summary

Building a tree-shakable library with RollupJS consists of these steps:

  1. Choose a proper build target format (ESM);
  2. Specify the library's entry points (to analyze their inter-dependencies);
  3. Provide the ESM build path as the module property of the library's package.json.

Below you can find a GitHub repository that contains a library setup and the example of a consumer application that is using that library. Follow the instructions in this repository to see the tree-shaking for yourself:

Fetching Redd-Developer/rollup-tree-shakable-library repository...

Afterword

Thanks for reading through! I hope you've learned a thing of two about JS modules and tree-shaking, and wish you to ship awesome libraries to your users! Let me know on Twitter about your experience with creating and shipping libraries.

Artem Zakharchenko

Artem Zakharchenko

@kettanaito

Hi! My name is Artem and I am a Full-stack JavaScript engineer, rock-n-roll musician and medical doctor.

If you like my material, please consider following me on Twitter to get notified when new posts are published, ask me a question and stay in touch.

Articles You May Enjoy

EngineeringMay 11, 2020

Efficient CircleCI debugging with SSH

Stop wasting hours on debugging failed CI jobs, when you can go to the very remote machine that run your tests, open its state, and solve the issue spot-on.