gots blog

Tree-Shaking and Custom Libraries

The Javascript universe has the big advantage that you get to results fast. Just choose a library that already implements a UI component, add it to the dependency list and run it. You also want to use some custom libraries because they include your design system. After adding a couple of libraries and dependencies you realize that your application has a bundle size of 600 kB. That happened to me. It was quite fast that I found the main issue for that: custom libraries. They are not under the audit of the open-source community and therefore there is lots of space for improvement if you don't know how tree-shaking works.

There are three important aspects to look at when creating custom libraries:

  • The library output should transpile to a recent ES version
  • It should support CommonJS and ES Modules
  • The library should not have any side effects
  • Try to avoid using submodules

Library Output

When you are creating the library and configuring the bundler or Typescript, you need to make sure that you are configuring it to transpile into a newer format like ES2020. Important is, that this format produces ES Modules because of it's ability to be tree-shakeable. You might think that you also want to support older browsers and therefore you need to compile to e.g. ES5. Please keep in mind, that you include your library into other projects and it's actually the project's bundler which takes your library and transpiles that into a format better suitable for the browser. In Typescript the following configuration would be enough.

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ES2020",
    "declaration": true,
    "outDir": "./lib/esm",
    "strict": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "moduleResolution": "node"
  },
  "include": [
    "src/**/*"
  ],
  "exclude": [
    "node_modules",
    "coverage"
  ]
}

CommonJS and ES Modules

Node.js supports ES modules since around v14 but there are still some tools that cannot handle it very well. It makes sense to add a CommonJS version of your library to the output. There are bundlers like rollup that support multiple output streams, but we can also use Typescript directly for it. In a new file tsconfig-cjs.json we can define the CommonJS build step.

{
    "extends": "./tsconfig.json",
    "compilerOptions": {
        "module": "CommonJS",
        "outDir": "./lib/cjs"
    },
}

With adjusting the build script in package.json we make sure that both steps run combined.

{
    "scripts": {
        "build": "tsc -p tsconfig.json && tsc -p tsconfig-cjs.json",
        ...
    }
}

After running npm run build you should see a lib/cjs and a lib/esm in the output folder. It is important that these folders are also added to the package that will be send to the package registry. Please make sure that the package.json configuration will include both folders with either pointing to the parent folder or including each separate.

{
    "files": [
        "lib/cjs",
        "lib/esm"
    ]
}

In addition, we need to tell the tools that are using the library where the main import file is located. The main field defines the CommonJS file and the module field is the counterpart for es modules.

{
    "main": "./lib/cjs/index.js",
    "module": "./lib/esm/index.js"
}

Free of Side Effects

It is a good approach to create functions and classes in a module and export them with named exports. With that approach the bundler can tree-shake your code and remove not used exports. This doesn't work in all cases because there could also be code in your module that runs already during the import – for example when you want to initialise state or singletons or attach to certain listeners. This code is a side effect for the bundler. Running over the file that includes the import the bundler doesn't see the import is actually used, although it will change something when including the import. By default the bundling tools consider your library to include side effects and therefore don't remove unused imports in modules. You should optimise this behaviour by avoiding side effects from the beginning and set sideEffects to false in package.json.

{
    "sideEffects": false,
    ...
}

If you have CSS or SCSS imports in your code you need to exclude them from side effects optimisation or otherwise they will get removed.

{
    "sideEffects": ["*.css", "*.scss"],
    ...
}

Submodules

Now you optimised your libraries and added CommonJS and ESM versions to it and you are happy that you can use it on your project. At some point you want to use submodules of this library, e.g. import mylibrary/submodule. Don't do that. Currently Node.js, Webpack and Typescript have a different implementation how to handle those. It's best to avoid that until the new exports field in package.json is completely supported by these tools. This field is needed to define conditional paths like ./lib/esm or ./lib/cjs for submodules.

If you still need to use submodules, then there is a workaround that you can tell webpack to rewrite cjs paths to esm by defining an alias. For example in Next.js you can do that with modifying the webpack config. Important is that you will import your submodule with the cjs path – e.g. import mylibrary/cjs/submodule.

const config = {
    webpack: (webpackConfig) => ({
        ...webpackConfig,
        resolve: {
            ...webpackConfig.resolve,
            alias: {
                ...webpackConfig.resolve.alias,
                'mylibrary/cjs/submodule' : 'mylibrary/esm/submodule',
            },
        },
    }),
};

module.exports = (phase) => config;

With this configuration Typescript and Node.js will use the CommonJS version, but webpack will tree-shake with the ESM version.

Bundle Size of Dependencies

You now have good tree-shakable libraries that can be processed by Webpack properly. But there are still a lot of third-party libraries that either don't provide ESM versions or have a big bundle size in general. The Webpack bundle analyzer combined with a tool like bundlephobia.com helps in identifying dependencies which have a huge bundle size. Bundlephobia offers alternatives to libraries. It is worth to have a look there, because we could replace the sanitize-html dependency with the leaner xss library. That decreased the bundle size by ~40 kB gzipped.

This article was inspired by How To Make Tree Shakeable Libraries.