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()
- You will need a static web server (most likely you already have one) for development as ES6 modules can not be loaded directly from the file system.
- If modules are served from a web server with authentication enabled
(eg. HTTP basic) make sure the script tag has the
crossorigin="use-credentials"
attribute to avoid surprises.
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:
- Written in ES6 syntax or older without using ES6 modules
- Written in ES6 and using ES6 modules
- Written in a compile-to-JavaScript language (like TypeScript or CoffeeScript)
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:
- Published as CommonJS
- Published as Universal Module Definition (UMD) or something else that even older browsers can directly use (or with a lightweight loader polyfill)
- Published as ES6 modules
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:
- You will still need Webpack or something similar to load non-ES6 modules or rely on plain old script tags and global variables. Not pretty but might work until the dependencies are only a handful.
- Libraries published in browser-friendly ES6 format can be used
directly from a CDN or from a local
node_modules
folder when served by the web server. In that case you will have to spell out the whole path includingnode_modules
.
Library usage examples:
// Some authors already publish in browser friendly ES6 format.
import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.5.13/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/d3-scale@1.0.7/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:
- First class ES6 native module support in build tools so that we can still ship optimized and backwards compatible code in production.
- Custom loaders for importing templates, CSS and others as modules so that we can use the same approach for modularizing my code everywhere.
- Maybe a smart static HTTP/2 server that allows importing large dependency trees (many small modules) efficiently without bundling so that there is no need for insanely complicated development servers. See this interesting topic on ES Discuss.
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.