Simohamed Marhraoui Vue and React developer | Linux enthusiast | Interested in FOSS

Tree shaking and code splitting in webpack

8 min read 2278

Webpack Logo Over a Tree Background

What is tree shaking?

Tree shaking, also known as dead code elimination, is the practice of removing unused code in your production build. It’s important to ship as little code to your end-users as possible. By statically analyzing our source code, we can determine what’s not being used and exclude it from our final bundle.

What is code splitting?

Code splitting, on the other hand, refers to splitting your production build code into multiple modules that are loaded on demand. If you’re utilizing a third-party library in your code after some user interaction, we can exclude that third-party code in our initial bundle and only load it when needed to achieve faster load times.

Tree shaking in webpack

In webpack, tree shaking works with both ECMAScript modules (ESM) and CommonJS, but it does not work with Asynchronous Module Definition (AMD) or Universal Module Definition (UMD).

ESM allows for the most optimal tree shaking because CommonJS, AMD, and UMD can all be non-deterministic and thus, impossible to statically analyze for effective dead code elimination.

In Node.js, for example, you can conditionally run require with a variable to load a random script. Webpack can’t possibly know all of your imports and exports at build time, so it will attempt to tree shake a handful of constructs and bail as soon as things get too dynamic.

This is true for ESM as well, the following code can force webpack to opt out of tree shaking app.js because the use of the import is not static.

import * as App from 'app.js'

const variable = // some variable

console.log(App[variable])

And, although UMD is an appealing choice as a module system because it works everywhere, it can’t be tree shaken, so, according to Sean Larkin at Microsoft, it’s best to stick to ESM and let the developers consuming your code handle the conversion from one module system to the other.

Getting started in webpack

When working with webpack, you will realize that some code is more tree-shakable than other similarly functioning code. It’s impossible to cover all the heuristics webpack employs in order to tree shake your code, so we will limit the use cases to a few important ones.

To get a basic webpack project running, install webpack and webpack-cli.

$ yarn init -y
$ yarn add -D webpack webpack-cli

Create two files inside a src directory, src/index.js and src/person.js:

We made a custom demo for .
No really. Click here to check it out.

// src/person.js
export const person = { name: "John", age: 30 };

In person.js, export a person object to be used in other modules.

// src/index.js
import { person } from "./person";

console.log(person.name);

Running yarn webpack will, by default, use src/index.js as the entry point and output a dist/main.js build file. The command will also warn us that we are not setting a mode and will run webpack in production mode.

If you open build/main.js, you will find the following unformatted code, which is a far cry from the source code we wrote.

// dist/main.js
(() => {
  "use strict";
  console.log("John");
})();

Notice that webpack wrapped code in IIFE and bundled all the modules into a single file, and it will continue to do so until we tell it otherwise.

It also correctly determined that we did not use the person object in its entirety, nor did we need a person variable to begin with.

If we reuse person.name (by duplicating our console.log call, for instance,) webpack will maintain it in our bundle after it’s been optimized and minimized but will continue to tree shake the unused properties from our person object:

// dist/main.js
(() => {
  "use strict";
  const o = "John";
  console.log(o), console.log(o);
})();

Using this setup, let’s explore some importing and exporting patterns that we use in our modules.

Using namespace imports and tree shaking in webpack

We will switch to a component.js file to work with familiar subjects. In component.js, we can write code that you would find in an open-source component library and export a handful of components:

// src/component.js
export const Root = () => "root";
export const Title = () => "title";
export const Overlay = () => "overlay";

In index.js, we use the Title component:

// src/index.js
import { Title } from "./component";

console.log(Title());

Compiling these two files, we get the following code:

// dist/main.js
(() => {
  "use strict";
  console.log("title");
})();

Using namespace imports works identically to having named imports in terms of tree shakability.

We can find this pattern suggested in several public packages’ documentation such as Yup’s and Radix UI’s. In webpack 5, this has been enhanced to cover nested imports as well.

// src/index.js
import * as Component from "./component";

console.log(Component.Title());

Bundling this code would result in the exact same output as before.

Namespace imports allow us to encapsulate several imports under one object. Some library authors take this matter into their own hands, though, and create that object for you, then usually export it as a default export a la React.

// src/component.js
export const Root = () => "root";
export const Title = () => "title";
export const Description = () => "description";

Root.Title = Title;
Root.Description = Description;

It’s common to see this pattern, where one component is assigned the rest of the components. You can find this pattern used in HeadlessUI through an Object.assign call, for example.

Unfortunately, it is no longer tree-shakable because the Root. assignments are dynamic and can be called conditionally. Webpack cannot statically analyze this anymore, and the bundle will look like this:

// dist/main.js
(() => {
  "use strict";
  const t = () => "root";
  (t.Title = () => "title"),
    (t.Description = () => "description"),
    console.log("title");
})();

Although we aren’t using the description function anywhere, it’s shipped in production code.

We can fix this and maintain a similar experience by exporting an actual object:

// src/component.js
export const Root = () => "root";
export const Title = () => "title";
export const Description = () => "description";

export const Component = {
  Root,
  Title,
  Description,
};
// src/index.js
import { Component } from "./component";

console.log(Component.Title());.
// dist/main.js
(() => {
  "use strict";
  console.log("title");
})();

Tree shaking classes in webpack

Unlike functions, classes are not statically analyzable by bundlers. If you have a class like the following, the methods greet and farewell cannot be tree shaken even if they are not used.

// src/person.js
export class Person {
  constructor(name) {
    this.name = name;
  }

  greet(greeting = "Hello") {
    return `${greeting}! I'm ${this.name}`;
  }

  farewell() {
    return `Goodbye!`;
  }
}
// src/index.js
import { Person } from "./person";

const John = new Person("John");

console.log(John.farewell());

Although we’re only using the farewell method and not the greet method, our bundled code contains both farewell and greet methods.

To get around this, we can extract the methods as stand-alone functions that take the class as an argument.

// src/person.js
export class Person {
  constructor(name) {
    this.name = name;
  }
}

export function greet(person, greeting = "Hello") {
  return `${greeting}! I'm ${person.name}`;
}

export function farewell() {
  return `Goodbye!`;
}

Now, we import greet, which results in farewell being tree shaken from our bundle.

// src/index.js
import { Person, greet } from "./person";

const John = new Person("John");

console.log(greet(John, "Hi")); // "Hi! I'm John"

Tree shaking side effects

In functional programming, we are accustomed to working with pure code. We import and export code that simply receives input and yields output. By contrast, code that has side effects is code that modifies something in a global context (e.g., polyfills).

Modules that are side effects cannot be tree shaken because they don’t have imports and exports.
But, code doesn’t have to be a module to have side effects. Take the following code as an example:

// src/side-effect.js
export const foo = "foo";

const mayHaveSideEffect = (greeting) => {
  fetch("/api");
  return `${greeting}!!`;
};

export const bar = mayHaveSideEffect("Hello");
// src/index.js
import { foo } from "./side-effect";

console.log(foo);

The bar variable triggers a side effect as it’s initialized. Webpack realizes this and has to include the side effect code in the bundle, even though we aren’t using bar at all:

// dist/main.js
(() => {
  "use strict";
  fetch("/api"), console.log("foo");
})();

To instruct webpack to drop the side effect of initializing bar , we can use the PURE magic comment, like so:

// src/side-effect.js
export const bar = /*#__PURE__*/ mayHaveSideEffect("Hello");

// dist/main.js
(() => {
  "use strict";
  console.log("foo");
})();

Code splitting in webpack

Before webpack, developers used a combination of script tags, IIFE, and JSON with padding (JSONP) to organize and write modular code.

Take this example:

<body>
  <script src="global.js"></script>
  <script src="carousel.js"></script> <!-- carousel.js depends on global.js -->
  <script src="shop.js"></script> <!-- shop.js depends on global.js -->
</body>

If carousel.js were to declare a variable with a name that’s already declared in global.js, it would overwrite it and crash the entire app. So, IIFEs were used to encapsulate code from affecting other code.

var foo = 'bar';

(function () {
  var foo = 'baz';
})()

An IIFE is a function that calls itself immediately, creating a new scope in the process that doesn’t interfere with the previous scope.

The last piece in this workflow is the use of JSONP, which was created back when CORS wasn’t standardized yet, and requesting a JSON file from a server was prohibited in the browser.

JSONP is a JavaScript file that calls a predefined function with certain data or logic immediately when requested. Note that the function does not have to be JSON.

<script type="text/javascript">
  var callback = function(json) {
      console.log(json)
    }
</script>
<script type="text/javascript" src="https://example.com/jsonp.js"></script>
<!--
  // jsonp.js contains:
  callback("The quick brown fox jumps over the lazy dog")

  when https://example.com/jsonp.js gets loaded,
  "The quick brown fox..." will be logged to the console immediately.
-->

You can see that using these concepts to modularize our code can be cumbersome and error-prone. But in reality, these are the very same concepts that power webpack. All that webpack does is automate this process through static analysis while providing a top-notch developer experience and extra features, among which is tree shaking.

It is apparent that code splitting or lazy loading is just webpack creating and appending more script tags that are referred to in the webpack world as chunks.

The code that handles lazy-loaded modules is already on the page. And, JSONP is used to execute that code as soon as the module is loaded.

<script type="text/javascript">
  var handleLazyLoadedComponent = function(component) {/* ... */}
</script>
<script type="text/javascript" src="chunk.js"></script>
<!-- chunk.js calls handleLazyLoadedComponent with the right code to work seamlessly -->

Code splitting in webpack

To utilize code splitting, we can use the global import function:

// src/lazy.js
export const logger = console.log;
// src/index.js
const importLogger = () => import("./lazy");

document.addEventListener("click", () => {
  importLogger().then((module) => {
    module.logger("hello world");
  });
});

In index.js, instead of importing our logger function statically, we chose to import it on demand when an event gets fired. import returns a promise that resolves with the whole module.

In our bundled code, we now see two files instead of one, effectively splitting our code.

Dynamic imports in webpack

Because webpack bundles our app at build-time using static analysis, it cannot provide truly dynamic importing at run-time. If you attempt to use the import function with a variable (i.e., import(someVariable)), webpack will warn you not to. But, if you give webpack a hint on where to look for your dynamic modules, it would code split them all at build-time in anticipation of using them.

As an example, let’s say we have a numbers directory with three files: one.js, two.js, and three.js, which exports numbers:

// src/numbers/one.js
export const one = 1;

// src/numbers/two.js
export const two = 2;

// src/numbers/three.js
export const three = 3;

If we want to dynamically import these files, we need to hardcode the path in the import function call:

// src/index.js
const getNumber = (number) => import(`./numbers/${number}.js`);

document.addEventListener("click", () => {
  getNumber("one").then((module) => {
    console.log(module.one);
  });
});

If we have modules that are not .js files (e.g., JSON or CSS files) inside our numbers directory, it helps to narrow down the imports to JavaScript files by including that in the import call.

This will create three additional bundles even though we’re only using one bundle in our code.

Tree shaking dynamic imports

Dynamic imports resolve the entire module — with its default and named exports — without tree shaking unused imports.

To dynamically import a node module and tree shake it, we can first create a module that only exports what we want, then dynamically import it.

Some libraries like Material-UI and lodash.es are structured in a way that you can access exports based on the file structure. In that case, we can skip re-exporting the module and just import it directly in the first place.

Conclusion

In this article, we covered tree shaking in webpack and learned how to make common patterns tree-shakable. We also covered how webpack works under the hood when it comes to code splitting, as well as how to dynamically import modules at runtime. Lastly, this article covered how to combine tree shaking and code splitting for the most optimal bundle possible. Thanks for reading.

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Simohamed Marhraoui Vue and React developer | Linux enthusiast | Interested in FOSS

Leave a Reply