Node.js 的模块系统经历了从 CommonJS 到 ESM 的漫长演进。模块解析规则决定了 require() 与 import 到底怎么找到文件,也决定了循环依赖、tree-shaking 与双格式互操作这些生产级问题的走向。本文从解析机制出发,讲透 CJS 与 ESM,并给出一套可落地的工程配置。
1. 模块系统的演进:CommonJS 与 ESM
Node.js 诞生于 2009 年,彼时浏览器没有模块系统,Node 引入了 CommonJS:一个文件即一个模块,require() 同步加载,module.exports 导出。
2015 年 ECMAScript 规范定了 ES Module(ESM):import/export 语句是静态声明,浏览器与 Node 逐步支持。到 Node 22,ESM 已默认支持,但 CommonJS 生态仍然庞大,二者必须共存。
CommonJS 哲学:运行时决定,加载即执行(同步)
require('a') → 立即解析、立即执行、缓存
module.exports = x → 导出值(拷贝语义,缓存引用)
ESM 哲学:编译期决定,加载可异步(静态)
import a from 'a' → 静态依赖图,可 tree-shaking
export const x → 导出绑定(实时绑定,可读写)
| 维度 | CommonJS | ESM |
|---|---|---|
| 出现时间 | 2009(Node 1.x) | 2015(ES2015 标准) |
| 加载方式 | 同步 require | 异步 import |
| 解析时机 | 运行时 | 编译期(静态分析) |
| 导出语义 | 值拷贝/缓存引用 | 实时绑定(live binding) |
| 循环依赖 | 可取到部分导出 | 支持,行为更可控 |
| Tree-shaking | 基本不支持 | 原生支持 |
| 浏览器 | 需打包器 | 原生支持 |
一句话:CJS 是"运行时加载"的务实方案,ESM 是"编译期分析"的规范方案。现代新项目直接写 ESM,老项目用 CJS 也不必急着全量迁移。
2. CommonJS 解析机制与模块缓存
2.1 解析顺序
require('x') 的查找规则,按优先级依次为:
1. 内置模块(node:fs、node:path 等)
2. 相对路径:./x 或 ../x(补扩展名:.js → .json → .node)
3. node_modules 逐级向上查找(当前目录 → 上级 → ... → 根)
4. 找不到 → MODULE_NOT_FOUND
// 模块加载器伪代码(理解缓存机制)
const Module = require('module');
Module._cache = {}; // 全局模块缓存
function loadModule(id) {
if (Module._cache[id]) return Module._cache[id].exports; // 命中缓存
const m = new Module(id);
Module._cache[id] = m; // 先入缓存,防循环依赖死循环
m.load(); // 同步加载并执行
return m.exports;
}
2.2 缓存与"新鲜度"陷阱
同一模块只会执行一次,之后 require 都返回缓存的 exports。这意味着:
- 模块内部的状态(计数器、单例连接)全局唯一;
- 想要"每次都要新实例",必须导出工厂函数而非对象。
// db.js —— 单例连接(缓存生效)
const conn = new Connection();
module.exports = conn;
// factory.js —— 每次 require 都新建(但必须手动调用)
module.exports = function createConn() { return new Connection(); };
2.3 __dirname 与 require.resolve
CJS 里 __dirname/__filename 直接可用;require.resolve('x') 只解析路径不加载,常用于判断依赖是否存在或定位文件。
一句话:CJS 的三大关键词是同步、缓存、逐级向上查找;理解缓存,才能解释"为什么改代码要重启进程"与"单例为什么只有一个"。
3. ESM:静态分析与 Tree-shaking
3.1 静态性带来的好处
import 语句在编译期就能确定依赖关系,打包器(Rollup/esbuild/webpack)据此做 tree-shaking:把没被用到的导出从产物里删掉,显著减小体积。
// utils.js —— 导出 20 个函数
export function used() { ... }
export function unused() { ... }
// app.js —— 只 import 了一个
import { used } from './utils.js';
// 打包后 unused 从产物中被移除
3.2 Live Binding(实时绑定)
ESM 的导出是引用绑定,不是值拷贝。模块内部修改导出变量,外部 import 到的值会同步变化(readonly 视图):
// counter.js
export let count = 0;
export function inc() { count++; }
// app.js
import { count, inc } from './counter.js';
console.log(count); // 0
inc();
console.log(count); // 1 —— ESM 下是 1;CJS 下通常还是 0
3.3 严格模式与顶层 await
ESM 默认启用严格模式;且支持顶层 await(top-level await),允许模块加载时直接 await 初始化资源:
// config.js —— 顶层 await 初始化
export const config = await loadRemoteConfig();
一句话:ESM 的静态结构带来 tree-shaking 与更稳的循环依赖语义,顶层 await 让异步初始化代码更直白;代价是所有 import 必须在顶层、路径需显式(一般不可省略扩展名)。
4. 双格式互操作与 package.json exports
4.1 一个包,双格式输出
Node 通过 package.json 的 type 与 exports 字段决定模块格式:
// package.json
{
"type": "module", // "module"=当前包文件按 ESM 解析;"commonjs"(缺省)按 CJS
"exports": {
".": {
"import": "./dist/index.mjs", // ESM 环境拿到的入口
"require": "./dist/index.cjs" // CJS 环境拿到的入口
},
"./sub": "./dist/sub.mjs"
}
}
// 双格式写法:把纯逻辑写一份,分别编译出 .mjs 与 .cjs
// 或在 package.json 里同时保留 type 字段 + exports 双入口
4.2 exports vs main
| 字段 | 作用 | 限制 |
|---|---|---|
main | CJS 时代默认入口 | 无法限制子路径,包内部文件全暴露 |
exports | 现代入口 + 子路径白名单 | 未列出的子路径一律拒绝访问 |
推荐:新包一律用 exports,既能提供双格式入口,又能"关上门"——用户只能 import 你允许暴露的路径。
// 用户错误 import 包内部文件,exports 直接报错
import x from 'pkg/dist/internal.js'; // ✗ 未在 exports 白名单 → ERR_PACKAGE_PATH_NOT_EXPORTED
4.3 跨格式引用注意
- CJS 里
require()一个 ESM 模块 → 会报错(ESM 是异步的,CJS 同步加载不了),需用动态import(); - ESM 里
import一个 CJS 模块 → 正常,取到module.exports作为默认导出。
一句话:
type+exports双字段是现代包的标准配置——type声明本包格式,exports声明对外白名单与双格式入口,两者配合彻底终结"入口混乱"。
5. 循环依赖:成因与对策
A 依赖 B、B 依赖 A 即成环。CJS 靠"先入缓存"容忍循环,但拿到的是不完整的导出:
// a.js
const b = require('./b.js');
exports.name = 'A';
exports.tell = () => `A 说 ${b.name}`; // b 在 a 加载完前只暴露了部分
// b.js
const a = require('./a.js');
exports.name = 'B';
exports.tell = () => `B 说 ${a.name}`; // a 此刻 exports.name 可能还是 undefined
对策优先级:
- 重构消除环:把共享逻辑抽到第三个模块;
- 延迟取值:函数体内再
require(而不是模块顶层); - CJS 用"提前导出":把依赖方需要的东西在
require之前先exports; - ESM 下循环引用更稳(live binding),但依然不推荐——依赖图清晰远比"能跑"重要。
// 延迟取值示例:函数体内再 require,避免顶层拿到半成品
exports.tell = () => {
const b = require('./b.js'); // 此时 b 已完整加载
return `A 说 ${b.name}`;
};
一句话:循环依赖是"代码组织问题"而非"技术问题"——90% 的循环可以通过抽出共享模块解决,剩下 10% 用延迟 require 兜底,千万别依赖"恰好能跑"。
6. 包管理器选型:npm / pnpm / yarn
| 维度 | npm | pnpm | yarn classic | yarn berry |
|---|---|---|---|---|
| 安装策略 | 扁平 node_modules | 硬链接 + 全局 store | 扁平 | 零安装(PnP) |
| 磁盘占用 | 高(重复副本) | 最低(共享 store) | 高 | 最低 |
| 幽灵依赖 | 常见 | 严格隔离 | 常见 | 无 |
| Monorepo 支持 | workspaces | 最佳 | workspaces | workspaces |
| 锁文件 | package-lock.json | pnpm-lock.yaml | yarn.lock | .yarn/install-state.gz |
pnpm 用全局 store + 硬链接,同一依赖版本只存一份,磁盘节省明显,且 strict node_modules 杜绝幽灵依赖(能 import 的必须是 package.json 声明过的)。
# pnpm 基本操作
pnpm add express # 安装并写入 dependencies
pnpm add -D typescript # devDependencies
pnpm dlx prettier --check # 临时执行,不污染依赖
pnpm -r test # 递归执行所有 workspace 包的 test
一句话:单包小项目三者差别不大;多包、磁盘紧张、要严格依赖边界 → 直接上 pnpm;锁文件必须提交进版本库,保证全队依赖一致。
7. Monorepo 与模块边界
7.1 Workspace 结构
monorepo/
packages/
core/ # 公共核心逻辑
api/ # 业务 API
cli/ # 命令行工具
pnpm-workspace.yaml
# pnpm-workspace.yaml
packages:
- "packages/*"
7.2 模块边界铁律
- 单向依赖:core ← api ← cli,禁止反向依赖;
- 最小暴露:每个包用
exports白名单只暴露公共 API; - 内部包用 workspace 协议:
"@org/api": "workspace:*",本地即点即用,发布时由工具改写为版本号; - 共享配置抽包:tsconfig.base、eslint 配置、lint-staged 统一在根目录。
// packages/api/package.json
{
"name": "@org/api",
"exports": {
".": "./dist/index.js"
},
"dependencies": {
"@org/core": "workspace:*" // 本地链接,发布时改写
}
}
一句话:Monorepo 的价值在于跨包复用与原子提交,但前提是守住单向依赖 + exports 白名单的边界纪律,否则比多仓库还乱。
8. 踩坑清单
| 坑 | 现象 | 对策 |
|---|---|---|
| CJS require ESM | ERR_REQUIRE_ESM | 用动态 import() 或提供 .cjs 双格式 |
忘带 .js 扩展名 | ESM 下 MODULE_NOT_FOUND | ESM 路径必须写全扩展名 |
exports 漏配置 | 用户 import 子路径报错 | 把需要公开的子路径都列入 |
| 循环依赖 | 拿到 undefined 导出 | 重构抽共享模块 / 延迟 require |
| 幽灵依赖 | 未声明却能 import | pnpm strict node_modules 杜绝 |
| 锁文件不提交 | 全队依赖不一致 | 提交 package-lock / pnpm-lock |
| 包内既有 CJS 又有 ESM 混用 | 加载行为不一致 | 统一 type 字段 + 双入口 |
main 过时 | 旧字段与新 exports 冲突 | 删除 main,只用 exports |
9. 总结
| 环节 | 要点 |
|---|---|
| CJS | 同步 require + 缓存 + 逐级查找;单例靠缓存实现 |
| ESM | 静态 import + tree-shaking + live binding + 顶层 await |
| 双格式 | type 声明格式,exports 声明白名单与双入口 |
| 循环依赖 | 抽共享模块为主,延迟 require 兜底 |
| 包管理器 | 团队统一 + 锁文件入库,多包选 pnpm |
| Monorepo | 单向依赖 + 最小暴露,守住边界 |
一句话记住:模块系统是 Node 工程的"宪法"——type+exports 定边界,CJS/ESM 各司其职,循环依赖靠架构消除,包管理器锁定一致性。把这些配置一次性做对,后续维护会省掉大量"为什么 require 不到/为什么版本不一致"的排查时间。
延伸阅读
- Node.js 核心架构与运行时 — V8、libuv 与模块系统底层
- Node.js TypeScript 工程化实践 — TS 下的模块与路径别名配置
- Node.js 异步编程与并发模型 — require/import 与异步加载的配合
- Node.js 运行时选型:Bun vs Deno — 各家模块系统的对比
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。