preface

ES Module and CommonJS are the perennial problems of JS, as well as the platitudes of many articles. The author also read a lot of relevant articles, but always feel that understand, start when there will be a lot of problems. What about errors such as ERR_REQUIRE_ESM in Node? Why is there a problem in Node when there is no problem with webpack/rollup/ Vite packaging tools? When importobject.default is needed and when it is not? What does esModuleInterop in TypeScript do? Why is there such a thing as an __esModule in a packaging product? In a word, these difficult problems are the legacy of JS community’s adoption of ES Module, as well as the inconsistency between practice and standards. In this paper, one by one to explore its context.

What is CommonJS?

As we know, CommonJS is a module format, which is adopted by NodeJS as the format for its modules. CommonJS originated in 2009, long before THE ES Module appeared in ES2015.

CommonJS is actually very simple, its module.exports is a freely writable object, and requires a module to get its module.exports.

CommonJS was adopted by Node and is widely used by Node, but it did not become the module format that entered the language standard.

What is an ECMAScript Module?

ESM is the format of THE JS language specification in ES2015 (ES6). I don’t need to elaborate on its usage, but let’s go over a few details here.

The import identifier

// foo.js
export const a = 1;
const d = 1;
export default d;

// main.js
import d, { a } from './foo.js';
Copy the code

When we write import d, {a} from ‘./foo.js’; What are we writing?

Here, we need to model./foo.js as an object, and import Specifier is only accessing its properties.

const foo = {
  default: d,
  a,
}
Copy the code

Foo is called a Module Namespace Object, which is exactly the Object returned by Namespace Import

import * as foo from './foo.js';
// foo.default === d, foo.a === a
Copy the code

In other words, we say that the following two are equivalent:

import d from './foo.js';
//////
import * as foo from './foo.js';
const d = foo.default;
Copy the code
import { a } from './foo.js';
//////
import * as foo from './foo.js';
const a = foo.a;
Copy the code

ESM and CJS in practice

As we all know, CJS has always been the dominant format. If we introduce ESM as the new format, we run into two problems.

  1. How to introduce old CJS code in ESM format?
  2. How to introduce new ESM code in CJS format?

This is the grey area of “standards” and the source of many problems. With answers to these two questions scattered around the web, let’s briefly outline the options.

Translated to send

For general JS development, our JS project needs to run on a target platform, NodeJS, browser, Electron, etc. If this is used as an opportunity to convert the dependency of different schemas into one supported by the platform, then the interoperability problem between different formats is solved. For example, # @rollup/ plugin-commonJS can convert CJS modules to ESM according to certain specifications, while TypeScript and others can convert ESM modules to CJS. Because CJS to ESM involves complex parsing, we focus on esM to CJS transformations.

ESM -> CJS

For the same ESM source file

export const a = 1;
const d = 1;
export default d;
Copy the code

Let’s look at a few examples:

  1. TypeScript "module:" "commonjs"
"use strict";
Object.defineProperty(exports."__esModule", { valuetrue });
exports.a = void 0;
exports.a = 1;
const d = 1;
exports.default = d;
Copy the code
  1. ESBuild esbuild foo.js --format=cjs
var __defProp = Object.defineProperty;
var __markAsModule = (target) = > __defProp(target, "__esModule", { value: true });
var __export = (target, all) = > {
  __markAsModule(target);
  for (var name in all)
    __defProp(target, name, { get: all[name], enumerable: true });
};
__export(exports, {
  a: () = > a,
  default: () = > foo_default
});
const a = 1;
const d = 1;
var foo_default = d;
Copy the code
  1. Babel
"use strict";

Object.defineProperty(exports."__esModule", {
  value: true
});
exports.default = exports.a = void 0;
const a = 1;
exports.a = a;
const d = 1;
var _default = d;
exports.default = _default;
Copy the code

As you can see, the result is pretty much the same: we gave a key named exports named NAME the export const NAME value, and exports.default was assigned the export default variable.

There is also the same thing: exports.__esModule. What does this __esModule do?

The answer to this is in import, so let’s see how import becomes require. Take TS, for example.

import d from './foo.js';

console.log(d);
Copy the code
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod{
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports."__esModule", { valuetrue });
const foo_js_1 = __importDefault(require("./foo.js"));
console.log(foo_js_1.default);
Copy the code

It turns out that this is because we can’t be sure if./foo.js is an ESM translation when compiling a single file without an __esModule identifier, which causes the following problems:

const foo = require('./foo.js');
Copy the code

Note that we require(‘./foo.js’) to get the corresponding Module Namespace Object of foo.js, so in effect

const foo = {
  default: d,
  a,
}
Copy the code

So we need to use foo.default to access d.

If./foo.js is a CJS module with module.exports = d, const d = require(‘./foo.js’) can access d directly. To make the ESM and CJS “default exports” behave similarly, we introduce an __esModule distinction so that we don’t need to write the display require(‘./foo.js’).default and let the translator do the work for us.

It is worth noting that it is not clear why almost all translocators support this feature, perhaps because of evolutionary convergence. We just need to know that we don’t need to worry about.default in most cases when we use the translator.

Ask for pies on demand

Not all JS tools that support module features support JS translation. Yes, NodeJS. NodeJS, as a pioneer of JS modularity practices, also has a heavy natural history burden, which introduced a lot of confusion during the transition from CommonJS to ESM, but ESM is now a stable feature of NodeJS.

So how do disabled people like NodeJS get CJS and ESM to communicate with each other? Quite simply, it requires us to help NodeJS handle the JS of both modules through package.json, file name extensions, etc.

NodeJS support for ESM

import

Main ways to import:

  • Relative Specifier: Same as the browser, NodeJS supportimport { a } from './foo.js'Syntax. Note that the suffix is required and can be ‘.js’ or ‘.mjs’. Unlike CJS, you must end with an exact relative path, and you cannot omit the suffix, otherwise the module will not be found. This is consistent with standards, but incompatible with standard packaging tools.
  • Bare Specifier: Similarimport React from 'react'This introduction. This is also supported in TS, Rollup, and other interpreters.Module introduction also has incompatibility with common packaging tools.

NodeJS module introduction

The algorithm can be approximated as follows:

  1. First, Node finds the package.json of the corresponding module using the usual node_modules localization algorithm.
  2. Exports of package.json, main to find the entry file. Exports has priority over Main.

Exports is a new feature introduced by Node. See Conditional Exports for details.

Note that NodeJS does not take into account the effects of fields such as “pkg.module” and “pkg.browser”, even though they are supported by other Bundlers but are never supported by Node.

NodeJS to determine the Module format algorithm

As we know, both ESM and CJS file name extensions can be JS, so Node cannot distinguish between ESM and CJS based on the file name. Instead, Node determines whether a module is ESM or CJS based on a simple set of rules:

  1. If the suffix is CJS, format is CJS.
  2. If the suffix is MJS, format is ESM.
  3. Otherwise, look for the nearest levelpackage.jsonIf its"type": "module", this file is ESM, otherwise CJS.

After determining the format, Node reads both according to different syntax rules. If you want to use the ESM syntax and don’t meet the rules, Node will assume it is CJS and get a syntax error. Otherwise, a runtime error occurs.

Interoperability between CJS and ESM in NodeJS

In NodeJS, the import keyword can be imported into the CJS module, while the require keyword cannot be imported into the ESM module.

  • Introduced the ESM CJS

Import can also be imported into.cjs files, see nodejs.org/api/esm.htm…

Module. exports corresponds to default import, while module.exports. A corresponds to import {a}.

  • CJS is introduced into the ESM

According to NodeJS documentation, nodejs.org/api/esm.htm… Since ESM has asynchronous operations, require cannot be used to introduce ESM (sounds like a far-fetched argument), which is a source of many errors.

For example, node-fetch has package.json

{
  "name": "node-fetch"."version": "3.0.0"."description": "A light-weight module that brings Fetch API to node.js"."main": "./src/index.js"."sideEffects": false."type": "module"
}
Copy the code

According to the format algorithm, if require(‘node-fetch’), the parsed entry file will be considered an ESM, causing errors such as ERR_REQUIRE_ESM. If we want to import, we can use import expression. CJS modules in Node also support import() to import modules, circumventing the require restriction. Unfortunately, import() is asynchronous, so if you need a synchronous approach, you’ll have to look elsewhere. For example, to find alternative modules, translation and so on.

To make it easy for Node and other packaging tools to import ESM and CJS modules on demand, we need to organize package.json like this:

{" name ":" node - fetch ", "version" : "3.0.0", "description" : "A light-weight module that brings Fetch API to node.js", "main": "./src/index.cjs", "module": "./src/index.js", "exports": { "import": "./src/index.js", "require": "./src/index.cjs" }, "sideEffects": false, "type": "module" }Copy the code

Node then selects exports.import when importing the module according to Conditional Exports rules; Exports.require = exports. Require For Node versions that do not support exports, the CJS module in Main is selected.

Note that “module”: “./ SRC /index.js” follows the resolution of Rollup, Webpack, Vite, and other ESM-enabled packaging tools, allowing them to choose ESM over the CJS module of “main”. (In fact, new versions should also support the “exports” preferred mode, with “module” being legacy support).

The end of the Node

NodeJS has stabilized ESM support, but is incompatible with most translation packaging tools. In general, it is recommended to use CommonJS as the translation method for apps and other projects to avoid the incompatibility between NodeJS and common tools.

conclusion

ESM and CJS introduce some confusing phenomena in practice, but both are not difficult from the concept of standards. Packaging translation tools bring complexity to JS writing, because of the degree of support and compatibility of specific ways, for the specific tool how to deal with ESM and CJS can not be taken for granted, encountered problems to find answers in the documentation.

This article is just the tip of the iceberg on the complex issues related to ESM and CJS, and there are many complex representations that need to be answered in standards, documentation, and source code.

The resources

  1. Modules: ECMAScript Modules nodejs.org/api/esm.htm…
  2. Modules: Packages nodejs.org/api/package…
  3. Rollup on Publishing ES Modules: rollupjs.org/guide/en/#p…
  4. Conditional Exports: nodejs.org/api/package…
  5. Discussion: TypeScript cannot emit valid ES modules due to file extension issue Github.com/microsoft/T…