Modernizing a webpack/babel config w/ esm treeshaking

Hearing so much about treeshaking in webpack, (and rollup, and parcel), it would seem reasonable that it’d be a well documented type of migration.

The webpack docs have a section on tree shaking, where it looks alluringly simple. We just have to use import { thing } from 'lib'; everywhere, and reap the blessings of the webpack gods.

If, however, we are using plugins like babel in our webpack configuration, (and honestly, who doesn’t?) there are several things that can prevent the tree shaking from working.

TLDR: After a couple days of trying, failing and trying again, the traps seem to be:

Transpile everything!

Wait, what? The last time I checked, it was common practice to not transpile node_modules w/ babel, for both performance and compatibility reasons. The assumption being that libraries ship in a precompiled state, for their target. Whether that is nodejs, latest chrome, or something like electron. Granted, that assumption might have been naive, and it is part of what’s keeping the community from moving forward.

Our first webpack.config.js rule:

[
	{
		test: /\.js$/,
		exclude: /node_modules/,
		use: ['babel-loader']
	}
];

We have a problem: if we want the esm version of our dependencies, they cannot possibly come ready made for our (most likely) feature-lacking browsers. By observing projects like create-react-app and parcel, we can get a pretty good idea of how people go about handling this.

parcel uses a lot of magic to transpile node_modules based on the presence of .babelrc files in the individual packages, while merging it with a default transform: preset-env. This alleviates the need of setting up transforms ourselves.

The common tendency here is to have a babel config for your app, and another with minimal transforms for node_modules.

Our separated webpack.config.js rule:

[{
  oneOf: [
    {
      test: /\.js$/,
      exclude: /node_modules/,
      use: [
        // Config for our app code
        {
          loader: 'babel-loader',
          options: {
            // ...
          }
        }
      ]
    },
    {
      test: /\.js$/,
      use: [
        // Minimal transforms for 3rd party code
        {
          loader: 'babel-loader',
          options: {
            babelrc: false
            // ...
          }
        }
      ]
    }
  ];
}];

Note: We might want to weak the mainFields property in webpack, depending on our target. The defaults should work, but if it’s not picking up the esm version as expected, check resolve.mainFields.

Don’t transpile module types. Watch those babel plugins!

As the docs say, tree shaking relies on static imports and exports. This means that we cannot get any help if what we’re passing to webpack has already become commonjs format.

What we want is this:

import { map } from 'lodash';
map([1, 2, 3], x => x + 3);

But, if we’re not careful with babel, what we’re passing to webpack works more like this (If curious, try it out on the babel repl):

var map = require('lodash').map;
map([1, 2, 3], x => x + 3);

Which is not statically analyzeable by webpack, and we will not be able to get it to remove the unnecessary code no matter how nicely we ask.

But what are we looking out for? Anything related to module formats.

Let’s inspect

For our purposes, we’re migrating an existing webpack config, and so it is always nice to start with seeing what’s going on. What are we actually passing to webpack? We can get an idea by adding inspect-loader above (meaning after) babel-loader in our config:

[
	{
		test: /\.js$/,
		use: [
			// Config for our app code
			{
				loader: 'inspect-loader',
				options: {
					callback({ arguments: [sourceCode, { sources }] }) {
						console.log('// ', sources.join(', '), 'generated:');
						console.log(sourceCode);
					}
				}
			},
			{
				loader: 'babel-loader',
				options: {
					// ...
				}
			}
		]
	}
];

And then run our build:

$ webpack
// src/index.js generated:
'use strict';

var _lodash = require('lodash');

(0, _lodash.map)([1, 2, 3], function(x) {
	return x + 3;
});

Which is no good, it’s not the format webpack needs to do it’s magic. Which magic flag should we flip to get this to work?

Presets and configs; @babel/preset-env

@babel/preset-env is probably the most pervasive preset in modern web development, which allows us to specify the browsers we want to support, rather than which features we want to transpile. This offloads the task of figuring out which flags to flip to support a given browser. Assuming we keep the packages up to date.

If we set the modules option of preset-env to false in our .babelrc, or inline in the webpack config, we can prevent the preset from handling modules for us:

{
	"presets": [
		[
			"@babel/preset-env",
			{
				"modules": false
			}
		]
	]
}

If we try again by running webpack, our inspect-loader will now print:

//  src/index.js generated:
import { map } from 'lodash';
map([1, 2, 3], function(x) {
	return x + 3;
});

which looks more promising.

Warning: we need to check that none of the presets we add will try to do the module transpilation. For instance, as stated on the preset-env docs, the babel-preset-stage-X presets are incompatible with preset-env. Among the incompatible features are esm to commonjs modules transform. So removing it and similar plugins is essential to getting this to work.

javascript/esm mode

We’re not done yet though, since esm mode will create some new edge cases for modules.

If you encounter something like this:

> Uncaught TypeError: Cannot assign to read only property ‘exports’ of object ‘#<Object>’

it’s because webpack is determining the module format to be esm, but the module is trying to use commonjs exports.

If a file contains the import keyword when they reach webpack, it will assume it’s an esm module, and run in javascript/esm mode, where module.exports is undefined, and exports is read-only. So anywhere that uses import must use esm style export as well. For more info see this issue on webpack github

This is potentially the biggest blocker to migrating our current codebase, since using named exports everywhere (i.e. breaking require('./foo') by exporting .default) will require major refactoring. Although that might be a task for a nifty codemod, and another day.

Posted in Programming with : Webpack

comments powered by Disqus