To ship less code, write transpiler-aware Javascript
JavaScript is undergoing a sort of performance renaissance as it matures (which is right now, I think). Our web-developer arsenal contains some great new tools— web workers, service workers, virtual DOMs, server-side, rendering, etc. These tools help us work around the inherent performance constraints of the web and really deliver a great experience to our users.
But as a web developer particularly focused on performance, I believe that we lack discourse around a tangential performance factor: the amount of code we ship. As bundle size scales so do certain disadvantages — download time increases, and parsing time increases, and so does the amount of memory needed to hold our code. This all equates to a longer period before our app is usable, which does not give our users a great experience.
The only problem is that it’s really difficult to just write less code. We simply can’t stop features and fixing bugs, and over time our codebases runs away in size. But there’s a way to reduce your bundle size for free and to slow the rate of growth: write transpiler-aware code.
Transpiler-aware code is written with the knowledge of how build tools like Webpack and Babel work. These tools enable us to write excellently, but in their course can produce a lot of extra boilerplate. By using this knowledge of how our tools work, we can write code which requires less boilerplate, thus reducing the amount of code we ship. And figuring out how your tools work is easy: just open up your bundle, look at what’s inside, and compare to what you wrote.
Transpiling
Transpiling is the act of transforming modern JavaScript into code which works on IE. Fractured ES* support on the web constrains what we can ship, but with tools like Babel we‘re empowered to write the best. Many transforms like const to var and fat arrow to function come at no cost — that is, the transpilation mimics what you would have written yourself. However, there are some features which transpile poorly, requiring many times more LOC than what you actually wrote. Class is a prime offender: unminified, Babel transpiles the below example from 27B to 910B — a 33x increase.
Polyfills can be another source of unexpected size increases in your codebase. For one, not all polyfills are created equally; promise polyfills, for example, may go beyond the spec and implement features you do not use. Furthermore, polyfills for more advanced features may absolutely massive — the most popular generator polyfill will net you about 20kb.
The key to efficiently transpiling isn’t the absence of transpiling, but the educated inclusion of new features you want today. You may not need generators, but you may want them.
Bundling
When multiple JS files are combined into one, that’s a bundle. We bundle in order to reduce the amount of network requests needed to serve our code, as well as to take advantage of codebase-wide optimizations. Tools like Webpack and Rollup accomplish this by combining a dependency graph of your modules along with some injected Javascript to link them all together. While this boilerplate is necessary, sometimes we write code in patterns which result in too much boilerplate. To make matters worse this boilerplate is repeated for every module you export — small modules have a large cost.
But bundling is a good thing, and you should do it. But how do you write code which bundles well?
1. Use Native Modules
You don’t need Babel to transpile your CommonJS/AMD/etc. modules anymore — as of version 3, Webpack now implements Harmony modules. Whatever transpiler you’re using needs to be told not to convert modules so that Webpack can handle it. In Babel:
"presets": [
["es2015", { "modules" : false }]
]
In our codebase, we saw ~10% reduction in bundle size with this option alone. The native module implementation is less verbose than say, a CommonJS implementation, and does not require interop boilerplate. But the real benefit is that Harmony modules allow Webpack to use fancy new features to further reduce your build size.
2. Tree Shake
When Webpack tree-shakes your code, it’s analyzes your dependency graph and removes any modules which are unused. Webpack 2+ does this automatically when using native modules. This can reduce your bundle size significantly if your code depends on large libraries like React. Our codebase didn’t see an appreciable size reduction, because we don’t depend on large libraries.
3. Module Concatenation
Webpack’s default behavior is to pack each module into it’s own function, so that each is contained within it’s own scope. Unfortunately this means that Webpack must repeat it’s boilerplate per each one of these scopes. And as hinted at before in the linked “Cost of Small Modules” post, lots of small functions actually degrade the performance of your bundle. I believe that Rollup was the first to solve this problem by allocating exactly 1 enclosing scope to your entire bundle. Not long after Webpack 3 introduced a similar feature known as “Partial Scope Hoisting”. It pretty much does exactly what Rollup does, but preserves Webpack’s claim to fame: code splitting. Webpack will flatten it’s modules but will also cut around code splits, potentially using more than one scope. To enable this feature, slap this line into your config’s plugins block:
new webpack.optimize.ModuleConcatenationPlugin()
The amount of savings you get here depends on how many modules you had before. It also depends on how well Webpack is able to concatenate your modules together. See Tobais Kopper’s post on this plugin and what causes it to fail. You can also see for yourself with the following Webpack CLI option:
webpack --display-optimization-bailout
By enabling a config option, we saw not only a 2% reduction in code size, but a whopping 68.5% reduction in the amount of function scopes.
4. Code Splitting
Your users probably don’t use all the code shipped in your bundle. For example, some users are fortunate enough to not be served an ad—in this case we never serve the code which plays it. On browsers which do not need polyfills our player library does not download them. When you do need these features, we dynamically (and asynchronously) download the chunk, slide it into the bundle, and execute a callback.
We accomplish this with a technique known as code splitting, Webpack’s original raison d’être. Code splitting is the act of segmenting your codebase into several dynamically-loaded bundles, with one bundle acting as the entry point. Code is split with a built-in Webpack function, require.ensure
(and now System.import
). Typically these chunks are loaded from the file system, but Webpack can be configured to load from a CDN.
Code splitting can save your users massive amounts of data, but isn’t the easiest optimization because it requires you to architect your codebase around your chunks. In addition to programming hard line to split your code by, you also need to cleverly load these chunks so your users don’t experience gaps or stalls.
Minifying
When I first looked at one of my minified bundles, I was curiously aware I could still understand it. “This isn’t ugly!”, I thought while seeing obj.overlyDescriptivePropertyName
more than twice. And while well-preserved variables have helped me debug in production, I wasn’t quite happy with merelyBelowAverageAttractivenessJS. So I began to dig deeper.
1. Be Conscious of your Prototypes
Because JS is a dynamic language, minifiers like Uglify and Babili cannot know how your objects will be used. In order not to break your code, they leave object properties unmangled. Solutions?
- Only put public methods on the prototype chain (the Revealing Module pattern is very good for this)
- Write smaller property names
You can also use the nuclear option and mangle “internal” properties — those on the prototype chain which you know are private. The Closure Compiler is smart enough to do this; Uglify has a config option, which mangles any property beginning with _
, like _foo
.
2. Strings are Dangerous
Minifiers leave strings alone — would you want your pretty log message to turn into ajks
? Be conscious of your string usage. More specifically, you can:
- Avoid referencing properties by string —
foo.x
overfoo[x]
- Assign strings to
const
variables, and include them - Use less strings
- Drop log statements — Uglify is even smart enough to remove specific levels
Conclusions
Cracking open and reading your bundle for the first time can leave you with your head spinning. There’s a lot going on, and not all of it comes from the same tool. But pressing onwards and developing code practices not just for developer friendliness but for tool friendliness, rewards you with smaller code for free. ✌️
(p.s. This is my first Medium post — I’d appreciate feedback!)