Modern script loading

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) { // notice the casing s.type = 'module' s.src = '/modern.js' } else { s.src = '/legacy.js' } document.head.appendChild(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) { // notice the casing s.type = 'module' s.src = '/modern.js' } else s.src = '/legacy.js' } document.head.appendChild(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?



Preload



Since 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:





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:






All Articles