DEV Community

Kenneth Lim
Kenneth Lim

Posted on

ES Modules and how we got where we are

This is a public draft of this article, there will likely be corrections, edits, rewrites for any parts below. It is a long read and I plan to eventually break it out into smaller parts when I republish it somewhere else in a more consolidated way.


We know from the history of JavaScript, at the very start, we didn't anticipate JavaScript to become so prevalent, it definitely wasn't certain JavaScript will be anything more than just an experiment. Other experiments to run code in the browser such as Flash, Java Applet, and Silverlight have come and went with JavaScript now being the de facto standard way to run code in the browser (WebAssembly technically is another way to run code in the browser but it still needs JavaScript as an interface).

That is all an excuse to say that it wasn't really anticipated at the beginning that JavaScript will be used to build the highly complex web apps and even server apps that we are using JavaScript for nowadays. Typical JavaScript files may had been at most a few kilobytes 20 years ago gradually grew and nowadays if we don't apply the different techniques that were developed over the years to keep things compact, we will be looking at several megabytes of JavaScript files being transfer for each page load, easily thousands of times more data that needed to be transferred, massively slowing page load speed down.

The more we want our web app to be capable of doing, the more complex our code becomes. The more complex our code becomes, the harder it is to manage effectively. The more features we wanted, the more code we need. The more code we need, the larger the JavaScript size we need to send to the browser. What we are trying to balance here is a tug of war from three sides:

  • Feature and capabilities
  • Transfer size/speed
  • Code maintainability

Stage 1 - The default pattern

Let's start at the beginning and work our way to today (as of time of writing). The basic way of including JavaScript in a web page is the <script> tag.

<script src="./script.js"></script>
Enter fullscreen mode Exit fullscreen mode

I'll leave out inline script in both script tag and HTML elements as they are functionally the same. As our code get more and more complex, we would like to have more than one file to help increase maintainability: we can split up utility functions, class definitions, etc in separate files:

<script src="./utils.js"></script>
<script src="./User.js"></script>
<script src="./script.js"></script>
Enter fullscreen mode Exit fullscreen mode

This is similar to how you can include libraries by including their script tags. This works because for any script that are included in the web page with a <script> tag, they all use the same global variable scope: a global variable defined in one script file is accessible in another script file directly. Now we have Feature and capabilities and some Code maintainability but in the process, we have sacrificed Transfer size/speed. How so you may ask?

The reason is a little complicated and I won't go into detail here, Rolldown has an excellent writeup for why that is the case. The short version of it is that, with each of the script tag we have, they are individual HTTP requests. Each HTTP requests takes time that is in addition to the actual data transfer time, which means that we want to minimize the number of HTTP requests: instead of making three HTTP requests in the above snippet, it would be faster if we just make one HTTP request for the same contents. That means we need to somehow combine the three files into one, ie. some sort of bundling.

Stage 2 - Line them up

To understand this next stage, we will need some example content in our scripts.

// utils.js
function capitalize(str){
  return str.charAt(0).toUpperCase() + str.slice(1);
}
Enter fullscreen mode Exit fullscreen mode
// User.js
class User {
  constructor(name){
    this.name = capitalize(name);
  }
}
Enter fullscreen mode Exit fullscreen mode
// script.js
let user;
const button = document.createElement("button");
button.innerHTML = "Create user";
button.addEventListener("click", () => {
  user = new User("sam");
});

console.log(user.name) // Logs "Sam"
Enter fullscreen mode Exit fullscreen mode

Now what we want to achieve is a single JavaScript file that our browser can include with a single <script> tag that will do the same thing as if we included all three files separately. Well that's easy, because if you remember, all <script> tag scripts on a web page has the same global variable scope, which means that they are functionally the same if they were all in the same file:

// script.js
function capitalize(str){
  return str.charAt(0).toUpperCase() + str.slice(1);
}

class User {
  constructor(name){
    this.name = capitalize(name);
  }
}

let user;
const button = document.createElement("button");
button.innerHTML = "Create user";
button.addEventListener("click", () => {
  user = new User("sam");
});

console.log(user.name) // Logs "Sam"
Enter fullscreen mode Exit fullscreen mode
<script src="./srcipt.js></script>
Enter fullscreen mode Exit fullscreen mode

This will only make one HTTP request to get script.js which fulfills our requirements. But that's where we started from! We split things up into separate files intentionally to make things more maintainable, if we combine them again we are just going around in circles, trading Code maintainability for Transfer size/speed. To get the best of both worlds, we want to write our code in separate files which we then combine into a single file before we let our server serve it to the browser. This is where the first bundling technique in JavaScript comes from, it's the very advanced technique we call "concatenation". Yes it is joining string (in this case our code) together, yes I'm being sarcastic, but only because it is such a straightforward and simple idea. What we essentially do is to take the three files we have: utils.js, User.js, and script.js then create a script that we can run to join the text in these three files together.

# An example Bash script that may do so
cat utils.js >> main.js
cat User.js >> main.js
cat script.js >> main.js
Enter fullscreen mode Exit fullscreen mode
<script src="./main.js"></script>
Enter fullscreen mode Exit fullscreen mode

Whenever we make a change to any of our source code files, we run the concatenation script, which will update the main.js combined script which we include in our HTML to have the benefit of only making one HTTP request. Great, now we are not sacrificing Code maintainability for Transfer size/speed or vice versa!

Sidenote: This is one of the reason cited in some (but not all) style guides for JavaScript where semicolon ; is recommended at the end of every statement in JavaScript even though it is optional. The precise reason for why, I will leave it as an exercise for you to figure out.

Stage 3 - Nobody calls it iffy

Unfortunately, our app keeps getting more complicated. Currently we are exposing several variables onto the global scope: capitalize, User, user, and button, as we continue developing, we will end up exposing more and more to the global scope. Eventually, either as a result of including a library script, or as our own code get more complicated, we will come across unintended bugs. Let's say we add another file to our example:

// login.js
let user;
const loginButton = document.querySelector("#loginButton");
loginButton.addEventListener("click", async () => {
  const username = document.querySelector("#username").value;
  const password = document.querySelector("#password").value;

  const response = await fetch("http://example.com/login", {
    body: JSON.stringify({
      username: username,
      password: password
    }),
    method: "POST"
  });
  const result = await response.json();

  user = new User(result.username);
});
Enter fullscreen mode Exit fullscreen mode

Next we include this new login.js file into our concatenation pipeline:

# An example Bash script that may do so
cat utils.js >> main.js
cat User.js >> main.js
cat script.js >> main.js
cat login.js >> main.js
Enter fullscreen mode Exit fullscreen mode

Great, it is not that much work to add a new file to our pipeline and we added another file while needing only one HTTP requests from the browser, win-win! Except, we are expecting a bug here, what is it? Let's look at the concatenated file:

// main.js
function capitalize(str){
  return str.charAt(0).toUpperCase() + str.slice(1);
}

class User {
  constructor(name){
    this.name = capitalize(name);
  }
}

let user;
const button = document.createElement("button");
button.innerHTML = "Create user";
button.addEventListener("click", () => {
  user = new User("sam");
});

console.log(user.name) // Logs "Sam"

let user;
const button = document.querySelector("#loginButton");
button.addEventListener("click", async () => {
  const username = document.querySelector("#username").value;
  const password = document.querySelector("#password").value;

  const response = await fetch("http://example.com/login", {
    body: JSON.stringify({
      username: username,
      password: password
    }),
    method: "POST"
  });
  const result = await response.json();

  user = new User(result.username);
});
Enter fullscreen mode Exit fullscreen mode

Noticed any issue here?

We have redeclarations of both user and button variables here which are not allowed in the same scope, running this script will immediately throw an error in the browser complaining about redeclaration. In the past when we only have var it is worse because one value to overwrite the other, but we should avoid using var in practice now that we have better options. We can work around this issue by renaming the second instance of user and button to something else, however that decreases Code maintainability because now we need to remember across however big our codebase is, all the possible variable names in case we get a collision. With "naming things" being one of two hardest problems in computer science, we should avoid it as much as possible. Is there some way we can still use the variable names we want for both instances?

Of course there is, that's why we are here in the first place. What we can do is to use something called an Immediately-Invoked Function Expression (IIFE), the name is a bit of a mouthful and the idea behind it touches on some esoteric feature of JavaScript.

Variable scoping

Variable scoping in JavaScript is infamous for its complexity, special cases, and easy to get wrong, it deserves its own article so I won't go into all the details here. Roughly speaking, there are two types of variable scoping in JavaScript (post-ES6): block scoping and function scoping. Block scoping means each block, represented by any combination of { /* ... */ }, creates its own scope and as such is permitted to redefine a higher level variable name. This includes things like if statements, for loops, or even manually created block by just surrounding statements with { /* ... */ }.

let a = 10;
if(someValue === true){
  let a = 20; // This is allowed
}
Enter fullscreen mode Exit fullscreen mode

Function scoping on the other hand means scopes are created by the current closest function the variable is in, meaning if statements and for loops don't create their own scope.

function sum(a, b){
  var c = a + b;
}

sum(1, 2);
console.log(c); // Will throw ReferenceError
Enter fullscreen mode Exit fullscreen mode

let and const are both block scope variable declarator while var is function scope, this is another reason why let and const are preferred over var these days.

This means that if we want to reuse the same variable names, we need to put them into their own scopes. While we are not using var anymore to declare variables, a function declaration in the form of function someAction(){} may still have block scope behavior, plus there are some other feature of a function that is useful for our purpose later. As such the first building block to IIFE is a function. As an example, let's use the login.js file:

// login.js
function(){
  let user;
  const loginButton = document.querySelector("#loginButton");
  loginButton.addEventListener("click", async () => {
    const username = document.querySelector("#username").value;
    const password = document.querySelector("#password").value;

    const response = await fetch("http://example.com/login", {
      body: JSON.stringify({
        username: username,
        password: password
      }),
      method: "POST"
    });
    const result = await response.json();

    user = new User(result.username);
  });
}
Enter fullscreen mode Exit fullscreen mode

Here we'll put our code in an anonymous function for now, obviously this code isn't very useful at all since the function is just a declaration, the code within it does not run. The next step is to actually run the function, since we didn't assign a name to this anonymous function we can't call it by its name. However, it is entirely possible to call this function. In JavaScript, if you add () brackets to the end of anything, you are attempting to call it as a function and this includes anonymous functions. That being said, if you try to do it with just function(){}(), you will get a syntax error. That is because in this form, it is ambiguous what your intent with this code is. Our intent here is to call this anonymous function expression with a function call, which means we need to make this intent more explicit. We can do this by forcing the function declaration to be an expression by surrounding it in brackets, resulting in (function(){})(). This is the full IIFE pattern.

// login.js
(function(){
  let user;
  const loginButton = document.querySelector("#loginButton");
  loginButton.addEventListener("click", async () => {
    const username = document.querySelector("#username").value;
    const password = document.querySelector("#password").value;

    const response = await fetch("http://example.com/login", {
      body: JSON.stringify({
        username: username,
        password: password
      }),
      method: "POST"
    });
    const result = await response.json();

    user = new User(result.username);
  });
})();
Enter fullscreen mode Exit fullscreen mode

We can do the same with all of our code and then concatenate them to get the following:

// main.js
(function(){
  function capitalize(str){
    return str.charAt(0).toUpperCase() + str.slice(1);
  }
})();

(function(){
  class User {
    constructor(name){
      this.name = capitalize(name);
    }
  }
})();

(function(){
  let user;
  const button = document.createElement("button");
  button.innerHTML = "Create user";
  button.addEventListener("click", () => {
    user = new User("sam");
  });

  console.log(user.name) // Logs "Sam"
})();

(function(){
  let user;
  const button = document.querySelector("#loginButton");
  button.addEventListener("click", async () => {
    const username = document.querySelector("#username").value;
    const password = document.querySelector("#password").value;

    const response = await fetch("http://example.com/login", {
      body: JSON.stringify({
        username: username,
        password: password
      }),
      method: "POST"
    });
    const result = await response.json();

    user = new User(result.username);
  });
})();
Enter fullscreen mode Exit fullscreen mode

Great, now everything has its own scope and we no longer have variable name collision/redeclaration issue, but this is non-working code. If we have a closer look, yes we have put everything into their own scope, but we have put everything into their own scope: code from one file can no longer access variables from another file, including those that was intended to be accessible such as the capitalize function. To get around this, we can utilize the return value of a function.

const capitalize = (function(){
  function capitalize(str){
    return str.charAt(0).toUpperCase() + str.slice(1);
  }

  return capitalize;
})();
Enter fullscreen mode Exit fullscreen mode

Since an IIFE is a function execution, we can capture its return value the same way we capture any other function return values. If all the IIFE are in the same top level scope as we have here, we won't need to do anything extra, however for some more obscure situation, there is another feature of IIFE that we can utilize which is using function parameters.

const capitalize = (function(){
  function capitalize(str){
    return str.charAt(0).toUpperCase() + str.slice(1);
  }

  return capitalize;
})();

// By using this function parameter, 
// we effectively renamed `capitalize` to `cap` and scoped it
(function(cap){
  class User {
    constructor(name){
      this.name = cap(name);
    }
  }
})(capitalize); // We pass in the in scope `capitalize` here
Enter fullscreen mode Exit fullscreen mode

Putting what we have together:

// utils.js
const capitalize = (function(){
  function capitalize(str){
    return str.charAt(0).toUpperCase() + str.slice(1);
  }

  return capitalize;
})();

// User.js
const User = (function(){
  class User {
    constructor(name){
      this.name = capitalize(name);
    }
  }

  return User;
})();

(function(){
  let user;
  const button = document.createElement("button");
  button.innerHTML = "Create user";
  button.addEventListener("click", () => {
    user = new User("sam");
  });

  console.log(user.name) // Logs "Sam"
})();

(function(){
  let user;
  const button = document.querySelector("#loginButton");
  button.addEventListener("click", async () => {
    const username = document.querySelector("#username").value;
    const password = document.querySelector("#password").value;

    const response = await fetch("http://example.com/login", {
      body: JSON.stringify({
        username: username,
        password: password
      }),
      method: "POST"
    });
    const result = await response.json();

    user = new User(result.username);
  });
})();
Enter fullscreen mode Exit fullscreen mode

Now only the variables we want to expose to global, capitalize and User, are exposed. Everything else will have their own scope, ie. user and button.

Stage 4 - Abstraction not distraction

One downside to IIFE is we have to use IIFE in all of our source code files, it adds noise and boilerplate to our code where we don't want it. We want to just write our code and then define what should be exposed globally and what shouldn't, in a simpler and cleaner way. We can come up with a convention for defining what values will be exported and even imported, then using automated tools, parse these convention and create the IIFE for us. What we are describing here is essentially a module standard and a bundler respectively.

Module standard

I'll only mention two of the module standards that have any traction in JavaScript and only describe one, these two standards are CommonJS and Acynchronous Module Definiton (AMD). Both of them attempts to achieve similar things with notable design and technical differences, while CommonJS is adopted by Node.js as its (and later on one of its) module system, the most notable implementation of AMD is RequireJS. AMD in general however has fallen out of use in the modern module landscape, as such I'm going to skip over AMD and just talk about CommonJS here.

CommonJS was originally designed with server side JavaScript in mind, before Node.js was even a thing, however I'm going to focus more on the browser side usage partly because server side modules are incredibly complex with numerous special cases. What we need to know is that CommonJS just happens to solve our problem of needing a way to define import and exports to our files so that we can make them into sensible IIFE later. Let's write out how we may define utils.js with CommonJS syntax:

// utils.js
function capitalize(str){
  return str.charAt(0).toUpperCase() + str.slice(1);
}

module.exports = capitalize;
Enter fullscreen mode Exit fullscreen mode

and how we can use it in User.js:

// User.js
const capitalize = require("./utils.js");

class User {
  constructor(name){
    this.name = capitalize(name);
  }
}

module.exports = User;
Enter fullscreen mode Exit fullscreen mode

The two key elements of CommonJS are require() and module.exports (sometimes this is just exports), which define imports and exports respectively. module.exports is essentially a variable you can assign any JavaScript value to, here in utils.js we are assigning it to the capitalize function but if needed to export more than one values, we can set module.exports to be an object instead: module.exports = { capitalize: capitalize, anotherValue: "Hello World!" }. require acts as a function that we can call which will import and evaluate to the module.exports value of the JavaScript file that the path argument is pointing to. In other words, we export whatever values we want by assigning them to module.exports then import that same values by importing them with require(). With this we can automate the process to have a bundler read these import and export statements and build a dependency graph and the relevant IIFE structure as needed. I'll expand a bit more on bundlers in the next section.

There are a couple caveat/special behavior of CommonJS that are worth pointing out. First, it is possible for multiple files to require() the same file and the evaluated value are shared across these files:

// first.js
const obj = {
  child1: {
    name: "Alex"
  },
  child2: {
    name: "Sam"
  }
};

module.exports = obj;
Enter fullscreen mode Exit fullscreen mode
// second.js
const obj = require("./first.js");

// Here we change the value of `child1`
obj.child1.name = "Nick";
Enter fullscreen mode Exit fullscreen mode
// third.js
const obj = require("./first.js");
require("./second.js");

console.log(obj.child1.name); // This now logs `"Nick"` instead of `"Alex"`.
Enter fullscreen mode Exit fullscreen mode

Next, as you may have noticed in the example above, in third.js I used require("./second.js") while second.js does not have a module.exports nor am I assigning the require() call to a variable. This is a side effect import, essentially I'm importing the module only to have its code executed and not using its export value, if it even had any. All require() calls executes the entirety of the imported file synchronously before it moves on, which will include side effects, regardless of whether I'm doing a side effect import or regular import. However if we combine this with the first point above, remember the same require() path resolves to the same object? This only works if that code file is evaluated and executed once, which means the side effect execution only run once regardless of how many require() uses the same file path.

This is particularly important if your module has side effect. In general side effects can be tricky to deal with especially as your application gets more complex, I would strongly advise against having any side effects in CommonJS modules (obviously other than the root file) unless you really know what you are doing.

Bundler

We'll leave CommonJS as is for now and start looking at bundlers: how will we be able to transform our CommonJS module code into nicely scoped IIFEs in a single file? While concatenation is in a way a bundler too, we have already seen that we need to scope things with IIFE ourselves which we didn't want to do, at the same time I have implied that CommonJS was designed for server side JavaScript, which by the time when we started to seriously look at browser side JavaScript bundling, Node.js is already well established and gaining traction. What people soon realise is that both Node.js and browser side JavaScript are JavaScript (ok, that is obvious to everyone in the first place), which means that if we have some code that does not use specific Node.js or browser API and just process some logic, they should be usable in both environments, in other word it should be possible to share code between Node.js and the browser.

The problem is that Node.js uses CommonJS and your Node.js code is likely to be written in CommonJS but the browser doesn't run CommonJS code, it does not know what require() or module.exports are. In comes Browserify, which does exactly what it says on the tin, it browser-ify your Node.js code so that it can run in the browser. At the same time Browserify also achieve the goal of bundling for us so that our collection of CommonJS code can be bundled into just one file. Browserify essentially creates a form of IIFE encapsualtion for us while preserving the general behavior of CommonJS so that code used in Node.js and the browser don't deviate too much in behavior. I won't go into details about how Browserify worksbut you can read more here.

Soon after Browserify gained traction (or shortly before depending on how you measure), people started to explore JavaScript bundling and the general JavaScript DevOps seriously. We started to see an explosion of tools and ideas spanning not just JavaScript but also HTML and CSS too. We were getting things like linter, test runner, code transformer, task runner, languages that compile to HTML/CSS/JavaScript, frontend frameworks, etc. A notable entrant of this period is Webpack which in a way seeks to consolidate many of the functionalities above into a single tool, creating a comprehensive bundling solution that can work with all elements of the web: HTML, CSS, JavaScript, or even image assets. Many of the ideas we expect of a modern build tool/bundler were either pioneered by Webpack or popularized by it. Even today, Webpack, despite what some may say, is still a good and valid tool for JavaScript bundling.

The whole ecosystem around JavaScript tooling was and still is very rich and diverse and maybe also saturated, which means I won't be able to detail all these came before or all that exist now. Each of them bring interesting ideas and solves either different problems or the same problem in different ways. We have ESLint, TypeScript, Parcel, Babel, SWC, and so many more. Another tool I want to highlight is Rollup. However to properly understand Rollup, we need to first understand ES Modules.

Stage 5 - Rolling and Shaking

ES Modules or ESM for short, is a bit of a colloquial name for the native module specification that was introduced into JavaScript around 2015. While there are already module definition system such as CommonJS that people are already using, ESM is a module system defined as part of the JavaScript standard itself rather than by an external group or informally through some code implementation. I won't go into the JavaScript standardization process in details here but just to highlight, the JavaScript standardization process involves all the major browser vendors, server side JavaScript groups, and many other people that through set processes came to the standard JavaScript language that we use today: long gone are the days where JavaScript was put together by one person in one week, that's literally ancient history in the overall timeline of JavaScript.

Before we look deeper into ESM, it is reasonably to ask, why ESM? Why another module system and not use CommonJS?

Standards

We have seen multiple times before, each solution/innovation does not exist in a vacuum, nor was it decided arbitrarily, there is always a problem to solve and a solution to it. In the case of ESM, a bit counter intuitively, the problem to solve is not bundling but modules itself. CommonJS is limited in that it needs to work with JavaScript as it exist while ESM has the benefit of creating new semantics in JavaScript by being a new standard, one of the main difference that this leads to is that CommonJS by requirement needs to load, parse, and execute its modules synchronously while ESM can do these asynchronously. For an understanding of acynchronicity in JavaScript do have a read on it here.

CommonJS being a synchronous module loading system means that if a particular file loads three different modules a.js, b.js, and c.js in that order in the code:

const a = require("./a.js");
const b = require("./b.js");
const c = require("./c.js");

// Do something with `a`, `b`, and `c`
Enter fullscreen mode Exit fullscreen mode

CommonJS will first need to load a.js from disk or from network for however long it takes, then parse it, then execute the code within a.js (remember side effects?) which may include loading additional modules. To load all of the modules recursively, CommonJS needs to execute all of the code synchronously, this is because without executing the code itself, we don't know what module is actually required to be loaded, require is just another JavaScript function call, you can call it anywhere such as in a conditional if block, we don't know if a module should be loaded or not without evaluating the if block. Only once a.js and all its child dependencies are loaded and executed then we will start loading b.js and so on.

From what we understand about loading from disk or network, we know that the JavaScript runtime does not need to wait for these asynchronous actions to finish before it starts doing something else. We also know that we can load the code for all the modules we need in parallel as long as we know in advance what that list of modules are. ESM is designed to help with that by enabling asynchronous loading and resolution of modules. Let's look at some basic import syntax of ESM before we talk about how it works:

import a from "./a.js";
import b from "./b.js";
import c from "./c.js";

// Do something with `a`, `b`, and `c`
Enter fullscreen mode Exit fullscreen mode

You may be able to see some echoes of CommonJS in the ESM syntax above which would make sense since they are trying to solve very similar problems, the syntax structure is different but the main idea is the same: we import/require a from the file "./a.js". One of the main difference here from CommonJS is that import statements can only be used in the top level, ie. it cannot be used within an if statement or any other blocks, you should be able to guess why if you think about what problems we are trying to solve with ESM here as compared to CommonJS.

In the case of ESM, rather than loading, parsing, then executing a.js before doing the same with b.js then c.js, we split the operation into three steps:

  1. Construction
  2. Instantiation
  3. Evaluation

The construction steps starts from the entry file, parse it and extracts all the import statements so that it knows which files to fetch next. It does this recursively until all modules are loaded and parsed. No execution or memory allocation is done at this stage and all of these can be parallelized.

Once construction is completed, we will have a full graph of all the modules that make up our programme and no other asynchronous loading is required anymore. Next the instantiation stage allocates memory that link imports with exports (we'll look at the syntax later) of each modules together.

Finally the evaluation stage starts executing the code and since all the modules are already loaded and linked in memory, there is no waiting around for any dependency to load and execute.

By using this system, we allow the asynchronous parts of the module system to be executed asynchronously and when it comes time to actually run the code, everything required is already there and ready to go. Hopefully you can see where it speeds up the overall module loading as compared to CommonJS.

Before diving even deeper, let's have a brief look at the ESM syntax a bit more, I won't go into full details here because this is not a ESM tutorial, do checkout relevant tutorials if you need more detailed explanation of the ESM syntax:

// main.js
import a from "./a.js";
import { myValue } from "./b.js";
Enter fullscreen mode Exit fullscreen mode
// a.js
const myValue = 20;
export default myValue;
Enter fullscreen mode Exit fullscreen mode
// b.js
export const myValue = 42;
Enter fullscreen mode Exit fullscreen mode

In ESM, the export statement like the import statement can only be used in the top level, because remember we need to be able to build the module tree fully before we execute the code, which means there cannot be any condition guarding whether a variable is exported or not. Another thing to note about export that can be seen above it that there are two types of export: a default export (export default) and a named export. Rather than assigning a value to an export variable like module.exports, the export keyword is best understood as representing an operation rather than variable assignment, it exports either a default (unnamed) variable or a named variable. You can use both a default export and named exports at the same time. When it comes time to import, you will also choose either to import the default variable or named variables: import a from "./a.js" imports the default export from a.js into the variable a while import { b } from "./b.js" import the named export b from "./b.js". You can rename the import as follow if you need a different name for the imported named export `import { b as myVar } from "./b.js".

Alright, now we see that in terms of module system, ESM better leverages the asynchronous nature of JavaScript better than CommonJS can, but you may be thinking: wait a minute, all these talk about loading a module etc doesn't really matter to bundling does it? All of the code are already bundled into a single file, there is no asynchronous disk or network request to get those code, it's all already there!

That is entirely correct, as briefly pointed to before, ESM is designed to solve the module problem not the bundling problem. In a way, by using bundlers, we are bypassing the runtime benefit of ESM since bundlers that consume ESM often outputs IIFE which does not use ESM at all. Bundlers will just use ESM as a standard syntax to define the imports and exports we are actually interested in to build the IIFE. As to whether ESM should be used directly without a bundler, I would say yes for server side JavaScript (since it is rare and not very beneficial to bundle server side code) and probably not yet for browser side code (which I will defer again to this piece from Rolldown).

Finally, since we are likely to still bundle even code written with ESM, we can get back to Rollup. Rollup is by design a bundler for JavaScript code written with ESM, there is no built in support for CommonJS modules but that functionality can be added with a plugin, meaning you can use both ESM and CommonJS modules to bundle into a single bundle. The is usually done to bridge the gap between code previously written in CommonJS and ESM, you should avoid using both module system in a single project at the same time. Other than supporting the standard syntax for modules, another major reason Rollup default to supporting ESM is because of "tree-shaking". Consider the following code:

js
// main.js
import { myVar } from "./a.js";

js
// a.js
export const myVar = 20;
export const anotherVar = 10;

We can see in the entry file main.js it imports from a.js however it only imports the named variable myVar and not the variable anotherVar. This means that in the final bundled code, a.js can only contain export const myVar = 20; and we can leave out the definition and export of anotherVar because we know this variable and export is never used. This is what is called dead-code elimination, "dead-code" being code that is never used. To be "dead-code", any lines of code needs to fulfill two conditions: one, it must not define any variables that is used or referenced anywhere else in the code; two, it must not have any side effects. If and only if we can determine for certainty the two conditions above can we mark a piece of code as dead-code and proceed to eliminate it, since it is not being used. Tree-shaking as a term is somewhat similar to dead-code elimination in that the final endpoint is the same, to have code that only includes the actual useful part and not any parts with no effects, usually you will see "tree-shaking" as a term more commonly used by JavaScript bundlers instead of "dead-code elimination".

js
// main.js
const { myVar } = require("./a.js");

js
// a.js
module.exports.myVar = 20;
module.exports.anotherVar = 10;

With the equivalent CommonJS example above, we can see that it should be possible (with a little more extra effort) to determine statically (ie. without actually running the code) that anotherVar is never used and as such can be eliminated, which means that it is technically possible to perform tree-shaking with CommonJS. However, since require() can be called anywhere in the code, including conditionally, there are situations where it cannot be statically confirmed that any piece of code fulfills both of the requirements for dead-code elimination. This, along with other features of CommonJS, makes tree-shaking CommonJS modules more complicated and less effective than tree-shaking ESM which by default provides static guarantees on the relationship between import and exports.

Stage 6 - Lost and confused

At this point, we have caught up to where things are at the time of writing, while there are many detailed aspects of the different things touched on above such as ESM dynamic imports or Rollup's relationship with Rolldown, I'll use the excuse of leaving all these as exercise for you to find out yourselves, that is part of the fun anyway. The goal and takeaway of this article is to present a picture of the history and context behind the most prominent tooling in JavaScript. To make an informed decision about the what and why of any tooling to use, I believe understanding this context is very important. If you would like to just have a suggestion or prescription from me on bundling tooling to use, you are likely in the wrong place, but I hope all these context above equip you with the understanding and critical approach to take when deciding what tools best fits your requirements.

The community around JavaScript is pretty unique as compared to the community in some other programming languages, while from the outside it looks like JavaScript is continually chasing trends or fads when it comes to developer tools, that is just the consequence of having a large community of people actively identifying & solving solutions and sharing openly with everyone the solutions they found. At times (perhaps even now), you may feel lost and confused with all the possibilities out there, to possibly help I can give two pieces of advice: 1. just because what you are using is not new and shiny does not mean you shouldn't use it, if it works, it works; 2. just because you have something that already works, don't let that prevent you from exploring new things with a critical mindset.

Even at this point, there is no doubt things will continue to develop, just as in linguistic, a language that doesn't change is a dead language. Part of the quality that makes a good programmer is the ability to continuously learn, adapting to change, or even create those changes yourself. That last point is what everyone who developed everything in this article above did, whether it is IIFE, Browserify, ESM, Rollup, etc, if you see a need for change or new ideas, try it out and share it with the world.

Reference

Top comments (0)