Today we have tree shaking and module bundlers, and we go back to code splitting to not block the main thread on startup and speed up the time to interactivity. We're also transpiling everything: using future features today? No problem – thanks to Babel!
ES6 modules have been defined in the ECMAScript specification for a while already. The community wrote tons of articles on how to use them with Babel and how
import differs from
require in Node.js, but it took a while until an actual implementation landed in browsers. I was surprised to see that Safari was the first one shipping ES6 modules in its technology preview channel, and now Edge and Firefox Nightly also ship this feature – even though it's behind a flag. After having used tools like RequireJS and Browserify (remember the AMD and CommonJS discussions?) it looks like modules are finally arriving in the browser landscape, so let's see a look what the bright future will bring. 🎉
The result of this app will be a "Hello world" telling us that all files are loaded.
The three base files are relatively small and have a total size of 347 bytes.
When I ran this through Webpack, I got a bundle with the size of 856 bytes, which is roughly 500 bytes boilerplate. These additional bytes are acceptable, as it's nothing compared to the bundles most of us ship in production. Thanks to Webpack, we can already use ES6 modules.
Now that we have the "traditional bundle" for all the browsers that don't support ES6 modules yet, we can start playing around with the cool stuff. To do so, let's add in the
index.html file a new script element pointing to the ES6 module with
When we take a look at Chrome, we'll see that there is not much more happening.
The bundle is loaded as before, "Hello world!" is shown, but that's it. And that's excellent, because this is how web works: browsers are forgiving, they won't throw errors when they don't understand markup we send down the wire. Chrome just ignores the script element with the type it doesn't know.
Now, let's check the Safari technology preview:
Sadly, there is no additional "Hello world" showing up. The reason is the difference between build tools and native ES modules: whereas Webpack figures out which files to include during the build process, when running ES modules in the browser, we need to define concrete file paths.
The adjusted file paths work great, except for the fact that Safari preview now loads the bundle and the three individual modules, meaning that our code will be executed twice.
The solution is the
nomodule attribute, which we can set on the script element requesting the bundle. This attribute was added to the spec quite recently and Safari Preview supports it as of the end of January. It tells Safari that this script is the "fallback" script for the lack of ES6 modules support, and in this case shouldn't be executed.
That's good. With the combination of
You can check out this state in production at es-module-on.stefans-playground.rocks.
ES6 modules are running in strict mode by default (no need for
'use strict' anymore).
Top-level value of
Top-level variables are local to the module.
ES6 modules are loaded and executed asynchronously after the browser finished parsing the HTML.
In my opinion, these are all huge advantages. Modules are local – there is no need for IIFEs around everything, and also we don't have to fear global variable leaking anymore. Also running in strict mode by default means that we can drop a lot of
'use strict' statements.
And from a performance point of view (probably the most important one) modules load and execute deferred by default. So we won't accidentally add blocking scripts to our website and there is no SPOF issue when dealing with script
type="module" elements. We could place an
async attribute on it, which overwrites the default deferred behavior, but
defer is a good choice these days.
In case you want to check the details around that, the script element spec is an understandable read and includes some examples.
But we're not quite there yet! We serve a minified bundle for Chrome and individual not minified files for Safari Preview now. How can we make these smaller? UglifyJS should do the job just fine, right?
It turns out that UglifyJS is not able to fully deal with ES6 code yet. There is a
But UglifyJS is in every toolchain today, how does this work for all the projects written in ES6 out there?
The usual flow is that tools like Babel transpile to ES5, and then Uglify comes into play to minify this ES5 code. I want to ignore ES5 transpilation in this article: we're dealing with the future here, Chrome has 97% ES6 coverage and Safari Preview has already fabulous 100% ES6 coverage since version 10.
With the Babili CLI tool, it's almost too easy to minify all the files separately.
The result looks then as follows.
The bundle is still roughly around 850B, and all the files are around 300B in total. I'm ignoring GZIP compression here as it doesn't work well on such small file sizes (we'll get back to that later).
The minification of the single JS files is a huge success. It's 298B vs. 856B, but we could even go further and speed things up more. Using ES6 modules we are now able to ship less code, but looking at the waterfall again we'll see that the requests are made sequentially because of the defined dependency chain of the modules.
What if we could throw
<link rel="preload" as="script"> elements in the mix which can be used to tell the browser upfront that additionally requests will be made soon? We have build tool plugins like Addy Osmani's Webpack preload plugin for code splitting already – is something like this possible for ES6 modules? In case you don't know how
rel="preload" works, you should check out the article on this topic by Yoav Weiss on Smashing Magazine.
Unfortunately, preloading of ES6 modules is not so easy because they behave differently than normal scripts. The question is how a link element with a set
rel="preload" attribute should treat an ES6 module? Should it fetch all the dependent files, too? This is an obvious question to answer, but there are more browser internal problems to solve, too, if module treatment should go into the
preload directive. In case you're interested in this topic Domenic Denicola discusses these problems in a GitHub issue, but it turns out that there are too many differences between scripts and modules to implement ES6 module treatment in the
rel="preload" directive. The solution might be another
rel="modulepreload" directive to clearly separate functionalities, with the spec pull request pending at the time of writing, so let's see how we'll preload modules in the future.
Three files don't make a real app, so let's add a real dependency. Fortunately, Lodash offers all of its functionality also in split ES6 modules, which I then minified using Babili. So let's modify the
index.js file to also include a Lodash method.
The use of
isEmpty is trivial in this case, but let's see what happens now after adding this dependency.
The request count went up to over 40, the page load time went up from roughly 100ms to something between 400ms and 800ms on a decent wifi connection, and the shipped overall size increased to approximately 12KB without compression. Unfortunately, Safari Preview is not available on WebPagetest to run some reliable benchmarks.
This 4KB difference is definitely something to check. You can find this example at lodash-module-on.stefans-playground.rocks.
Khan Academy discovered the same thing a while ago when experimenting with HTTP/2. The idea of shipping smaller files is great to guarantee perfect cache hit ratios, but at the end, it's always a tradeoff and it's depending on several factors. For a large code base splitting the code into several chunks (a vendor and an app bundle) makes sense, but shipping thousands of tiny files that can't be compressed properly is not the right approach.
Another thing to point out is that thanks to the relatively new tree shaking mechanism, build processes can eliminate code that's not used and imported by any other module. The first build tool that supported this was Rollup, but now Webpack in version 2 supports it as well — as long as we disable the
module option in babel.
Let's say we changed
dep-2.js to include things that won't be imported by
Babili will simply minify the file and Safari Preview, in this case, would receive several code lines that are not used. A Webpack or Rollup bundle, on the other hand, won't include
unneededStuff. Tree shaking offers huge savings that definitely should be used in a real production code base.
So, ES6 modules are on their way, but it doesn't look like anything will change when they finally arrive in all the major browsers. We won't start shipping thousands of tiny files to guarantee good compression, and we won't abandon build processes to make use of tree shaking and dead code elimination. Frontend development is and will be as complicated as always.