A Universal Bundle Loader

Photo by Leone Venter on Unsplash

This is a little follow up for my previous post, targeting those developers wondering about the usage of <script type=module> to simply distinguish between modern browsers and not.

The ES2017 Bar

Even if ECMAScript Modules have been defined already in ES2015, their implemntation on the Web landed between 2017 and 2018.

If you check targets browsers able to understand the new script type, you realize around 75% of the browsers would work there, leaving out though UC Browser and Samsung browser on mobile, both a very relevant piece of mobile market.

Accordingly, I’d say it’s still a bit early to cut out all non ESM compatible browsers, and after all, ES2016+ didn’t bring in anything that special that couldn’t be transpiled down to ES2015 with relatvely ease.

The ES2015 Bar

This is the sweet spot where most common features will just work, and few others can be transpiled with ease, including async and await, usually the reason developers would love to stick with ES2017+.

As previously mentioned though, the most important thing to preserve in ES2015 are classes, that regardless everyone on the web sold them as “syntactical sugar”, ES6 classes are a curse for both TypeScript and Babel to transform, and full of hidden evil gotchas.

But covering around 88% of the browsers, including UC Browser and Samsung Internet, we can stop avoiding classes, extending native constructors, or patching the whole DOM, because we won’t need anymore to fallback to the old function based classes that are so similar, and yet so different, from the modern native one.

A document.write Alternative

While the major point of my previous post was to demonstrate there are techniques to manually target with ease modern and legacy browsers, using ES2015 as the line in beteween, few developers came back with the usual story that document.write is ugly and yada yada, missing the whole point that my technique would not ever touch modern browsers, but will simply always work reliably on legacy.

However, I have to admit posting about document.write in 2018 felt dirty, so dirty that instaed of sleeping I have created another proof of concept fully based on HTML, with a pinch of 100% cross browser JavaScript to bring optionally in at runtime either ES2015 or ES5 bundles.

I’ve called it: Universal Bundle Loader, UBL for short.

<!-- targeting native async/await as ES2017 target -->
<script type="module" src="es2017.js"></script>
<!-- targeting ES2015 with ES5 fallback -->
<script nomodule>(function(g,d,s){
// Safari 10.1 is compatible with ESM
// but it ignores the nomodule attribute
if(!/\s+Version\/10\.1(?:\.\d+)?\s+Safari\//.test(navigator.userAgent)){
// grab last script node
s=d.getElementsByTagName('script');s=s[s.length-1];
// append before it a new script (IE < 9 compatible)
s=s.parentNode.insertBefore(d.createElement('script'),s);
// set it up deferred and with a good'ol mime
s.defer=!0;
s.type='text/javascript';
// load the ES2015 bundle, fallback to the ES5 one
s.src=g.Reflect?'es2015.js':'es5.js';
}}(this,document));
</script>

Above technique is 100% compatible with pretty much every browser that ever parsed JS since ES3 era, and even if it shows a triple bundle solution, something that actually shouldn’t really be too hard to automate, if we can already automate 2 different bundles, it can be used to serve only two versions of our bundles, one targeting ES2015 and one targeting ES5 (or even lower than ES5).

You can test it live in here. (Update: current live page is using an alternative technique described later on in this post)

To reduce the technique in two bundles insteda of 3, either keep the same output but use es2015.js also as type=module source, or drop the script module and remove the nomodule attribute, together with the User Agent sniffing bit, to decide at runtime which bundle should go in.

<script>(function(g,d,s){
s=d.getElementsByTagName('script');s=s[s.length-1];
s=s.parentNode.insertBefore(d.createElement('script'),s);
s.defer=!0;s.type='text/javascript';
s.src=g.Reflect?'es2015.js':'es5.js';
}(this,document));</script>

About that User Agent sniff …

I can hear already someone screaming something along the line:

Oh come on, you replaced document.write with UA sniffing? Bad dev!

First of all, I haven’t really removed the document.write technique described in the previous post, because to bring in on demand common polyfills, the technique is still valid and usable.

Secondly, the day you will find a real-world issue with that UA sniffing is the day I’ll consider improving it, but if I were you, I would not drop that sniff, unless you decided that Safari 10.1 isn’t interesting for your business.

And yet, I don’t think any of you thinks bringing in this polyfill for that browser only would be a wiser choice than a pragmatic UA sniff that target a single browser, isn’t it?

Without User Agent sniff

Using an intermediate external file, we can avoid the UA sniffing by detecting if the ESM code previously run.

<script type="module">window.ESM=!0;import'./es2017.js';</script>
<script nomodule defer src="ubl.js"></script>

The first script will flag ESM as true and import the ES2017 bundle, while the second file will contain similar code shown before.

(function(g,d,s){if(!g.ESM){
s=d.documentElement;
s=s.insertBefore(d.createElement('script'),s.lastChild);
s.defer=!0;
s.type='text/javascript';
s.src=g.Reflect?'es2015.js':'es5.js';
}}(window,document));

The key, beside using nomodule attribute, is to add defer to the mix so that it eventually runs, in case nomodule is not respected, instantly after the module has been parsed.

That would avoid any browser downloading, even by accident, anything more than just that little closure which will not bring in any file.

Other Techniques or Alternative Opinions

Still coming from my previous post, I’ve described more techniques to feature detect and bring in on demand any polyfill you want through this repository.

You can also find, in the same repo, a link to another post which aim is very similar to mine in my last two posts.

I am talking about Philip Walton post titled Deploying ES2015+ Code in Production Today, where he focues mostly on delivering ES2017+ instead of the most widely available ES2015, which I’m pushing for in here too.

As Summary

It’s safe, and Web compatible, to target 2015 capable browsers, leaving out mostly only IE, but not Edge, something fading away in 2018.

It will probably be just enough in 2020 to target ES2017+ browsers, splitting between regular <script type=module> and <script nomodule>, but we’re not quite there yet.

In few words, even if you use babel-preset-env, I think for production sake it’s better, and safer, to target last 3 years browsers, instead of last 3 versions, since versions lost their meaning in evergreen browsers, and who’s not evergreen won’t easily catch up anyway.

With the Universal Bundle Loader approach though, you can already target legacy, oldish, and modern browesers, covering the entirity of the Web.