A Good Way

Philip Walton has a great article about using ES modules in the browser. The main takeaway is that because modern browsers will run scripts with the type="module" attribute but ignore those with the nomodule attribute, whereas legacy browsers will run scripts with the nomodule attribute but ignore those with the type="module" attribute, we now have a simple way to serve slimmed-down ES6+ scripts to browsers that can handle them and polyfill-bloated ES5 fallbacks to those that can’t. (But what exactly does ignore mean? Devil’s in the details, and we’ll get there.) The relevant HTML might typically look something like this:

<head>
  <!-- ... -->
  <script type="module" src="main.js"></script>
</head>
<body>
  <!-- ... -->
  <script nomodule src="es5-main.js"></script>
</body>

Note the placement of the script tags—ordinary scripts are often tucked at the bottom of the body because they run as soon as they’re called, but modules are executed after the document parses and can always safely go in the head.

To be clear, it doesn’t matter whether the ES6+ version is actually a module. In fact, it’s more performant if it isn’t (better for the client to download a single bundle than have to fetch multiple files). We’re using module-support as a proxy for ES6-support. And it’s a damn good proxy! Why? First, any browser that supports modules necessarily supports ES6+. Second, modern browsers auto-update, which means that browsers that support ES6+ but not modules are exceedingly rare (and they’ll just run the ES5 fallback instead—no biggie.)

Now, about those devilish details… what did we mean by ignore in the first paragraph? Actually we meant two things:

  • modern browsers won’t attempt to download scripts marked nomodule (this is what makes the trick worthwhile!);
  • legacy browsers will download but not execute scripts marked type="module".

So to sum up, the procedure here is to write your code in ES6+, use Webpack and Babel to build two bundles (a small ES6+ one and a larger one compiled to ES5 with any needed polyfills; I use Webpack’s babel-loader plugin), and then serve both files as in the HTML snippet above. The browsers will take care of the rest.

But what about our poor legacy users?

A Better Way

If we’re optimizing for browsers that support modules, why not also optimize for those that don’t? There’s no need for legacy browsers to download ES6+ bundles they can’t run. To that end, I’ve come up with an improvement on Walton’s method:

<head>
  <!-- ... -->
</head>
<body>
  <!-- ... -->
  <script>
    var head = document.head || document.getElementsByTagName('head')[0];
    var testScript = document.createElement('script');
    testScript.src = ('noModule' in testScript) ? 'main.js' : 'es5-main.js';
    head.appendChild(testScript);
  </script>
</body>

This little script, written in ES5 for obvious reasons, looks for the noModule property in the HTMLScriptElement interface (see MDN). If it’s there, then the browser supports ES6+, and <script src="main.js"></script> gets appended to the head. If it isn’t, then the fallback <script src="es5-main.js"></script> gets appended instead. Either way, the browser immediately downloads the appropriate file and runs it.

Two quick notes. First, document.getElementsByTagName('head')[0] is there as a fallback for super-legacy browsers that don’t support the faster document.head (we’re talking IE 8). Second, I’ve placed my script at the bottom of the body to ensure that the file in question is run after the document has been parsed. (Of course it works just as well to place the code in an external .js file and call it at the end of the body.)

Webpack and Web Workers

Walton also suggests using separate Webpack configuration files for the two bundles, but this isn’t necessary. Webpack supports exporting multiple configurations from a single webpack.config.js file. This comes in handy for Web Workers, too, which can likewise benefit from the noModule trick described in this post. Just use Webpack and Babel to build two versions of your worker (one ES6+, one ES5), and include something like the following in the source code of your main script:

const testScript = document.createElement('script');
const workerFile = ('noModule' in testScript) ? 'worker.js' : 'es5-worker.js';
const worker = new Worker(workerFile);

This is what I did in my Sudoku Solver. Here is my Webpack configuration file (four bundles!), and here is where I call the appropriate worker from my main script.