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 的向后兼容性实现如下:
当谈到 `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版本向后兼容。