DEV Community

tsensei
tsensei

Posted on

Modules in NodeJS : ESM vs CommonJS

NodeJS Module Types :

NodeJS has three module types :

  • Core Modules : These are built-in modules that are part of Node.js distribution and are availabe globally without needing to install any additional distribution. Example : fs, http, etc.
  • Local Modules : Custom modules written by developers and saved as individual files.
  • Third-Party Modules : Written by external developers, installed and managed by Node Package Manager (NPM).

NodeJS Module Systems :

Node provides us with 2 module system to work with modules :

  • CommonJS : It is the original way to work with modules using the require() syntax to import and module.exports to export modules.
  • ESM (ECMAScript modules): Newer module system introduced in es6. It uses import to import modules and export to export modules.

Module Importing Priority : core modules > third party modules (node_modules) > local modules

CommonJS :

Importing :

In CommonJS syntax, we import using the require() function. When the require function is invoked, Node goes through the following steps :

  • Resolving
  • Loading
  • Wrapping : Code inside the module is wrapped in a special wrapper which gives the encapsulation and provides with some special properties :
  (function (exports, require, module, __filename, __dirname) {
    // Module code lives in here
  });
Enter fullscreen mode Exit fullscreen mode
  • Evaluating : Node executes the code within the wrapeer and exports function and variables mentioned in module.exports
  • Caching : Modules are cached after loading for the first time. So, any later loading of the module simply returns the cached value without executing it again. Caching will be discussed in details in a later section.

By wrapping the code in this wrapper, Node.js achieves a few things :

  • It keeps top-level variables (defined with var, const, or let) scoped to the module rather than the global object.
  • It helps to provide some global-looking variables that are actually specific to the module, such as: __filename and __dirname

Exporting :

In CommonJS, we can export variables or functions with the export property of the module object provided by the wrapper.

  • For default export, we can set our module.export directly to whatever we want to export. Also, In the importing file, we don't need to match the name of the exported variable/function, as we can have only one default export per module.
  // my-module.js

  function hello(name) {
    console.log(`Hello, ${name}!`);
  }

  module.exports = hello;
Enter fullscreen mode Exit fullscreen mode
  // app.js

  const whateverYouNameIt = require("./my-module");

  hello("world");
Enter fullscreen mode Exit fullscreen mode

Logging the module object in my-module.js gives us:

  Module {
    id: '.',
    path: '/home/user/Lessons/nodejs',
    exports: [Function: hello],
    filename: '/home/user/Lessons/nodejs/module.js',
    loaded: false,
    children: [],
    paths: [
      ...
    ]
  }
Enter fullscreen mode Exit fullscreen mode
  • For named export, we can bind all our exportable variables/functions to the module.exports object. Then we can destructure to use specific ones.
  // my-module.js

  function hello(name) {
    console.log(`Hello, ${name}!`);
  }

  module.exports = {
    hello,
  };
Enter fullscreen mode Exit fullscreen mode

or,

  // my-module.js

  module.exports.hello = function (name) {
    console.log(name);
  };
Enter fullscreen mode Exit fullscreen mode
  // app.js

  const { hello } = require("./my-module");

  hello("world");
Enter fullscreen mode Exit fullscreen mode

Logging the module object in my-module.js gives us:

  Module {
    id: '.',
    path: '/home/user/Lessons/nodejs',
    exports: { hello: [Function: hello] },
    filename: '/home/user/Lessons/nodejs/module.js',
    loaded: false,
    children: [],
    paths: [
      ...
    ]
  }
Enter fullscreen mode Exit fullscreen mode

exports vs module.exports :

From the wrapper, we get a exports object, this is just a reference to the module.exports object. So, whatever we add to the export object, gets added to the module.exports as well. But if we reassign export with some variable/function in hope of default exporting, the link is broken, and module.exports will return an empty object.

ECMAScript Modules :

Importing :

To use import syntax, we need to set "type" : "module" in package.json.

Different than CommonJS module, in ESM module,
import {myFunction} from "./circle.js"
and,
import {myFunction} from "./circle"
isn't the same.

because, in ESM we can import other type of modules beside JS files like JSON or CSS files, and specifying the extension is necessary to disambiguate between them.

When import is invoked, Node goes through the following steps :

  • Resolving : The resolving algorithm is different than that of CommonJS.
  • Loading
  • Wrapping : There is no wrapper for ES modules. they are native to the underlying JS engine. Specifically, ECMAScript modules have their own module scope, which means that the variables and functions defined in an ECMAScript module are not global.
  • Evaluating: Node executes the code within the module.
  • Caching: Modules are cached after loading for the first time. So, any later loading of the module simply returns the cached value without executing it again.

Exporting :

In ESM, we have export for named exports and export default for default exports.

// my-module.js

export function hello(name) {
  console.log(`Hello, ${name}!`);
}

export default function logger(message) {
  console.log(message);
}
Enter fullscreen mode Exit fullscreen mode
// app.js

import whateverYouNameIt, { hello } from "./my-module.js";

whateverYouNameIt("custom message"); // calls the logger function, we can name it anything because of it being a default export

hello("world");
Enter fullscreen mode Exit fullscreen mode

We can have multiple exports from a module, but only one default export.

Module caching :

Caching :

When loading a module, NodeJS executes the code once and caches the result. On subsequent imports from same or other modules, it returns the cached results.

Caching example in CommonJS :

Let's do a little fun experiment to see this in action.

In one.js :

// We generate the current timestamp and add 5 to it
// so, each time we execute this following code, we will have different results

console.log("one.js execution started");

let timestamp = Date.now();
let number = 5;

let sum = timestamp + number;

module.exports = {
  sum,
};
Enter fullscreen mode Exit fullscreen mode

In two.js :

// This file imports the time dependant sum
// but executes after a 5 seconds delay
// We export a dummy variable from this file to reference in three.js

console.log("two.js execution started");

console.log("Loop started");
const startTime = Date.now();
while (Date.now() < startTime + 5000) {}
console.log("Loop finished after 5 seconds");

const { sum } = require("./one");

console.log(`Sum within two.js is ${sum}`);

let dummy = 8;

module.exports = {
  dummy,
};
Enter fullscreen mode Exit fullscreen mode

In three.js :

// This file imports both one.js and two.js

console.log("three.js execution started");

const { sum } = require("./one");

console.log(`Sum within three.js is ${sum}`);

// two.js is imported after getting the sum from one.js

const { dummy } = require("./two");
Enter fullscreen mode Exit fullscreen mode

Intuitively, when we run three.js with node three.js, we expect it:

  1. Log "three.js execution started"
  2. Import the one.js module and execute its code
  3. Log "Sum within three.js is (current timestamp + 5)" with the current time of execution.
  4. Importing two.js module starts the 5 seconds loop and then requires the one.js file to get the sum. So, technically, timestamp is in seconds, so for five seconds delay, we can expect some difference
  5. The log within two.js should generate a sum greater than the one logged in three.js due to difference in execution time.

So, log output should be something like this :

three.js execution started
one.js execution started
Sum within three.js is 1683184022476
two.js execution started
Loop started
Loop finished after 5 seconds
Sum within two.js is 1683184022481
Enter fullscreen mode Exit fullscreen mode

But, what we really get is this :

three.js execution started
one.js execution started
Sum within three.js is 1683184022476
two.js execution started
Loop started
Loop finished after 5 seconds
Sum within two.js is 1683184022476
Enter fullscreen mode Exit fullscreen mode

Even with the five seconds loop, we get the same value for sum which is dependant on time !

So, by now we should be clear that NodeJS when importing a module for the first time, executes the code once and caches the result for future imports.

Caching example in ESM :

// my-module.js

let count = 0;

console.log("Module loaded");

export function increment() {
  count++;
}

export function getCount() {
  return count;
}
Enter fullscreen mode Exit fullscreen mode
// app.js

import { increment, getCount } from "./my-module.js";

console.log("Import 1");
increment();
console.log(getCount()); // 1

console.log("Import 2");
increment();
console.log(getCount()); // 2
Enter fullscreen mode Exit fullscreen mode
//some-other-file.js

// Import the module again from a different file
import { getCount } from "./my-module.js";
console.log(getCount()); // 2 (the same value as the previous call to getCount())
Enter fullscreen mode Exit fullscreen mode

References used :

More details on the underlying workings can be found at NodeJS specification :

Top comments (0)