Since version 13.2.0, Node.js has added support for ES Modules (ESM) syntax, while retaining CommonJS (CJS) syntax.

It is commendable that Node is embracing the new standard after CJS has been around for a long time, and we look forward to a future where Node will be able to break the barrier between two modular grammars without the need for tools…

But in fact, everything is not as good as it seems.

First, imperfect ESM support

1.1 Using ESM on Nodes

Node only supports CJS syntax by default, which means that if you write a JS file with ESM syntax, it will not be executed.

If you want to use ESM syntax in Node, you can do so in two ways:

  • (1) inpackage.jsonIn the new"type": "module"Configuration items.
  • ⑵ Change the file you want to use ESM to.mjsThe suffix.

For the first method, Node will parse all modules in the same path as the package.json file as ESM.

The second method does not need to modify package.json, and Node automatically parses all xxx.mjs files as ESM.

Similarly, if you set “type”: “commonjs” in package.json, it means that modules in this path are parsed as CJS. If the file suffix is.cjs, Node automatically parses it as a CJS module (even if it is configured in ESM mode in package.json).

We can modify package.json as described above so that all modules are executed in ESM form and all modules on the project are written in ESM syntax.

If there are too many old CJS modules that you don’t want to modify, just move them all to a folder and add package.json with {“type”: “commonjs”} in the path of that folder.

When Node parses a referenced module (whether it is imported or required), it parses the referenced module based on its suffix or corresponding package.json configuration.

1.2 ESM References CJS modules

ESM can import CJS modules without any problems, but for Named exports (module.exports), it can only import CJS modules as default export:

/ * *@file cjs/a.js **/
// named exports
module.exports = {
    foo: () = > {
        console.log("It's a foo function...")}}/ * *@file index_err.js **/
import { foo } from './cjs/a.js';  
// SyntaxError: Named export 'foo' not found. The requested module './cjs/a.js' is a CommonJS module, which may not support all module.exports as named exports.
foo();


/ * *@file index_err.js **/
import pkg from './cjs/a.js';  // Import as default export
pkg.foo();  // Normal execution
Copy the code

Click on Github to get the sample code (test1).

We’ll talk about the reasons later.

1.3 CJS References ESM modules

If you are developing an open source project for others to use, and use the ESM format to export the module, then the problem is that CJS require cannot import the ESM package directly.

let { foo } = require('./esm/b.js');
              ^

Error [ERR_REQUIRE_ESM]: require(a)of ES Module BlogDemo3\220220\test2\esm\b.js from BlogDemo3\220220\test2\require.js not supported.
Instead change the require of b.js in BlogDemo3\220220\test2\require.js to a dynamic import() which is available in all CommonJS modules.
    at Object.<anonymous> (BlogDemo3\220220\test2\require.js:4:15) {
  code: 'ERR_REQUIRE_ESM'
}
Copy the code

Following the above misstatement, we could not import the ES module with require (for reasons that will be discussed later) and should instead use the dynamic import method built into the CJS module:

import('./esm/b.js').then(({ foo }) = > {
    foo();
});

// or

(async() = > {const { foo } = await import('./esm/b.js'); }) ();Copy the code

Click on Github to get the sample code (test2). Click on the Dynamic Import document.

Open source projects certainly can’t force users to import in this form, so they have to compile projects into CJS modules using tools like rollup…


As you can see from the above, node.js support for ESM syntax currently has limitations that can be annoying without tools.

These onerous rules and restrictions can also be confusing for anyone trying to get started on the front end.

As OF this writing, Node.js LTS version 16.14.0 has been more than two years since ESM support began with version 13.2.0.

So why hasn’t Node.js been able to get through CJS and ESM yet?

The answer is not that Node.js is hostile to the ESM standard, but that CJS and ESM are so different.

2. Differences between CJS and ESM

2.1 Different loading logic

In a CJS module, require() is a synchronization interface that reads the dependency module directly from disk (or network) and executes the corresponding script immediately.

The ESM standard module loader is completely different. After reading the script, it will not execute it directly. Instead, it will first enter the compilation stage to parse the module, check where import and export are called on the module, and then download the dependent modules asynchronously and in parallel.

At this stage, the ESM loader does not execute any dependent module code, only syntax error checking, determining module dependencies, and determining module input and output variables.

Finally, the ESM enters the execution phase, executing each module script in sequence.

So we often say that CommonJS modules are loaded at run time and ES6 modules are compile-time output interfaces.

In section 1.2 above, we mentioned that CJS named exports cannot be imported from ESM by specifying dependency module properties:

/ * *@file cjs/a.js **/
// named exports
module.exports = {
    foo: () = > {
        console.log("It's a foo function...")}}/ * *@file index_err.js **/
import { foo } from './cjs/a.js';  
// SyntaxError: Named export 'foo' not found. The requested module './cjs/a.js' is a CommonJS module, which may not support all module.exports as named exports.
foo();
Copy the code

This is because the ESM gets the specified dependency module properties (inside curly braces) that need to be statically analyzed at compile time, whereas CJS scripts calculate their named exports at run time, causing ESM to be unable to analyze at compile time.

2.2 Different modes

ESM uses strict mode by default, so this in the ES module no longer refers to global objects (instead of undefined), and variables cannot be used until they are declared.

This is why in browsers,

See more restrictions on the ESM’s strict mode.

2.3 ESM supports “top-level await”, but CJS does not.

ESM supports top-level await (top-level await), i.e., in ES modules, you can use await directly without inside async functions:

// index.mjs
const { foo } = await import('./c.js');
foo();
Copy the code

Click on Github to get the sample code (test3).

This capability is not available in CSJ modules (even with the dynamic import interface), which is one reason why Require cannot load the ESM.

Imagine a CJS module where the require loader synchronously loads an ES module, which asynchronously imports a CJS module, which synchronously loads an ES module… This kind of complex nested logic can become tricky to deal with.

Click here for more discussion on “How to implement Require loading ESM”.

2.4 The ESM lacks a __filename and __dirname

In CJS, the execution of a module is wrapped in functions that specify some common values:

  NativeModule.wrapper = [
    '(function (exports, require, module, __filename, __dirname) { '.'\n}); '
  ];
Copy the code

That’s why we can use __filename and __dirname directly in CJS modules.

The ESM standard does not include the implementation of __filename and __dirname in the Node ESM.

Reference: Node.js source code.


As you can see from the above points, there are huge compatibility issues in Node.js when switching from the default CJS to ESM.

This is also a persistent module specification battle that Node.js is currently struggling to resolve, and will be for a long time to come.

If you want to be more relaxed about using ESM without tools and rules, try using Deno instead of Node, which uses ESM as the module specification by default (though not as ecologically complete as Node).

Third, with the help of tools to achieve CJS, ESM mixed writing

With the help of building tools, you can realize the mixing of CJS module and ES module, and even mix the API of both specifications in the same module at the same time, so that the development does not need to care about the limitations of Node.js. In addition, the build tool can take advantage of the ESM’s static parsing at compile time to achieve a tree-shaking effect and reduce the output of redundant code.

Rollup rollup rollup rollup rollup rollup

pnpm i -g rollup
Copy the code

Rollup-plugin-commonjs, which allows rollup support to import CJS modules (rollup itself does not support CJS modules) :

pnpm i --save-dev @rollup/plugin-commonjs
Copy the code

We create a new rollup configuration file rollup.config.js in the project root directory:

import commonjs from 'rollup-plugin-commonjs';

export default {
  input: 'index.js'.// Import file
  output: {
    file: 'bundle.js'.// Target file
    format: 'iife'
  },
  plugins: [
    commonjs({
      transformMixedEsModules: true.sourceMap: false}})];Copy the code

Plugin-commonjs skips all import/export modules by default. To support a hybrid such as import + require, you need the transformMixedEsModules property.

Then execute rollup –config to compile and package as rollup.config.js.

The sample

/ * *@file a.js **/
export let func = () = > {
    console.log("It's an a-func...");
}

export let deadCode = () = > {
    console.log("[a.js deadCode] Never been called here");
}


/ * *@file b.js **/
// named exports
module.exports = {
    func() {
        console.log("It's a b-func...")},deadCode() {
        console.log("[b.js deadCode] Never been called here"); }}/ * *@file c.js **/
module.exports.func = () = > {
    console.log("It's a c-func...")};module.exports.deadCode = () = > {
    console.log("[c.js deadCode] Never been called here");
}


/ * *@file index.js **/
let a = require('./a');
import { func as bFunc } from './b.js';
import { func as cFunc } from './c.js';

a.func();
bFunc();
cFunc();
Copy the code

Click on Github to get the sample code (test4).

The bundled bundle.js file looks like this:

(function () {
	'use strict';

	function getAugmentedNamespace(n) {
		if (n.__esModule) return n;
		var a = Object.defineProperty({}, '__esModule', {value: true});
		Object.keys(n).forEach(function (k) {
			var d = Object.getOwnPropertyDescriptor(n, k);
			Object.defineProperty(a, k, d.get ? d : {
				enumerable: true.get: function () {
					returnn[k]; }}); });return a;
	}

	let func$1 = () = > {
	    console.log("It's an a-func...");
	};

	let deadCode = () = > {
	    console.log("[a.js deadCode] Never been called here");
	};

	var a$1 = /*#__PURE__*/Object.freeze({
		__proto__: null.func: func$1.deadCode: deadCode
	});

	var require$$0 = /*@__PURE__*/getAugmentedNamespace(a$1);

	var b = {
	    func() {
	        console.log("It's a b-func...");
	    },
	    deadCode() {
	        console.log("[b.js deadCode] Never been called here"); }};var func = () = > {
	    console.log("It's a c-func...");
	};

	let a = require$$0; a.func(); b.func(); func(); }) ();Copy the code

As you can see, rollup Tree shaking removes the deadCode method from module C that has never been called, but the deadCode fragment from module A and B is not removed because we used require when referencing A. js. The use of CJS named exports in B. Js caused Rollup to fail to take advantage of ESM features for static resolution.

In general, when developing projects, it is recommended to write all modules in ESM syntax as much as possible to maximize the use of build tools and reduce the size of the final build file.


I hope this article can provide you with help

FYI:

Node Modules at War: Why CommonJS and ES Modules Can’t Get Along

CommonJs and ES6 Module differences – Answer by Yu Lue Wang

Ruan Yifeng ES6-Module