现代 JavaScript 模块化深度解析:CommonJS vs ES Modules

本文基于 Node.js 22 LTS / 20.19+(2026 年现状) 校对。涉及模块互调的结论与老教程(2022 年前)差异较大,请注意甄别存量资料。

在 JavaScript 的演进历程中,模块化规范的制定解决了代码隔离、依赖管理和工程复用的核心痛点。作为前端架构师,深刻理解 CommonJS (CJS) 与 ES Modules (ESM) 的底层机制、边界差异及混合工程实践,是构建高性能、高可维护性现代应用的基础。


一、 核心概念与硬核对比

下表从底层运行机制到工程化落地,对两套规范进行全方位对比:

维度 CommonJS (CJS) ES Modules (ESM)
标准属性 社区标准(2009年由 ServerJS 演进而来) 语言级官方标准(ES6 / ES2015 正式提出)
核心语法 require() / module.exports import / export
实现载体 纯宿主函数实现(利用闭包与环境注入) JS 引擎底层实现(V8/SpiderMonkey 原生内核)
加载机制 运行时同步阻塞加载 静态解析(先于执行) + 异步加载流
值传递方式 共享的 exports 对象引用,但属性在导出时快照赋值(基本类型不联动,对象属性联动) 值的动态绑定(Live Bindings)(只读引用,始终联动)
动态依赖 天然支持(支持路径拼接、条件分支内加载) 静态 import 不支持,通过 import() 异步函数实现
Tree-Shaking ⚠️ 基本无效(打包器仅对极简字面量模式做有限优化,不可靠) 天然支持,打包工具可精准剔除未引用代码
循环依赖 返回当前已执行部分的 exports 快照,可能出现部分导出为 undefined 建立 Live Binding 引用图,在使用前完成初始化即可(否则触发 TDZ 报错)
严格模式 默认非严格模式 永远处于严格模式
顶层 this this === module.exports(仅模块顶层成立,函数内不成立) this === undefined
路径工具 注入 __filename / __dirname 使用 import.meta.dirname / import.meta.filename(Node 20.11+)或 import.meta.url
环境支持 仅 Node.js 等服务端环境原生支持(浏览器需打包) 现代浏览器及现代 Node.js (v14+) 原生支持

二、 底层原理深度解剖

1. CommonJS 的函数式包装与环境限制

CommonJS 并非 JavaScript 官方规范,它的实现完全不需要 JS 引擎做底层修改。在 Node.js 环境中,每个 CommonJS 模块在执行前,底层的 Module 模块会利用一个闭包函数外壳将其包裹起来:

// Node.js 底层的模块包装器伪代码
(function(exports, require, module, __filename, __dirname) {
    // 你的 CJS 模块代码实际运行在这里
    const math = require('./math');
    module.exports = { ... };
});
  • 原生的环境限制:因为 require、module 等本质上只是该外层函数的形参,且依赖 Node.js 底层特有的磁盘 I/O 能力,因此现代浏览器无法原生支持 CommonJS(直接运行会抛出 require is not defined)。前端工程中能使用 CJS,全靠 Webpack 等打包工具在编译期模拟了这套函数包装机制。

2. exports / module.exports / this 的三角关系

CJS 中最经典的"导出丢失"事故来源。包装器被调用时,exports 形参和 this 值都被绑定为 module.exports:

// 包装器的实际调用方式(伪代码)
wrapper.call(module.exports, module.exports, require, module, __filename, __dirname);
//           ↑ this 值           ↑ exports 形参
  • 初始状态下,三者指向同一个对象:
// 模块顶层(非严格模式)
console.log(this === module.exports);  // true
console.log(exports === module.exports); // true
  • 但"同一对象"靠共享引用维持,重新赋值即断裂——exports 和 this 只是初始对象的别名快捷方式,require() 真正返回的只有 module.exports:
// ✅ 挂载属性:三者同生共死
exports.foo = 1;
console.log(module.exports.foo); // 1

// ❌ 对 exports 重新赋值:别名被"剪断"
exports = { foo: 1 };        // exports 指向了新对象
console.log(module.exports); // {} —— 消费方拿到空对象,foo 无声丢失

// ❌ 反过来对 module.exports 重新赋值后,exports / this 仍指向旧的空对象
module.exports = { foo: 1 };
console.log(exports.foo);    // undefined
  • this === module.exports 只在模块顶层成立:模块内部的普通函数里,this 是 globalThis(非严格模式)或 undefined(严格模式);顶层的箭头函数捕获顶层 this,所以箭头函数内仍是 module.exports。
  • 工程结论:只用 module.exports.xxx = ...(或统一用 exports.xxx = ... 但绝不对其整体重赋值),不要混用。

3. CJS 的动态依赖与同步阻塞机制

因为 require() 本质上是一个普通的 JavaScript 函数调用,所以它具备完全的动态性与同步性:

  • 动态依赖:依赖关系不需要提前声明,必须等到代码实际运行到那一行时才能确定。路径可以动态拼接,也可以放入 if-else 分支。
  • 同步执行:当主线程运行到 require 时,会停下来阻塞后续代码,直到本地磁盘将该模块文件读取、解析并执行完毕,返回 module.exports 后,主线程才会继续往下走。在服务器端由于是本地磁盘读取,性能损耗可控;但在网络环境下的浏览器端,这种同步阻塞会导致页面彻底卡死。

4. 静态解析 (ESM) vs 动态执行 (CJS)

  • CJS (运行时):如上所述,在运行时动态同步读取、包裹并执行,最后将结果写入 require 缓存(同一模块多次 require 返回同一个对象引用)。
  • ESM (编译前静态阶段):JS 引擎在真正执行代码前,会先对代码进行静态分析(Module Record)。它会先找出所有的 import,下载并解析模块,构建出一棵模块依赖图(Module Graph),然后再按照深度优先遍历的顺序执行代码。

5. 导出快照 (CJS) vs 引用绑定 (ESM)

这是工程中最容易引发 Bug 的地方。注意:CJS 并非"值的拷贝"——require 返回的是带缓存的、全局共享的 module.exports 对象引用。真正准确的机制是:

  • CJS:模块执行时,把"当时的值"赋值给 exports 对象的属性。基本类型 = 快照(之后不联动);对象/函数 = 属性引用共享(修改可见)。
  • ESM:导出的是指向模块内部变量的只读 Live Binding,任何时刻读取的都是模块内的最新值。

💡 CommonJS 示例(基本类型快照,不联动):

// counter.js (CJS)
let count = 1;
function increment() { count++; }
module.exports = { count, increment };  // 基本类型 count 在此刻被"快照"进 exports

// main.js
const counter = require('./counter');
console.log(counter.count); // 1
counter.increment();
console.log(counter.count); // 1  <-- increment 改的是模块内部变量,exports.count 永远是快照值

💡 CommonJS 反例(对象属性是共享引用,联动!):

// counter.js (CJS)
const state = { count: 1 };
module.exports = {
  state,                               // 导出的是对象的引用
  increment() { state.count++; }       // 修改的是同一个对象的属性
};

// main.js
const counter = require('./counter');
counter.increment();
console.log(counter.state.count); // 2  <-- 外部完全感知到了变化

💡 ES Modules 示例(Live Binding,永远联动):

// counter.js (ESM)
export let count = 1;
export function increment() { count++; }

// main.js
import { count, increment } from './counter.js';
console.log(count); // 1
increment();
console.log(count); // 2  <-- 动态绑定,实时映射模块内部的最新值
// count = 3;       // ❌ 报错!导入的变量是只读的(Read-only binding)

6. ESM 原理速览:语法级标准 + 双依赖模型 + 符号绑定

用一句话概括 ESM:由 JS 引擎实现的语法级官方标准,与 CJS 的"宿主函数注入"形成根本对照。可以从四个维度完整收束:

  • 官方标准 + 新语法实现:import / export 是 ES2015 引入的语言关键字,由引擎在语法分析阶段直接识别——而不是 CJS 那样靠宿主注入 require / module 形参。这是 ESM 一切"静态能力"的根源。
  • 现代环境均原生支持:现代浏览器(<script type="module">)、Node.js(v14+,.mjs 或 "type": "module")、Deno / Bun / Edge Runtime。注意边界:老浏览器(如 IE11)与老 Node 不支持,需要打包工具降级转译。
  • 同时支持静态依赖与动态依赖:
    • 静态依赖(顶层 import 声明):路径必须是字符串字面量,声明会被提升(hoisting),依赖关系在代码运行前(解析阶段)就已确定——这是构建 Module Graph 和 Tree-Shaking 的前提。
    • 动态依赖(import()):返回 Promise,异步加载,路径可动态拼接、可写在条件分支或函数体内,依赖关系到运行时才确定。⚠️ 注意:import() 只是长得像普通函数,实际是引擎实现的特殊语法形式——不能赋值给变量、不能作为回调传递、无法被普通函数 polyfill。
  • 符号绑定(Live Binding):import 得到的是对导出模块内部变量的只读符号绑定,而非值快照——导出方修改,导入方实时可见(机制与示例见第 5 节)。

三、 现代架构中的混合工程实践(Dual-Package)

在当前的过渡期(Node.js 全面转向 ESM,但 npm 生态仍有大量 CJS),架构师必须处理"双模"共存的问题。

1. 避坑指南:Node.js 中的模块互调规则(现代版)

  • ESM 引入 CJS:
    • 支持。import pkg from 'cjs'(default 引入)永远可用,拿到的是整个 module.exports。
    • 命名解构导入同样支持:Node 内置 cjs-module-lexer 静态分析器,只要 CJS 导出是静态可分析的(exports.foo = ...、module.exports = { a, b } 等字面量模式),import { add } from './math.cjs' 完全合法。
    • 唯一的限制:当导出是动态生成的(如 Object.keys(obj).forEach(k => exports[k] = ...)),词法分析失败,此时只能 default 整体引入后再手动解构。
// math.cjs
module.exports = { add, sub };

// main.mjs —— 现代 Node 中正常运行
import { add } from './math.cjs';
import pkg from './math.cjs'; // pkg === module.exports,同样可用
  • CJS 引入 ESM:
    • 现代 Node(≥ 22.12 / ≥ 20.19):require(esm) 已默认启用,可以直接同步加载 ESM,ERR_REQUIRE_ESM 已成历史。返回的是 module namespace 对象(default 导出挂在 .default 上)。
    • 硬性限制:目标模块图内不能包含 top-level await,否则抛 ERR_REQUIRE_ASYNC_MODULE;文件必须是 .mjs 或 "type": "module" 下的 .js。
    • 老版本 Node(< 20.19):只能使用动态导入 import()(异步)来加载 ESM 模块。
// 现代 Node 中,CJS 直接 require ESM(无 top-level await 时)
const esm = require('./module.mjs'); // ✅ 返回 namespace 对象
console.log(esm.default);

// 老版本 Node 的兼容写法(异步,任何版本都可用)
const esm2 = await import('./module.mjs');

2. 基础架构落地:库开发者的双包兼容(Dual Package)

如果你正在设计一个企业级的基础 UI 库或工具库,为了同时兼容新老项目,需要在 package.json 中配置 Conditional Exports:

{
  "name": "corporate-core-library",
  "version": "1.0.0",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}
  • 扩展名必须用 .mjs / .cjs:不要依赖 "type" 字段去猜测 .js 的模块格式,.mjs/.cjs 与包配置解耦,任何环境下行为都确定。
  • "types" 条件放最前:TypeScript 按条件顺序匹配,放最后存在解析不到的边角情况。
  • Vite/Webpack/现代 Node 会识别 import 字段并加载 ESM,享受 Tree-shaking。
  • 老旧 Node.js/CJS 项目 会识别 require 字段并加载打包好的 CJS 产物。

⚠️ 双包最大的坑:Dual Package Hazard

同一份库被 ESM 和 CJS 渠道各加载一次,进程内产生两份模块实例,导致:

  • instanceof 跨实例判断失效(foo instanceof Foo 返回 false);
  • 单例状态、全局缓存分裂成两份,产生难以排查的灵异 Bug。

缓解方案:

  1. Wrapper 模式:把所有状态/类定义放在 CJS 侧,.mjs 入口只做 export * from './index.cjs' 式的 re-export,保证进程内永远只有一份真实实例;
  2. 只发 ESM(2026 年越来越可行):现代 Node 已支持 require(esm),纯 ESM 库的 CJS 消费障碍基本消除,新库可以优先考虑 ESM-only。

四、 架构师的技术选型建议

  1. 新立项的应用(前端/后端):
    • 全面拥抱 ESM。前端项目配合 Vite / Webpack 5,Node.js 服务端项目在 package.json 中明确声明 "type": "module"。
  2. 存量老项目(BFF / Node.js 历史服务):
    • 如果项目庞大且依赖了大量未维护的 CJS 传统三方库,保持 CJS 架构。盲目重构为 ESM 会带来巨大的历史包袱和运行风险。
    • 好消息是:现代 Node 已支持 require(esm),老 CJS 项目消费新的纯 ESM 三方库不再是硬障碍,渐进式混用的空间比过去大得多。
  3. 团队规范演进:
    • 在 CI/CD 阶段和代码规范(ESLint)中,逐步废弃 __dirname 的使用。Node 20.11+ 直接使用 import.meta.dirname / import.meta.filename,不再需要 fileURLToPath(import.meta.url) 的仪式代码,为未来全面 ESM 化扫清语法障碍。
  4. ESM 落地细节备忘:
    • Node 中 ESM 相对导入必须写全扩展名(import './util.js'),没有 CJS 式的扩展名猜测;
    • ESM 永远严格模式,顶层 this === undefined,不要再写依赖 this 指向模块导出的老代码;
    • 导入 JSON 使用 Import Attributes:import data from './x.json' with { type: 'json' }。
posted @ 2026-10-03 23:30  HuangBingQuan  阅读(27)  评论(0)    收藏  举报