Passing the right code for each browser is not an easy task.
In this article, we will consider several options for how this problem can be solved.
Passing modern code by a modern browser can greatly improve performance. Your JavaScript packages will be able to contain more compact or optimized modern syntax and support older browsers.
Among the tools for developers, the
module / nomodule pattern of declarative loading of modern or legacy code dominates, which provides browsers with sources and allows you to decide which ones to use:
<script type="module" src="/modern.js"></script> <script nomodule src="/legacy.js"></script>
Unfortunately, not everything is so simple. The HTML approach shown above triggers
a script reload in Edge and Safari .
What can be done?
Depending on the browser, we need to deliver one of the options for compiled scripts, but a couple of old browsers do not support all the syntax necessary for this.
Firstly, there is
Safari Fix . Safari 10.1 supports JS modules, not the
nomodule
attribute in scripts, which allows it to execute both modern and legacy code. However, the non-standard
beforeload
event supported by Safari 10 & 11 can be used to polyfill
nomodule
.
Method One: Dynamic Download
You can get around these problems by implementing a small script loader. Similar to how
LoadCSS works. Instead of hoping for the implementation of ES-modules and the
nomodule
attribute in
nomodule
, you can try to execute a module script as a “test with a litmus test”, and based on the result, choose to download a modern or legacy code.
<!-- use a module script to detect modern browsers: --> <script type="module"> self.modern = true </script> <!-- now use that flag to load modern VS legacy code: --> <script> addEventListener('load', function() { var s = document.createElement('script') if ('noModule' in s) { </script>
But with this approach, you must wait for the "litmus" module script to complete before you implement the correct script. This happens because
<sript type="module">
always works asynchronously. But there is a better way!
You can implement the independent option by checking if the
nomodule
is
nomodule
in the browser. This means that we will consider browsers like Safari 10.1 as deprecated, even if they support modules. But it
could be for the
best . Here is the relevant code:
var s = document.createElement('script') if ('noModule' in s) {
This can be quickly turned into a function that loads modern or legacy code, and also provides asynchronous loading of them:
<script> $loadjs("/modern.js","/legacy.js") function $loadjs(src,fallback,s) { s = document.createElement('script') if ('noModule' in s) s.type = 'module', s.src = src else s.async = true, s.src = fallback document.head.appendChild(s) } </script>
What is the trade-off here?
PreloadSince the solution is completely dynamic, the browser will not be able to detect our JavaScript resources until it starts the bootstrapping code that we wrote to insert modern or legacy scripts. Typically, the browser scans HTML for resources that it can download in advance. This problem is solved, but not ideally: you can preload the modern version of the package in modern browsers using
<link rl=modulpreload>
.
Unfortunately, so far
only Chrome supports modulepreload
.
<link rel="modulepreload" href="/modern.js"> <script type="module">self.modern=1</script> <!-- etc -->
If this technique is suitable for you, you can reduce the size of the HTML document into which you embed these scripts. If your payload is small, like a splash screen or client application download code, then dropping the preload scanner is unlikely to affect performance. And if you draw a lot of important HTML on the server to send to browsers, then the preload scanner will be useful to you and the described approach will not be the best option for you.
Here's what this solution might look like in use:
<link rel="modulepreload" href="/modern.js"> <script type="module">self.modern=1</script> <script> $loadjs("/modern.js","/legacy.js") function $loadjs(e,d,c){c=document.createElement("script"),self.modern?(c.src=e,c.type="module"):c.src=d,document.head.appendChild(c)} </script>
It should also be noted that the list of browsers that support JS modules is almost the same as those that support
<link rl=preload>
. For some sites, it may be advisable to use
<link rl=preload as=script crossorigin>
instead of
modulepreload
. Performance may deteriorate because classic script preloading does not imply uniform parsing over time, as is the case with
modulepreload
.
Method Two: Track User Agent
I do not have a suitable code example, since tracking User Agent is not a trivial task. But then you can read the excellent
article in Smashing Magazine.In fact, it all starts with the same
<scrit src=bundle.js>
in HTML for all browsers. When bundle.js is requested, the server parses the User Agent string of the requesting browser and selects which JavaScript to return - modern or legacy, depending on how the browser was recognized.
The approach is universal, but entails serious consequences:
- Since smart servers are required, this approach will not work in a static deployment environment (static site generators, Netlify, etc.).
- The caching for these JavaScript URLs now depends on the User Agent, which is very volatile.
- The definition of UA is difficult and may lead to a false classification.
- The User Agent line is easy to spoof, and new UAs appear every day.
One way around these restrictions is to combine the module / nomodule pattern with the User Agent differentiation to avoid sending multiple versions of the package to the same address. This approach reduces page cacheability, but provides efficient preloading: the HTML-generating server knows when to use
modulepreload
and when to
preload
.
function renderPage(request, response) { let html = `<html><head>...`; const agent = request.headers.userAgent; const isModern = userAgent.isModern(agent); if (isModern) { html += ` <link rel="modulepreload" href="modern.mjs"> <script type="module" src="modern.mjs"></script> `; } else { html += ` <link rel="preload" as="script" href="legacy.js"> <script src="legacy.js"></script> `; } response.end(html); }
For sites that already generate HTML on the server in response to each request, this can be an effective transition to downloading modern scripts.
Method three: fine old browsers
The negative effect of the module / nomodule pattern is visible in older versions of Chrome, Firefox and Safari - their number is very small, because browsers are updated automatically. With Edge 16-18, the situation is different, but there is hope: new versions of Edge will use the Chromium-based rendering engine, which does not have such problems.
For some applications, this would be an ideal compromise: download the modern version of the code in 90% of browsers, and give legacy code to the old ones. The load in older browsers will increase.
By the way, none of the User Agents for which such a reboot is a problem do not occupy a significant share of the mobile market. So the source of all these extra bytes is unlikely to be mobile devices or devices with a weak processor.
If you are creating a site that is mainly accessed by mobile or fresh browsers, then for most of these users, the simplest kind of module / nomodule pattern is suitable. Just make sure you add the
Safari 10.1 fix if older iOS devices come to you.
iOS-. <!-- polyfill `nomodule` in Safari 10.1: --> <script type="module"> !function(e,t,n){!("noModule"in(t=e.createElement("script")))&&"onbeforeload"in t&&(n=!1,e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove())}(document) </script> <!-- 90+% of browsers: --> <script src="modern.js" type="module'></script> <!-- IE, Edge <16, Safari <10.1, old desktop: --> <script src="legacy.js" nomodule async defer></script>
Method Four: Apply Package Terms
A good solution would be to use
nomodule
to conditionally download packages with code that is not needed in modern browsers, such as polyfills. With this approach, in the worst case, polyfill will be loaded or even executed (in Safari 10.1), but the effect of this will be limited to “re-polyfilling”. Considering that today the approach with downloading and executing polyfills in all browsers prevails, this can be a worthy improvement.
<!-- newer browsers will not load this bundle: --> <script nomodule src="polyfills.js"></script> <!-- all browsers load this one: --> <script src="/bundle.js"></script>
You can configure the Angular CLI to use this approach with polyfills, as
Minko Gachev
demonstrated . Having learned about this approach, I realized that you can enable automatic polyfill injection in preact-cli - this
PR demonstrates how easy it is to implement this technique.
And if you use WebPack, then there is a
convenient plugin for
html-webpack-plugin
, which makes it easy to add a nomodule to packages with polyfills.
So what to choose?
The answer depends on your situation. If you are creating a client application, and your HTML contains a little more than
<sript>
, then you may need the
first method .
If you are creating a site that is rendered on the server, and you can afford caching, then the
second method may be suitable
for you .
If you use
universal rendering, the performance gain offered by pre-load scanning can be very important. Therefore, pay attention to the
third or
fourth methods. Choose what suits your architecture.
Personally, I choose, focusing on the duration of parsing on mobile devices, and not on the cost of downloading in desktop versions. Mobile users perceive parsing and data transfer costs as actual expenses (battery consumption and data transfer fees), while desktop users do not have such restrictions. I also proceed from optimization for 90% of users - the main audience of my projects uses modern and / or mobile browsers.
What to read
Want to learn more about this topic? You can start from here: