Babel to EsBuild migration for Webpack
Recently I had some time to take a look at the performance of my pet project. One thing bothered me: the project is relatively small, but the bundle size was too big: 780 KiB. Whereas the project had only a few dependencies: react (~10 KiB), react-router (~70 KiB), react-dom (~90 KiB), dayjs (~3 KiB), lodash (~70 KiB). The bundle should not be that big for so few dependencies.
So I ran webpack-dev-analyzer and found a weird thing: the sum of the libs is 3 times smaller than the final bundle size. Then I decided to take a look at @statoscope/webpack-plugin. It was a good tool but it gave me the same result. The bundle has way too much trash.
Ok. Probably it's just webpack wrappers over 300+ small modules. So I asked Perplexity - what can I do with it? Turned out all the proper options were already activated. So, I made a decision - it's time to try esbuild. It made a huge impact on my work project, so I knew it could work out.
I knew that esbuild didn't support a lot of stuff that babel supports. So I expected that it would take a day to migrate the codebase to esbuild. But surprisingly once I replaced babel-loader with esbuild-loader the build command worked out. The build didn't work but the bundle was assembled without any errors.
Did it help? Nope. Instead of 780 KiB, I got ~1 MiB. Luckily I found out that there's also EsbuildPlugin, which replaces TerserPlugin and works better. Added this line, it worked like a charm, and... Whoa! 280 KiB. That's huuuge.
So I figured out - it worked. Now I have to find a way to fix the app (spoiler: it didn't work at all).
Implicit React imports
React 17+ supports the possibility of avoiding adding import React from 'react' to every TSX-file. esbuild supports it too, but somehow it didn't work out of the box. Solution:
const esbuildLoader: RuleSetRule = {
test: /\.(js|ts|tsx)$/,
loader: 'esbuild-loader',
exclude: /node_modules/,
options: {
target: 'es2020',
minify: cfg.PROD,
+ // Support implicit React imports:
+ jsx: 'automatic',
+ jsxFactory: 'React.createElement',
+ jsxImportSource: 'react',
} satisfies LoaderOptions,
};
TSX-Control-Statements
I just removed the packages and all their <If/>s. I liked them though. There's a plugin for Vite that adds their support. But I still use Webpack and the plugin works via string replacements, not via AST.
SourceMaps
To enable sourcemaps I had to disable EsbuildPlugin for development. Theoretically, it supports sourcemaps, but no matter what I did it didn't work. I even debugged the package itself. Didn't help. So I surrendered and enabled it only for the prod build.
TypeScript
EsBuild supports TypeScript syntax out of the box but it doesn't check types. So it builds extremely fast but I need to know my errors. Solution:
webpackConfig.plugins!.push(
new ForkTsCheckerWebpackPlugin({
typescript: {
configFile: cfg.paths.TS_CONFIG,
},
})
);
How does it work? The build and the type-checking processes are run in parallel. The 2nd doesn't linger the 1st. You still can see the errors in the browser (and CLI) console. And visually too. Nice.
ES-Lint
For some reason, it stopped working. The solution was pretty simple:
webpackConfig.plugins!.push(
new ESLintPlugin({
extensions: ['ts', 'tsx'],
Final results
- File sizes
- Before: 641 KiB + 234 KiB + 29.5 KiB (css) = 904.5 KiB
- After: 221 KiB + 55 KiB + 8.1 KiB (o_O, css) = 284.1 KiB
- After minifyIdentifiers: true (scary thing) & minifyWhitespace: true: 138 KiB + 44.2 KiB + 8.1 KiB = 190.1 KiB
- DOMContentLoaded:
- Before: 737ms
- After: 231ms
- After-2: 131ms
- Build time: esbuild was ~3x faster
Afterthought
Taking a deeper look I found:
- The main issue wasn't in babel. The main issue was that I used inline-source-map for prod. I didn't want to do it. Just a mistake. And both bundle analyzers didn't notice that. Even when I manually looked at the file I barely found it (the horisontal scroll was too big). Fixing this issue dropped the file size 3x times even with babel.
- esbuild has a better wrapper logic for a ton of tiny files than babel does.
- Whitespace minification is quite important. GZip doesn't eliminate the difference.
- Name mangling is a beast. But be careful, it can easily break your app in runtime.