Skip to content
This repository has been archived by the owner on Sep 27, 2022. It is now read-only.

Converting existing project to use ES modules in production

Elian Ibaj edited this page May 20, 2020 · 1 revision

Related reading:

Before

  • author in ES modules syntax
  • bundle and transpile everything with webpack and babel

After

  • keep build step only for browsers that don't support modules
  • ES modules and untranspiled JavaScript for dev and modern browsers

The rest of this article will describe step by step all the work involved in making this change. As you'll (hopefully) see, it's not that much work at all, and I think it's worth it considering the benefits explained in the linked articles on top.

Modern browsers

Bare modules to relative file URLs

First thing I had to do was to replace the "bare" modules import syntax that we're used to from commonjs and webpack with relative URLs as one of the supported module specifiers in ES modules like this:

- import {RouterHandler} from "./router/router-handler";
+ import {RouterHandler} from "./router/router-handler.js";

The tools I linked at the top of this article like snowpack actually allow you to keep writing bare modules as before and the tool does the necessary transformations to make it work in the browser. This can be very convenient when you're migrating a big project. In this case it was easy enough to change them with a find and replace in VS Code's search in files (it even previews the changes as you're writing the regex):

// Search
import \{(.*)\} from "(.*)"

// Replace
import {$1} from "$2.js"

Loading directly from a CDN

That was for our own code. We also use two packages from npm: navigo and markdown. This is where I differed the most from the third article and the tools. What they do is produce one file for each external package like so: ./vendor/navigo.js, ./vendor/markdown.js.

This being a vanilla JavaScript project (no typescript, jsx etc.), I thought it makes sense to keep it super simple and use ES modules directly from a CDN (unpkg.com). This is also in line with the ideal future envisioned in the first article, where a visitor to our site might already have a cached navigo.js in their browser from the same CDN so they won't need to download it again.

Now, whether you use a CDN, or serve your own single-file bundled versions of packages, one problem remains the same: finding libraries published as ES Modules.

In our case, navigo does publish an ESM version so it was super easy to switch to it:

- var Navigo = require('navigo');
+ import Navigo from "https://unpkg.com/navigo@7.1.2/lib/navigo.es.js";

markdown does not publish an ESM version though. Incidentally, this package happens to be unmaintained since many years ago, and there are many alternatives which do publish ESM. But I wanted to keep using the same package so as to simulate as much as possible a realworld scenario, where there are indeed many well maintained packages, for which you might not want to find alternatives to, that do not publish an ESM version (most notably react).

What you can do then, besides sending a PR to the library, is to find the umd version (if the library is meant to be used in browsers it's safe to assume it publishes at least umd) and convert it to ESM yourself. That's exactly what I did for markdown and published it as es-markdown. Here's what the conversion looked like: https://github.com/microbouji/es-markdown/commit/9994f9c99d6f4daac8917ec32336b32afa840ebc

There are a few variations of UMD but they generally look something like this:

(function(exportObj){
  // lib code here
  // putting stuff in exportObj and possibly returning it
})(
  /*
  exportObj dynamically provided here at runtime
  */
)

So when converting to ESM, your job is to get rid of the dynamic runtime acrobatics, and to find the exportObj and replace it with ESM export statements.

In fact this is so mechanic that a code mod or babel plugin can be written that does it for different flavors of umd. One current solution for react might serve as a good starting point if you're interested in doing that.

And after I did this manually, just a few days ago this other solution was published that does it starting from commonjs instead of umd: https://github.com/addaleax/gen-esm-wrapper

Script type module

With all code (our own and libraries) in ESM I could now directly include the entry module in a script tag:

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

and serve the app folder with any local server to get a working dev environment. I added servor to the project and made a script command so you can do yarn start to get live-reloading too.

And that's it for development and modern browsers. Don't let my inability to explain it more concisely make you believe that it's a complicated process.

Code splitting

Code-splitting is not specific to ESM and you're most probably already doing it in your project, but if you're not, you should think about it when converting to ESM. In this project we didn't have code splitting before and I applied a route based code splitting enabled by dynamic import only for the article preview component which is the only one that imports the markdown package.

Legacy bundle

We already had webpack setup before, so I only had to do few changes to make it produce a legacy bundle. The main showstopper here was that webpack is a bundler that expects all your dependencies to be in the file system, so it doesn't know what to do with something like:

import Navigo from "https://unpkg.com/navigo@7.1.2/lib/navigo.es.js";

The easiest way for me to fix that was to install the packages locally and setup aliases from unpkg.com to node_modules:

  resolve: {
    alias: {
      ['https://unpkg.com/navigo@7.1.2']: 'navigo',
      ['https://unpkg.com/es-markdown@0.1.0']: 'es-markdown'
    }
  },

With these in place, webpack can now resolve the navigo module specifier from the import example above to node_modules/navigo/lib/navigo.es.js.

Here too, a resolve plugin can be written to make this work with any package without having to make an alias for each. But since we're already manually installing the packages locally anyway, I don't mind also explicitly adding them to this aliases list which also serves as a quick way to reference all the packages we're using with their corresponding versions.

The other minor changes I made to the config included adding a targets option to babel's env preset (there is no minimum browser support specification in realworld, so I set an arbitrary requirement of ie 11), and splitting the polyfills from the main bundle chunk.

Module / nomodule

If you read the modern script loading article you'll have no trouble understanding the <script src="dist/app.bundle.js" nomodule async defer></script> line in index.html to load the legacy bundle for browsers that don't support modules. I only included the safari polyfill and didn't do anything to fix the double fetch/execute issues for old desktop browsers.

With everything defined in the config file, the command to create the bundle is simply webpack and I also added that to a build script. So yarn build will create a dist folder inside of app with the minified, bundled and transpiled code that will run as far back as IE 11.