How ES Modules have redefined web development

-

Published

You know the inspirational phrase "skate to where the puck is going?" Well, in web development... it feels like the puck is teleporting across the rink at Mach 30 sometimes.

That's how I felt diving into how ES Modules work. Turns out, there's been some huge shifts right under my framework-laden nose these past few years. After discovering that this is valid JS across all major browsers...

const js = await import('script.js')

...I had to make a post about it. So let's explore

  1. 🥞 My misconceptions about what bundlers do these days
  2. 🧩 What ES Modules + dynamic imports can do
  3. 🚀 How build tools are evolving for the post-IE era

Onwards!

Foreward: Personal delusions in a webpack world

What I'm sharing here is probably common knowledge to some. Heck, import-able JavaScript modules have lurked in the ECMAScript standard since 2017! But if you've been using "traditional" project configs like create-react-app for a long time, you might think that old-school bundling is how the world works.

So let me ahem unpack the traditional definition of "bundling." In short, it's the concept of taking a chain of JS files like this:

// toppings.js
export {
  blueberries: async () => await fetch('fresh-from-the-farm'),
  syrup = "maple",
}

// ingredients.js
export { flour: 'white', eggs: 'free-range', milk: '2%', butter: 'dairy-free' }

// pancake.js
import { blueberries, syrup } from './toppings'
import { flour, eggs, milk, butter } from './ingredients'

const pancake = new Pancake()

pancake.mixItUp([ flour, eggs, milk, butter ])
pancake.cook()
pancake.applyToppings([ blueberries, syrup ])

And "flattening" the import / export chains into a big bundle pancake 🥞

// bundler-output-alksdfjfsadlf.js
const toppings__chunk = {
  blueberries: async () => await fetch('fresh-from-the-farm'),
  syrup = "maple",
}

const ingredients__chunk = { flour: 'white', eggs: 'free-range', milk: '2%', butter: 'dairy-free' }

const { blueberries, syrup } = toppings__chunk
const { flour, eggs, milk, butter } = ingredients__chunk
const pancake = new Pancake()

pancake.mixItUp([ flour, eggs, milk, butter ])
pancake.cook()
pancake.applyToppings([ blueberries, syrup ])

So we're compressing all the JavaScript files we're developing into a single file for the browser to consume. Back in 2015-era web development, this really was the only way to pull off "importing" one JS file into another. import wasn't even valid JavaScript! It was just some neat trickery that build tools like webpack could pick up and understand.

But silently, in the depths of the ES spec, import and export syntax did become valid JavaScript. Almost overnight, it became feasible to leave all your import and export statements in your code or even gasp ditch your JS bundler entirely 😨

This innovation became what we call modules.

ES Modules

There's an in-depth article from MDN on this topic that's well worth the read. But in short, "ES Modules" (sometimes denoted by .mjs files) are JavaScript files with some exported values for others to import and use. As long as you load your "entry" files with the type="module" attribute:

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

That file is ready to import all the other scripts it wants! Well, as long as those other scripts exist in your project's build of course (we'll ignore CORS issues for now 😁).

This concept of importing what's needed over "flattening all the things" has some nice benefits:

  1. You don't need to load and parse everything up front. By default, anything imported is "deferred" for loading as needed. In other words, your computer won't turn into a fighter jet trying to load JS when you first visit your website.
  2. The need for tooling like webpack can (one day) disappear ✨ Bringing browsers closer to how humans write their code is a huge win for newbies and pros alike 🏆

Dynamic imports take it a step further

Dynamic imports are the spicier side of ES Modules that really make things interesting. As this article from the V8 team describes (creators of Google Chrome's rendering engine), a dynamic import is an asynchronous fetch for some JavaScript whenever you need it.

It's very similar to the fetch API in a way! But instread of grabbing some JSON or plain text, we're grabbing some real, executable code that we want to run.

All you need is a humble one-liner:

const lookAtTheTime = await import('./fashionably-late.js')

...and you just grabbed all the exports from that file. Loading JS on-the-fly like this has a ton of benefits if you're working with single page apps like NextJS or create-react-app. The V8 team offered this elegantly simple take on client-side routing, only loading the JS you need when you click on a link:

const links = document.querySelectorAll('nav > a');
for (const link of links) {
  link.addEventListener('click', async (event) => {
    try {
      // go grab whatever JS the route may need
      const module = await import(`${event.target.href}/script.mjs`);
      // The module exports a function named `loadPageInto`,
      // Which might render some HTML into the body
      module.loadPageInto(document.body);
    } catch (error) {
      document.body.innerHTML = `
        <p>404 page not found</p>
      `
    }
  });
}

I basically just implemented a router in 10 lines of code. (yes, that's a serious overstatement, but it's closer than you might think).

This falls into code splitting, aka loading "components" (or modules) of code whenever the user needs them. Back in the dark ages of bundle all-the-things, you'd have to load all these components up front. This could mean thousands of lines of dead code!

So wait, it's 2021... why does all my tooling look the same?

This was certainly my first question when I read up on this. I recently graduated from create-react-app to NextJS as my React boilerplate go-to, but there's still that same webpack configuration + bundle process to think about 🤷‍♀️

A lot of this is just the curse of abstraction. Looking under the hood, these tools have made great strides since ES modules hit the scene. Namely, tools like NextJS can magically "split" your React app into bite-sized chunks that get loaded as-needed. This means:

  • only load the JS for a page when you actually visit that page
  • only load React components when they actually need to display
  • (bonus) pre-fetch JS when someone is likely to need it. This is a more advanced feature (documented here), but it lets you do all sorts of craziness; say, grabbing resources for a page when you hover over link

There's also the benefit of backwards compatibility when using a bundler. For instance, Internet Explorer has no concept of "modules" or "import" statements, so any attempt to code split will blow up in your face 😬 But with a meta-framework like NextJS by your side, you can polyfill such use cases without having to think about it.

Approaching the post-IE age

If you haven't heard, a major announcement sent ripples through the web dev community recently: Microsoft will officially drop IE 11 support for its products in August 2021 😱

Many are treating this as the ticking timebomb for legacy browser support. When it goes off... we might be safe to lose our polyfills for good. Yes, certain sites for governments and internal business operations will probably stick to their PHP-laced guns. But for us bleeding-edge developers, we may have a whole new frontier to explore 🚀

A world of bundlers that... don't bundle

The tides have certainly shifted in the JS bundler community in the past year. With the prospect of dropping polyfills and aggressive bundling for good, people started turning to the real reasons you want a bundler:

  • To process all your fanciness that isn't valid JS. Think JSX for React components, TypeScript for type checking, Styled Components and CSS modules for CSS-in-JS, etc etc.
  • To spin up your app locally. You could always open HTML files in your browser directly, but you'll loose all that immediate feedback! You should see all your new JS and CSS the millisecond you hit "save."
  • To optimize code for production. You'll probably want some last-minute stripping for added speed, like removing console.logs, minifying everything, linting, and so on.

Because of this refined feature set, the new wave of JS processors are just calling themselves "build tools" to stay more generalized.

Snowpack is really what got the ball rolling from my perspective. They promise all the selling points I listed above, plus the absolute fastest live-reloading in the biz. This is mainly because of that code splitting I mentioned earlier. Since they leave all those modules and dynamic imports in-tact, they avoid re-processing the JavaScript that didn't change. So if you just updated a single React component, it'll reprocess those 10 lines of code and blast it onto the page in a flash ⚡️

Vite is a major contender to note as well. This one was spearheaded by Evan You (the overlord of VueJS) to tackle a similar feature set to Snowpack. It's far too early to say whether I'd prefer this setup to Snowpack, but here's a nice comparison piece if you're considering either for serious apps.

There's also the crazy world of using different programming languages to process your code. ESBuild is a big contender right now, using GoLang to process JavaScript in no time flat.

Call to action: explore these new build tools!

It's definitely worth your time to whip up a sandbox and start compiling some code. Vite's create-app tool is a great one for it's beginner friendliness, with options to use any major framework out-of-the-box (React, Vue, Svelte, and even Lit Element!).

I was caught off guard to find there's no build directory when working in development. The code your write gets mapped to the browser directly, processed on-the-fly whenever you save ❤️

So go forth and see what the future is like! With any luck, we'll get to have our bundle pancake and eat it too 😁

The whiteboardist newsletter

Occasional posts and learnings from a lead Astro maintainer.