JavaScript模块系统工程化

CommonJSvsESM

JavaScript 两大模块系统的深度对决 —— 从 require() import, 彻底理解它们的运行原理、差异与选型策略。

Chapter 01

什么是模块系统?

CommonJS (CJS)

Node.js 的默认模块系统

CommonJS 诞生于 2009 年,最初是为服务端 JavaScript 设计的模块规范。Node.js 全面采用了这套规范,使其成为 Node 生态的基石。它的核心思想是:在运行时同步加载模块,每个文件就是一个独立的模块作用域。

服务端优先运行时加载

ES Modules (ESM)

ECMAScript 官方标准

ESM 是 ECMAScript 2015 (ES6) 正式引入的官方模块标准。它具有静态结构,这意味着模块的依赖关系在代码执行前就可以确定, 从而支持 Tree Shaking 等高级优化。现代浏览器和 Node.js (v12+) 均已原生支持。

浏览器原生静态分析
Chapter 02

核心差异:一图胜千言

CommonJS — math.js
// ── 导出 ── const add = (a, b) => a + b; const PI = 3.14159; module.exports = { add, PI ; // ── 导入 ── const math = require('./math'); console.log( math.add(1, 2)); // 3 // 解构导入 const { add } = require('./math'); console.log(add(1, 2)); // 3
ES Modules — math.mjs
// ── 命名导出 ── export const add = (a, b) => a + b; export const PI = 3.14159; // ── 或者默认导出 ── export default { add, PI ; // ── 导入 ── import { add, PI } from './math.mjs'; console.log(add(1, 2) ); // 3 // 默认导入 import math from './math.mjs';
加载方式
CJS运行时同步加载 (require 是函数调用)
ESM编译时静态解析 (import 是关键字)
值的绑定
CJS拷贝值 (Copy):导出后修改不影响已导入方
ESM活绑定 (Live Binding):始终引用同一内存地址
Tree Shaking
CJS❌ 无法做到,因为 require 是动态的
ESM✅ 天然支持,静态结构可被打包器分析
循环依赖
CJS返回已执行部分的「未完成导出」
ESM通过活绑定解决,但可能遇到 TDZ 错误
this 指向
CJS顶层 this === module.exports (模块对象)
ESM顶层 this === undefined
文件扩展名
CJS.js / .cjs (由 package.json type 决定)
ESM.mjs 或 package.json 设置 "type":"module"
Chapter 03

运行原理:引擎如何加载模块?

1CommonJS 加载流程(运行时)

require('./foo')
解析路径
读取文件
包装函数
执行 & 缓存
// Node.js 实际上把每个文件包装成了一个函数! (function(exports, require, module, __filename, __dirname) { // ── 你的模块代码在这里 ── const secret = 42; // 私有变量! module.exports = { secret }; // 所以 exports / require / module / __filename // 并非全局变量,而是函数参数 });
同步阻塞
require() 会阻塞后续代码直到模块加载完成
结果缓存
同一路径第二次 require() 直接返回缓存
拷贝导出
exports 是 module.exports 的引用,但赋值是拷贝

2ES Modules 加载流程(三阶段)

① 构建 (Construction)

引擎解析所有 import / export 语句,构建模块依赖图(Module Graph)。此阶段不执行任何代码。

解析 URL → 获取源码 → 解析 AST
② 实例化 (Instantiation)

为每个模块在内存中分配空间,将 export 和 import 指向同一块内存(活绑定 / Live Bindings)。

创建 Module Record → 链接 bindings
③ 求值 (Evaluation)

按照依赖拓扑顺序,从「叶节点」到「根节点」依次执行模块代码,返回最终结果。

拓扑排序 → 逐个执行 → 返回值
// counter.mjs — 演示 Live Binding export let count = 0; export function increment() { count++; } // main.mjs import { count, increment } from './counter.mjs'; console.log(count); // 0 ← 不是拷贝,是同一引用! increment(); console.log(count); // 1 ← 值自动更新了! // 注意:你不能在导入方直接 count = 99 // 因为 import 绑定是只读的 (read-only view)
交互实验场

动手试一试:循环依赖

场景:a.js 导入 b.js,b.js 又导入 a.js(循环依赖)。观察 Node.js 如何处理这种"部分完成"的导出。
1
开始执行 a.js
Node 进入 a.js 模块
2
a.js: exports.done = false
等待执行...
3
a.js: require("./b")
等待执行...
4
b.js: exports.done = false
等待执行...
5
b.js: require("./a")
等待执行...
6
b.js: console.log(b.a.done)
等待执行...
7
b.js: exports.done = true
等待执行...
8
a.js: console.log(b.done)
等待执行...
9
a.js: exports.done = true
等待执行...
1/9
历史脉络

JavaScript 模块化进化史

95
1995

无模块时代

所有 JS 代码共用全局作用域,通过 script 标签顺序加载,变量冲突频发。

09
2009

CommonJS 诞生

Ryan Dahl 创建 Node.js,采用 CommonJS 规范。服务端 JS 终于有了模块系统。

10
2010

AMD & RequireJS

浏览器端异步模块方案出现,define() / require() 在前端流行。

13
2013

UMD 统一方案

Universal Module Definition 兼容 CJS + AMD + 全局变量,成为库作者的过渡选择。

15
2015

ES6 Modules 标准化 🎉

ES2015 正式引入 import / export 关键字,JS 模块化终于有了语言级别的标准。

8+
2018+

ESM 成为主流

Node.js v12+ 支持 ESM,浏览器原生支持 <script type="module">,打包器全面适配。

Chapter 04

选型指南:到底该用哪个?

✅ 推荐

新项目 → 用 ESM

  • 支持 Tree Shaking,打包体积更小
  • 浏览器原生支持,无需 bundler 也能跑
  • 静态结构利于 IDE 自动补全和类型检查
  • 是 TC39 官方标准,未来唯一方向
  • Top-level await 支持
⚠️ 仍需了解

遗留项目 → CJS 不可避免

  • npm 上大量包仍是 CommonJS 格式
  • Node.js 内部许多 API 仍用 CJS 导出
  • 一些工具链(Jest、某些 ESLint 插件)对 ESM 支持不完善
  • CommonJS 更容易理解「执行时发生了什么」
  • 在 __dirname、__filename 有用的场景下原生更方便

快速决策:3 秒选模块系统

你要开始一个新项目吗?
YES ↓
✅ 用 ESM,毫不犹豫
NO ↓
🔧 保持现有 CJS,逐步迁移到 ESM

互操作:在 ESM 中使用 CJS 包

// ✅ ESM 可以直接 import CJS 包(Node.js 自动包装默认导出) import express from 'express'; import _ from 'lodash'; // ✅ 使用 createRequire 在 ESM 中使用 require() import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const data = require('./legacy-config.json'); // 读 JSON 方便 // ❌ 不能在 CJS 中直接 require() 一个 .mjs 文件 // 但可以用 dynamic import(): async function loadESM() { const { default: foo } = await import('./my-module.mjs'); }
速查表

CommonJS vs ESM 速查卡

场景代码备注
默认导出module.exports = value覆盖整个 exports 对象
命名导出exports.name = value别直接 exports = ...
默认导入const x = require('./x')返回整个 exports
解构导入const { a, b } = require('./x')只是语法糖
动态导入require(variable)✅ 可以使用变量
JSON 导入require('./data.json')Node 内置支持
条件导入if (cond) require('./x')✅ 完全合法

一句话总结

CommonJS 是 Node.js 生态的「遗产」,运行时同步加载、拷贝值导出; ESM 是 JavaScript 的「官方标准」,编译时静态解析、活绑定导出。新项目请无条件选择 ESM,但务必理解 CJS,因为你绕不开它。
记住这个顺序:ESM 优先,CJS 兼容,逐步迁移。