Real World Experience with ES6 Modules in Browsers

All modern browsers landed native ECMAScript 6 modules support last year. I was excited to try them out on a real project and did not have to wait for too long. I started prototyping a web project for a client a few months ago and decided to use native modules as long as it does not prevent me from getting things done.

ES6 modules in browsers?

This post does not explain browser-native modules in detail. I recommend reading Jake Archibald’s excellent article if you want to know more. Here is an example with the essentials:

index.html:

<script type="module" src="app.js"></script>

app.js:

import lib from './lib.js'

lib.doStuff()

Using external dependencies

As long as you write your own modules and import them with ES6 module syntax, you are lucky. Eventually any non-trivial project will depend on external dependencies. And this is where things will become complicated.

Let’s start with an overview of how people write and publish JavaScript libraries nowadays:

Before publishing to npm or a CDN libraries are compiled into one or more other formats that can (or can not) be consumed by browsers or certain versions of Node.js. The most common publishing formats are:

Now comes the sad reality. Most of these formats can not be used with the shiny new import './app.js' syntax. Anything that is not in the new module format simply does not work.

Unfortunately even most libraries authored or published in ES6 module format will not work because they target transpilers and rely on the Node.js ecosystem. Why is that a problem? Using bare module paths like import _ from 'lodash' is currently invalid, browsers don’t know what to do with them.

Using UMD modules with import is not possible either. Browsers throw a SyntaxError when attempting to parse a file as an ES6 module if it contains no export definition.

In real life therefore:

Library usage examples:

// Some authors already publish in browser friendly ES6 format.
import Vue from 'https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.esm.browser.js'

// Lodash in ES6 format is published under a different name. When
// using npm, the whole node_modules folder has to be exposed by the
// static web server.
import camelCase from './node_modules/lodash-es/camelCase.js'

// Other libraries are authored in ES6 module format but use bare
// module paths which does not work in browsers. CDNs like Unpkg can
// help by expanding the modules.
import scale from 'https://unpkg.com/[email protected]/index.js?module'

// Sadly there is no module expansion when installed with npm, this
// will NOT work.
import scale from './node_modules/d3-scale/index.js'

See these ES6 module loading examples in your browser.

Loading templates or CSS with import

Quite common practice in transpiler-backed workflows is importing precompiled template or CSS files as modules. Unfortunately native browser imports strictly require the response to be served with JavaScript content type and the body must be parsable as an ES6 module.

There is hope though. One day browsers will implement the System.loader API which would allow adding custom module loaders but the standard is not even ready to be implemented.

Custom loaders with Service Workers

As someone pointed out Service Workers can intercept Fetch API requests. This comes in handy as ES6 import uses fetch behind the scenes.

That means it is possible to turn any fetched resource into a module by turning the response into a string that the browser can parse as ES6 code. For example a template file can be turned into an exported JavaScript string or function.

Unfortunately Service Workers are not yet as widely implemented by browsers as ES6 modules but I was too curious and implemented a rudimentary text file loader. Check out the examples and more specifically the worker itself:

self.addEventListener('fetch', (event) => {
  // Hijack all fetch requests to URLs ending with txt
  if (event.request.url.endsWith('.txt')) {
    event.respondWith(
      fetch(event.request.url)
        .then((response) => response.text())
        .then((body) => {
          // Export the response body as a JavaSript string.
          // The response body has to be sanitized before turning it
          // into JavaScript code.
          // Credits: https://stackoverflow.com/a/22837870
          const newBody =
            `export default "${JSON.stringify(body).slice(1, -1)}"`
          // Replace the original response with an ES6 module
          return new Response(newBody, {
            headers: new Headers({
              'Content-Type': 'application/javascript'
            })
          })
        })
    )
  }
})

This idea can be adopted to load CSS into the page or compile templates on the fly. Beware however, Service Workers is a very new technology.

Fun fact: as of writing you can not write Service Workers as ES6 modules.

Bundling

Any serious project will at some point have to support older browsers that don’t implement native ES6 modules yet. Unless the project only has a very few and small dependencies fetching hundreds of small JavaScript files will become a bottleneck. Bundling is inevitable.

Widely used bundling tools like Webpack or Browserify will have no problem transpiling ES6 modules. Likely you will transpile the code into pre-ES6 modules, therefore <script type="module"> will have to be rewritten to plain old script tags. Check the documentation of your tool to see how that is possible.

I used AssetGraph-builder to bundle the entire project and it did indeed need ugly workarounds as AssetGraph itself does not yet support ES6 modules.

Further gotchas

Since browser-native modules are a very new technology expect development tools to choke on them here-and-there, one such example is Karma I had trouble with.

Another challenge can be supporting old browsers. If you develop using ES6 native modules your raw code will only work in the latest browser versions.

Sharing code in ES modules in Node.js can be tricky too. Native ES6 modules in Node are behind the --experimental-modules flag and require naming modules differently using the .mjs extension. You can use babel-node but (surprise!) “ES6-style module-loading may not function as expected”.

Conclusion

You can start using ES6 modules for experiments, prototyping and small projects today. Anything beyond that will eventually require more tooling. In fact introducing Webpack in development is soon going to happen on my current client project.

My wishlist for the future:

Further reading

I used these articles while researching for this post and highly recommend them to learn further details about native ES6 modules in browsers and Node.js.