Node.js:cjs、bundler 和 esm 简史

介绍

如果您是 Node.js 开发人员,您可能听说过“cjs”和“esm”模块,但可能不确定为什么有两个模块以及它们如何在 Node.js 应用程序中共存。这篇博文将简要介绍 Node.js 中 JavaScript 模块的历史(附示例 🙂),以便您在处理这些概念时更加自信。

全局范围

最初,JavaScript 仅具有全局范围,所有成员均已声明。这在共享代码时会出现问题,因为两个独立文件可能会对某个成员使用相同的名称。例如:

`greet-1.js`

function greet(name) {
  return `Hello ${name}!`;
}

`greet-2.js`

var greet = "...";

`index.html`



  
    
    Collision example
  
  
    
    

    
    

    
  

CommonJS 模块

Node.js 通过 CommonJS(也称为“cjs”)正式引入了 JavaScript 模块的概念。这解决了共享全局作用域的冲突问题,因为开发人员可以决定导出什么(通过 module.exports)和导入什么(通过 require())。例如:

`src/greet.js`

// this remains "private"
const GREETING_PREFIX = "Hello";

// this will be exported
function greet(name) {
  return `${GREETING_PREFIX} ${name}!`;
}

// `exports` is a shortcut to `module.exports`
exports.greet = greet;

`src/main.js`

// notice the `.js` suffix is missing
const { greet } = require("./greet");

// logs: Hello Alice!
console.log(greet("Alice"));

npm 包

Node.js 开发人气飙升,这要归功于 npm 软件包,它允许开发人员发布和使用可重复使用的 JavaScript 代码。默认情况下,“npm”软件包安装在 node_modules 文件夹中。所有“npm”软件包中都存在的 package.json 文件尤为重要,因为它可以通过“main”属性指示 Node.js 哪个文件是入口点。例如:

`node_modules/greeter/package.json`

{
  "name": "greeter",
  "main": "./entry-point.js"
  // ...
}

`node_modules/greeter/entry-point.js`

module.exports = {
  greet(name) {
    return `Hello ${name}!`;
  }
};

`src/main.js`

// notice there's no relative path (e.g. `./`)
const { greet } = require("greeter");

// logs: Hello Bob!
console.log(greet("Bob"));

打包工具

`npm` 软件包能够利用其他开发人员的工作成果,从而大大提高开发人员的生产力。然而,它有一个很大的缺点:`cjs` 与 Web 浏览器不兼容。为了解决这个问题,bundler 的概念诞生了。browserify 是第一个捆绑器,它本质上是通过遍历入口点并将所有 `require()` 代码“捆绑”到与 Web 浏览器兼容的单个 `.js` 文件中来工作的。随着时间的推移,其他具有附加功能和差异化的捆绑器被引入。最值得注意的是 webpack、parcel、rollup、esbuild 和 vite(按时间顺序排列)。

ECMAScript 模块

随着 Node.js 和“cjs”模块成为主流,ECMAScript 规范维护者决定纳入模块概念。这就是为什么原生 JavaScript 模块也称为 ESModules 或“esm”(ECMAScript 模块的缩写)。

`esm` 定义了用于导出和导入成员的新关键字和语法,并引入了默认导出等新概念。随着时间的推移,`esm` 模块获得了动态 import() 和顶级 await 等新功能。例如:

`src/greet.js`

// this remains "private"
const GREETING_PREFIX = "Hello";

// this will be exported
export function greet(name) {
  return `${GREETING_PREFIX} ${name}!`;
}

`src/part.js`

// default export: new concept
export default function part(name) {
  return `Goodbye ${name}!`;
}

`src/main.js`

// notice the `.js` suffix is required
import part from "./part.js";

// dynamic import: new capability
// top-level await: new capability
const { greet } = await import("./greet.js");

// logs: Hello Alice!
console.log(greet("Alice"));

// logs: Bye Bob!
console.log(part("Bob"));

随着时间的推移,由于捆绑器和 TypeScript 等语言能够将 `esm` 语法转换为 `cjs`,`esm` 被开发人员广泛采用。

Node.js cjs/esm 互操作性

由于需求的不断增长,Node.js 在 12.x 版本中正式添加了对 esm 的支持。与 cjs 的向后兼容性实现如下:

  • 除非 package.json 将“type”属性设置为“module”,否则 Node.js 将把 .js 文件解释为 cjs 模块。
  • Node.js 将 .cjs 文件解释为 cjs 模块。
  • Node.js 将 .mjs 文件解释为 esm 模块。
  • 当谈到 `npm` 包兼容性时,`esm` 模块可以使用 `cjs` 和 `esm` 入口点导入 `npm` 包。然而,相反的做法有一些注意事项。请看以下示例:

    `node_modules/cjs/package.json`

    {
      "name": "cjs",
      "main": "./entry.js"
    }

    `node_modules/cjs/entry.js`

    module.exports = {
      value: "cjs"
    };

    `node_modules/esm/package.json`

    {
      "name": "esm",
      "type": "module",
      "main": "./entry.js"
    }

    `node_modules/esm/entry.js`

    export default {
      value: "esm"
    };

    以下运行正常:

    `src/main.mjs`

    import cjs from "cjs";
    import esm from "esm";
    
    // logs: { value: 'cjs' }
    console.log(cjs);
    
    // logs: { value: 'esm' }
    console.log(esm);

    但是,下面的操作无法运行:

    `src/main.cjs`

    // OK
    const cjs = require("cjs");
    // Error [ERR_REQUIRE_ESM]:
    // require() of ES Module (...)/node_modules/esm/entry.js
    // from (...)/src/main.cjs not supported
    const esm = require("esm");
    
    console.log(cjs);
    console.log(esm);

    不允许这样做的原因是 `esm` 模块允许顶级 `await`,而 `require()` 函数是同步的。代码可以重写以使用动态 `import()`,但由于它返回一个 `Promise`,因此强制具有以下内容:

    `src/main.cjs`

    (async () => {
      const { default: cjs } = await import("cjs");
      const { default: esm } = await import("esm");
    
      // logs: { value: 'cjs' }
      console.log(cjs);
    
      // logs: { value: 'esm' }
      console.log(esm);
    })();

    为了缓解此兼容性问题,一些 `npm` 包通过利用 `package.json` 的“exports”属性和条件导出来公开 `cjs` 和 `mjs` 入口点。例如:

    `node_modules/esm/entry.cjs`:

    // usually this would be auto-generated by a tool
    module.exports = {
      value: "esm"
    };

    `node_modules/esm/package.json`:

    {
      "name": "esm",
      "type": "module",
      "main": "./entry.cjs",
      "exports": {
        "import": "./entry.js",
        "require": "./entry.cjs"
      }
    }

    请注意“main”如何指向“cjs”版本,以便与不支持“exports”属性的Node.js版本向后兼容。