Using ES Modules in the Browser Today
This article will show you how you can use ES modules in the browser today.
Until recently, JavaScript had no concept of modules. It wasn’t possible to directly reference or include one JavaScript file in another. And as applications grew in size and complexity, this made writing JavaScript for the browser tricky.
One common solution is to load arbitrary scripts in a web page using <script>
tags. However, this brings its own problems. For example, each script initiates a render-blocking HTTP request, which can make JS-heavy pages feel sluggish and slow. Dependency management also becomes complicated, as load order matters.
ES6 (ES2015) went some way to addressing this situation by introducing a single, native module standard. (You can read more about ES6 modules here.) However, as browser support for ES6 modules was initially poor, people started using module loaders to bundle dependencies into a single ES5 cross-browser compatible file. This process introduces its own issues and degree of complexity.
But good news is at hand. Browser support is getting ever better, so let’s look at how you can use ES6 modules in today’s browsers.
The Current ES Modules Landscape
Safari, Chrome, Firefox and Edge all support the ES6 Modules import syntax. Here’s what they look like.
<script type="module">
import { tag } from './html.js'
const h1 = tag('h1', '👋 Hello Modules!')
document.body.appendChild(h1)
</script>
// html.js
export function tag (tag, text) {
const el = document.createElement(tag)
el.textContent = text
return el
}
Or as an external script:
<script type="module" src="app.js"></script>
// app.js
import { tag } from './html.js'
const h1 = tag('h1', '👋 Hello Modules!')
document.body.appendChild(h1)
Simply add type="module"
to your script tags and the browser will load them as ES Modules. The browser will follow all import paths, downloading and executing each module only once.
Older browsers won’t execute scripts with an unknown “type”, but you can define fallback scripts with the nomodule
attribute:
<script type="module" src="module.js"></script>
<script nomodule src="fallback.js"></script>
Requirements
You’ll need a server to be able to fetch with import, as it doesn’t work on the file://
protocol. You can use npx serve
to start up a server in the current directory for testing locally.
If you want to load ES modules on a different domain, you’ll need to enable CORS
.
If you’re bold enough to try this in production today, you’ll still need to create separate bundles for older browsers. There’s a polyfill available at browser-es-module-loader which is following the spec. However, this isn’t recommended for production at all.
<script nomodule src="https://unpkg.com/browser-es-module-loader/dist/babel-browser-build.js"></script>
<script nomodule src="https://unpkg.com/browser-es-module-loader"></script>
<script type="module" src="./app.js"></script>
Performance
Don’t throw away your build tools like Babel and Webpack just yet, as browsers are still implementing ways to optimize fetching. Still, there are performance pitfalls and gains to be had in the future with ES Modules.
Why We Bundle
Today we bundle our JavaScript to reduce the number of HTTP requests being made, as the network is often the slowest part of loading a web page. This is still a very valid concern today, but the future is bright: ES Modules with HTTP2’s ability to stream multiple assets with server push and browsers implementing preloading.
Preloading
link rel=”modulepreload” is coming soon to a browser near you. Rather than having the browser resolve all the module imports one by one, producing a network waterfall like this …
<script type="module" src="./app.js"></script>
---> GET index.html
<---
---> GET app.js
<---
---> GET html.js
<---
---> GET lib.js
<---
… you’ll be able to tell the browser up front that the pages require html.js
and lib.js
, keeping that waterfall under control:
<link rel="modulepreload" href="html.js">
<link rel="modulepreload" href="lib.js">
<script type="module" src="./app.js"></script>
---> GET /index.html
<---
---> GET app.js
---> GET html.js
---> GET lib.js
<---
<---
<---
HTTP2 with Server Push
HTTP2 is capable of pushing multiple resources in a single response compared to HTTP1.1, which can only deliver one. This will help keep the number of round trips over the network to a minimum.
In our example, it would be possible to deliver index.html
, app.js
and html.js
in a single request:
---> GET /index.html
<--- index.html
<--- app.js
<--- html.js
<--- lib.js
Caching
Delivering multiple smaller ES modules may benefit caching as the browser will only need to fetch the ones that have changed. The problem with producing large bundles is that if you change one line, you invalidate the whole bundle.
async / defer
ES modules are not render blocking by default, like <script defer>
. If your modules don’t need to be executed in the same order they are defined in the HTML, you can also add async
to execute them as soon as they’re downloaded.
Libraries
Popular libraries are starting to be published as ES modules now, however they’re still targeting bundlers and not direct imports.
This humble little import triggers a waterfall of 640 requests:
<script type="module">
import _ from 'https://unpkg.com/lodash-es'
</script>
How about if we do the right thing and just import the one function we need? We’re down to a mere 119 requests:
<script type="module">
import cloneDeep from 'https://unpkg.com/lodash-es/cloneDeep'
</script>
This is just an example to demonstrate that lodash-es
is not built to be loaded directly in the browser yet. To do that, you’ll still need to create your own bundle with ES modules as the target.
Browser Support
As the following table shows, browser support for ES modules is good (and getting better all the time).
The time to start experimenting with ES modules in the browser is now. Soon enough, you’ll be able to use them in all modern browsers without a transpiler or bundler, should you wish.