模块系统
Node.js 模块系统
Node.js 的模块系统是其核心设计之一,它允许开发者将代码拆分为多个文件(模块),通过 require(CommonJS)或 import/export(ESM)进行组织和复用。下面详细介绍 CommonJS、ESM 以及两者的互操作。
一、CommonJS 规范
CommonJS 是 Node.js 原生支持的模块系统,每个文件都是一个独立的模块,拥有自己的作用域。模块通过 module.exports 导出内容,通过 require 导入其他模块。
1. require 的加载机制
基本规则:
require可以传入模块标识符:- 核心模块:如
fs、path,直接返回内置模块。 - 相对路径:如
'./mod'、'../lib',会解析为当前文件所在目录下的文件(可省略.js、.json、.node扩展名)。 - 绝对路径:如
'/usr/local/lib/mod'。 - 包名:如
'lodash',Node.js 会从当前目录开始向上查找node_modules目录,找到对应的包后加载其入口文件。
- 核心模块:如
加载步骤:
- 解析路径:将模块标识符转换为绝对路径(如果是包名,则查找
node_modules)。 - 检查缓存:如果模块已被加载,直接从缓存中返回
exports对象,不重复执行模块代码。 - 创建模块对象:包括
id、exports、parent等属性。 - 编译执行:读取模块代码,将其包裹在一个函数中执行(注入
exports、require、module、__filename、__dirname等变量)。 - 返回导出内容:模块执行后的
module.exports被缓存并返回。
示例:
// 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 采用了有限加载策略:在循环依赖发生时,模块会返回当前已执行部分导出的内容,尚未执行的部分暂为空。这样可以避免无限递归。
示例:
// 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);
输出顺序:
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.js中require('./b')时,b.js中又引用了a.js,但此时a.js还没有执行完,a的exports只包含已经执行部分的导出(done: false)。 - 这种机制使得循环依赖不会导致死锁,但需要开发者确保依赖关系不会造成未定义行为。
二、ES 模块(ESM)支持
Node.js 从 12 版本开始稳定支持 ES 模块(以下简称 ESM),并在后续版本中持续完善。ESM 使用 import 和 export 语法,与浏览器保持一致。
1. 启用 ESM 的方式
有两种方式告诉 Node.js 将 .js 文件视为 ES 模块:
- 使用
.mjs扩展名:文件以.mjs结尾,Node.js 默认以 ESM 处理。 - 在
package.json中设置"type": "module":此时项目下所有.js文件被视为 ESM;若想个别文件使用 CommonJS,可使用.cjs扩展名。
示例:
// package.json
{
"type": "module",
"dependencies": {...}
}
// 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 模块)使用。这对于代码分割、按需加载非常有用。
// 动态导入
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 的重要差异
| 特性 | CommonJS | ESM |
|---|---|---|
| 语法 | require() / module.exports | import / export |
| 加载时机 | 同步,运行时加载 | 异步,编译时静态分析(但支持动态导入) |
顶级 await | ❌ 不支持 | ✅ 支持(仅在 ESM 中) |
| 循环依赖处理 | 返回部分导出(有限加载) | 通过“绑定”机制,但需谨慎使用 |
| 模块作用域 | 拥有 __dirname、__filename、require、module、exports | 没有这些变量,需通过 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 模块使用了静态可分析的导出(某些工具可协助,但原生不支持)。
示例:
// 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 函数中处理):
// 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() 得到的模块命名空间对象:
// esm.mjs(同步、无顶层 await)
export const greet = () => "Hello";
// commonjs.cjs
const { greet } = require("./esm.mjs"); // Node 22.12+/20.19+ 可直接同步 require
console.log(greet());
若目标 ES 模块包含顶层 await,require() 仍会抛出 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 会创建“实时绑定”。但在混合使用时,循环依赖可能导致难以预测的行为,应尽量避免。
总结对比表
| 方面 | CommonJS | ESM |
|---|---|---|
| 默认扩展名 | .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 官方文档:Modules: CommonJS modules、Modules: ECMAScript modules
- Node.js 官方指南:ESM 与 CommonJS 的差异
- 深入理解:《Node.js 设计模式》第 1 章(模块系统)
- 工具:
esbuild、@babel/node可用于转换模块格式
理解模块系统是编写可维护、可扩展 Node.js 应用的基础,尤其是在大型项目中,合理选择模块格式并遵循互操作规则至关重要。
相关文章
全局对象与变量
Node.js 提供模块内可直接使用的全局对象;与浏览器 window 不同,模块顶层的 const/let 不会挂到 global。见 后端入门概览、模块系统。
事件循环
Node.js 的事件循环是其实现非阻塞 I/O 的核心机制。它基于 libuv 库,将各种异步操作(定时器、I/O、setImmediate 等)组织成不同的阶段,每个阶段都有一个先进先出的回调队列。事件循环会按照固定的顺序反复执行…
事件驱动与非阻塞 I/O 模型
Node.js 的底层架构核心是事件驱动与非阻塞 I/O:以 V8 引擎执行 JavaScript、libuv 库处理底层 I/O 与事件循环。涵盖 libuv 线程池、事件循环各阶段、EventEmitter 实践、最佳实践与常见坑。
后端入门概览
本目录覆盖 JavaScript/TypeScript 服务端运行时与框架、数据存储,以及 Rust 系统编程入门,提供从零到部署的完整学习路径。前置建议:JavaScript 基础、工程化 · 环境变量。
文件系统
Node.js 的 fs 模块提供了与文件系统交互的 API,几乎涵盖了所有标准文件操作。它支持三种风格的 API:同步、回调式异步 和 Promise 式异步。下面详细介绍这些 API 的选择策略、流式读写、文件监视以及常用操作。
异步编程与模式
Node.js 的核心优势在于异步非阻塞 I/O,但这也带来了回调地狱、错误处理复杂等问题。下面从解救方案、工具函数、并发控制、异步迭代以及常见反模式等角度展开。