skip to Main Content

I read the documentation of bun two days ago. I am having a hard time understanding this:

"The biggest difference between CommonJS and ES Modules is that CommonJS modules are synchronous, while ES Modules are asynchronous. ".

I googled it and found out many people also mentioned this, but no one explained it. Here’s some reference:

From my point, the loading of ES modules are synchronous with the static import. For example:

// exporter.mjs
console.log("exporter");
// importer.mjs
import "exporter.mjs"
console.log("importer");

Using this command node importer.mjs. Well, if the loading of exporter.js is asynchronous, the output would be "importer" printing first and "exporter" following. While the result is "exporter" printing first and "importer" following.
enter image description here

This proved that the loading of ES modules are synchronous with the static import, in which case we could import ES modules in CommonJS modules. While the article I mentioned above all say "we can’t import ES modules in CommonJS modules, we have to use the dynamic import function" , so did the documentation of nodejs. However, we could import ES modules in CommonJS modules in Bun. This is an example:

// exporter.mjs
export let number = 10;
// importer.cjs
const { number } = require("./exporter.mjs");

console.log(number);

Using this command bun importer.cjs. We can get the output like this.
enter image description here

I am really confused.

2

Answers


  1. import * as foo from './foo' being asynchronous means it’s a complete equivalent of
    const foo = await import('./foo')
    which only makes a difference if ‘./foo’ has a top level await

    As the doc says,

    The only exception to this rule is top-level await. You can’t require() a file that uses top-level await, since the require() function is inherently synchronous

    Login or Signup to reply.
  2. The idea that "CommonJS modules are synchronous and ES Modules are asynchronous" by itself might be a bit of an over-simplification which causes this confusion. Let’s add some context.

    How do ES Modules work?

    ES Modules go through the following steps:

    1. Load: Asynchronously loads dependencies.

      From the spec:

      Prepares the module for linking by recursively loading all its dependencies, and returns a promise.

    2. Link: Connects module exports with imports in memory.

      From the spec:

      Prepare the module for evaluation by transitively resolving all module dependencies and creating a Module Environment Record.

      LoadRequestedModules must have completed successfully prior to invoking this method.

    3. Evaluate (and execute): Run the code.

      From the spec:

      Returns a promise for the evaluation of this module and its dependencies, resolving on successful evaluation or if it has already been evaluated successfully, and rejecting for an evaluation error or if it has already been evaluated unsuccessfully. If the promise is rejected, hosts are expected to handle the promise rejection and rethrow the evaluation error.

      Link must have completed successfully prior to invoking this method.

    As you can see from the quotes from the JavaScript/ECMAScript spec, various steps are performed asynchronously however steps depend on each other. In practice, this means dependencies will be evaluated and executed before the dependent, which gives the impression that ES modules are synchronous.

    How do CommonJS modules work?

    CommonJS is different because it uses a function (require) to import modules. This means, dependencies are only known at runtime (when the code is executed). In other words, code is executed first then dependencies are loaded; as opposed to ES modules which loads dependencies first then executes the code. The synchronous nature of require comes in because when a require function is encountered, code after the function is not run until after the require function has loaded and executed the code within the dependency.

    A practical comparison

    This difference in the order of loading vs execution becomes apparent in a case like this:

    CommonJS ES Modules
    a.js
    const b = require("./b");

    console.log(b);

    exports.a = "a";
    import { b } from "./b";

    console.log(b);

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

    setTimeout(() => { console.log(a); }, 0);

    exports.b = "b";
    import { a } from "./a";

    setTimeout(() => { console.log(a); }, 0);

    export const b = "b";
    Logs
    b
    undefined
    b
    a

    In the CommonJS case, a.js requires b.js. As require is synchronous, it blocks the export of a. As a result, a is undefined in b.js.

    In the ES Module case, while a.js also requires b.js, the link between the a export in a.js and import in b.js is set up before code is executed. As module evaluation and execution occurs asynchronously, a is exported before the setTimeout‘s callback execution meaning a will have its value initialised to "a". Why this happens is because Promises are scheduled in the microtask queue while timers are scheduled in the macrotask queue; and microtasks are executed before macrotasks. This itself is a whole topic that already has answers elsewhere on the internet. I’ll include something in the further reading below.

    Further reading

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search