Node.js 模块系统深度:CommonJS、ESM 与工程化实践

深入 Node.js 模块系统:CommonJS 解析机制与缓存、ESM 静态分析与 tree-shaking、双格式互操作、package.json exports 字段、循环依赖成因与对策、包管理器选型(npm/pnpm/yarn)与 Monorepo 模块边界设计。

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        → 导出绑定(实时绑定,可读写)
维度CommonJSESM
出现时间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

字段作用限制
mainCJS 时代默认入口无法限制子路径,包内部文件全暴露
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

对策优先级:

  1. 重构消除环:把共享逻辑抽到第三个模块;
  2. 延迟取值:函数体内再 require(而不是模块顶层);
  3. CJS 用"提前导出":把依赖方需要的东西在 require 之前先 exports;
  4. ESM 下循环引用更稳(live binding),但依然不推荐——依赖图清晰远比"能跑"重要。
// 延迟取值示例:函数体内再 require,避免顶层拿到半成品
exports.tell = () => {
  const b = require('./b.js'); // 此时 b 已完整加载
  return `A 说 ${b.name}`;
};

一句话:循环依赖是"代码组织问题"而非"技术问题"——90% 的循环可以通过抽出共享模块解决,剩下 10% 用延迟 require 兜底,千万别依赖"恰好能跑"。


6. 包管理器选型:npm / pnpm / yarn

维度npmpnpmyarn classicyarn berry
安装策略扁平 node_modules硬链接 + 全局 store扁平零安装(PnP)
磁盘占用高(重复副本)最低(共享 store)高最低
幽灵依赖常见严格隔离常见无
Monorepo 支持workspaces最佳workspacesworkspaces
锁文件package-lock.jsonpnpm-lock.yamlyarn.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 模块边界铁律

  1. 单向依赖:core ← api ← cli,禁止反向依赖;
  2. 最小暴露:每个包用 exports 白名单只暴露公共 API;
  3. 内部包用 workspace 协议:"@org/api": "workspace:*",本地即点即用,发布时由工具改写为版本号;
  4. 共享配置抽包: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 ESMERR_REQUIRE_ESM用动态 import() 或提供 .cjs 双格式
忘带 .js 扩展名ESM 下 MODULE_NOT_FOUNDESM 路径必须写全扩展名
exports 漏配置用户 import 子路径报错把需要公开的子路径都列入
循环依赖拿到 undefined 导出重构抽共享模块 / 延迟 require
幽灵依赖未声明却能 importpnpm 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 不到/为什么版本不一致"的排查时间。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nodejs」更多文章

  1. Node.js CLI 工具开发实战:参数、交互、打包与发布
  2. Node.js 输入校验与数据契约:Zod、类型安全与工程实践
  3. Node.js 错误处理与日志工程:从异常到可观测