FORMA

模块系统

Node.js 模块系统

Node.js 的模块系统是其核心设计之一,它允许开发者将代码拆分为多个文件(模块),通过 require(CommonJS)或 import/export(ESM)进行组织和复用。下面详细介绍 CommonJS、ESM 以及两者的互操作。

一、CommonJS 规范

CommonJS 是 Node.js 原生支持的模块系统,每个文件都是一个独立的模块,拥有自己的作用域。模块通过 module.exports 导出内容,通过 require 导入其他模块。

1. require 的加载机制

基本规则

  • require 可以传入模块标识符:
    • 核心模块:如 fspath,直接返回内置模块。
    • 相对路径:如 './mod''../lib',会解析为当前文件所在目录下的文件(可省略 .js.json.node 扩展名)。
    • 绝对路径:如 '/usr/local/lib/mod'
    • 包名:如 'lodash',Node.js 会从当前目录开始向上查找 node_modules 目录,找到对应的包后加载其入口文件。

加载步骤

  1. 解析路径:将模块标识符转换为绝对路径(如果是包名,则查找 node_modules)。
  2. 检查缓存:如果模块已被加载,直接从缓存中返回 exports 对象,不重复执行模块代码。
  3. 创建模块对象:包括 idexportsparent 等属性。
  4. 编译执行:读取模块代码,将其包裹在一个函数中执行(注入 exportsrequiremodule__filename__dirname 等变量)。
  5. 返回导出内容:模块执行后的 module.exports 被缓存并返回。

示例

js
// a.js
module.exports = { foo: "bar" };

// b.js
const a = require("./a"); // 加载 a.js
console.log(a.foo); // 'bar'

2. 缓存机制

  • 每个模块第一次 require 后会被缓存到 require.cache 对象中(键为模块的绝对路径)。
  • 多次 require 同一个模块会返回同一个 exports 对象,模块内的代码只执行一次。
  • 可以通过 delete require.cache[modulePath] 清除缓存(不推荐在生产环境使用,除非特殊热更新场景)。

3. 循环依赖处理

当两个模块互相引用时,CommonJS 采用了有限加载策略:在循环依赖发生时,模块会返回当前已执行部分导出的内容,尚未执行的部分暂为空。这样可以避免无限递归。

示例

js
// a.js
console.log("a starting");
exports.done = false;
const b = require("./b");
console.log("in a, b.done =", b.done);
exports.done = true;
console.log("a done");

// b.js
console.log("b starting");
exports.done = false;
const a = require("./a");
console.log("in b, a.done =", a.done);
exports.done = true;
console.log("b done");

// main.js
const a = require("./a");
console.log("in main, a.done =", a.done, "b.done =", require("./b").done);

输出顺序

text
a starting
b starting
in b, a.done = false   // a 尚未执行完,其 exports 只包含初始的 done: false
b done
in a, b.done = true
a done
in main, a.done = true b.done = true

关键点

  • a.jsrequire('./b') 时,b.js 中又引用了 a.js,但此时 a.js 还没有执行完,aexports 只包含已经执行部分的导出(done: false)。
  • 这种机制使得循环依赖不会导致死锁,但需要开发者确保依赖关系不会造成未定义行为。

二、ES 模块(ESM)支持

Node.js 从 12 版本开始稳定支持 ES 模块(以下简称 ESM),并在后续版本中持续完善。ESM 使用 importexport 语法,与浏览器保持一致。

1. 启用 ESM 的方式

有两种方式告诉 Node.js 将 .js 文件视为 ES 模块:

  • 使用 .mjs 扩展名:文件以 .mjs 结尾,Node.js 默认以 ESM 处理。
  • package.json 中设置 "type": "module":此时项目下所有 .js 文件被视为 ESM;若想个别文件使用 CommonJS,可使用 .cjs 扩展名。

示例

json
// package.json
{
  "type": "module",
  "dependencies": {...}
}
js
// math.mjs 或 math.js (当 type: module)
export const add = (a, b) => a + b;

// app.mjs 或 app.js
import { add } from "./math.js";
console.log(add(1, 2));

2. 动态导入 import()

ESM 提供了 import() 函数,支持异步动态加载模块。它返回一个 Promise,可以在任何地方(包括 CommonJS 模块)使用。这对于代码分割、按需加载非常有用。

js
// 动态导入
const module = await import("./some-module.js");
console.log(module.default);

// 在 CommonJS 中使用
setTimeout(async () => {
  const { add } = await import("./math.js");
  console.log(add(3, 4));
}, 1000);

3. ESM 与 CommonJS 的重要差异

特性CommonJSESM
语法require() / module.exportsimport / export
加载时机同步,运行时加载异步,编译时静态分析(但支持动态导入)
顶级 await❌ 不支持✅ 支持(仅在 ESM 中)
循环依赖处理返回部分导出(有限加载)通过“绑定”机制,但需谨慎使用
模块作用域拥有 __dirname__filenamerequiremoduleexports没有这些变量,需通过 import.meta.url 获取
缓存require.cache基于 URL 的缓存,可通过 import.meta.resolve 等操作
文件扩展名.js.cjs(当 type: commonjs.mjs.js(当 type: module

三、CommonJS 与 ESM 的互操作

在实际项目中,经常需要混合使用两种模块格式。Node.js 提供了一套复杂的互操作规则。

1. 在 ESM 中导入 CommonJS 模块

  • 默认导入:可以使用 import module from 'cjs-module',Node.js 会自动将 module.exports 视为默认导出。
  • 命名空间导入import * as cjs from 'cjs-module',得到的是包含 default 属性以及其他命名属性的对象(因为 CommonJS 的导出没有作为命名导出)。
  • 仅导入命名导出:不能直接使用 import { prop } from 'cjs-module',除非该 CommonJS 模块使用了静态可分析的导出(某些工具可协助,但原生不支持)。

示例

js
// commonjs.cjs
module.exports = { name: "Alice", age: 25 };

// esm.mjs
import cjs from "./commonjs.cjs";
console.log(cjs.name); // 'Alice'

import * as wrapped from "./commonjs.cjs";
console.log(wrapped.default.name); // 'Alice'

注意:ESM 中导入 CommonJS 模块时,require 的同步特性会导致 ESM 依赖树在解析阶段同步执行模块代码,但整体仍然是异步的。

2. 在 CommonJS 中导入 ESM 模块

早期 Node.js 中,CommonJS 不能直接 require 一个 ES 模块(会抛出 ERR_REQUIRE_ESM),必须使用动态 import()(返回 Promise,需在回调或 async 函数中处理):

js
// esm.mjs
export const greet = () => "Hello";

// commonjs.cjs
async function load() {
  const { greet } = await import("./esm.mjs");
  console.log(greet());
}
load();

版本更新:自 Node.js v22.12.0 / v20.19.0 起,require(esm) 已默认启用并趋于稳定——只要目标 ES 模块是同步的(不含顶层 await),require() 就可以直接加载它,返回的对象类似 import() 得到的模块命名空间对象:

js
// esm.mjs(同步、无顶层 await)
export const greet = () => "Hello";

// commonjs.cjs
const { greet } = require("./esm.mjs"); // Node 22.12+/20.19+ 可直接同步 require
console.log(greet());

若目标 ES 模块包含顶层 awaitrequire() 仍会抛出 ERR_REQUIRE_ASYNC_MODULE,此时依然需要动态 import()

3. 互操作注意事项

  • “默认导出”的差异:CommonJS 模块没有真正的默认导出,其 module.exports 整体被视为 ESM 中的 default。因此 ESM 中 import cjs from 'cjs' 相当于拿到了整个 module.exports 对象。
  • 命名导出的缺失:CommonJS 模块的命名属性不会自动成为 ESM 的命名导出。若需要,可以使用工具(如 esbuild@babel/node)或在导出时手动包装。
  • 循环依赖:ESM 的循环依赖比 CommonJS 更安全,因为 ESM 会创建“实时绑定”。但在混合使用时,循环依赖可能导致难以预测的行为,应尽量避免。

总结对比表

方面CommonJSESM
默认扩展名.js.cjs(当 package.json 未指定 type 或 type: commonjs).mjs.js(当 type: module)
导入语句const lib = require('lib')import lib from 'lib'
导出语句module.exports = ...exports.foo = ...export default ...export const foo = ...
加载方式同步、运行时异步、编译时 + 运行时(动态 import)
循环依赖返回已执行部分导出实时绑定,但依赖图必须可解析
静态分析困难(动态 require)容易(import/export 静态)
顶级 await
与 ESM 互操作Node 22.12+/20.19+ 可 require() 同步 ESM(不含顶层 await);更早版本需动态 import()可以同步导入 CommonJS(整体作为 default)

推荐参考资料

理解模块系统是编写可维护、可扩展 Node.js 应用的基础,尤其是在大型项目中,合理选择模块格式并遵循互操作规则至关重要。

相关文章

Series

base

3 / 4