模块系统双轨操作
# 13.模块系统双轨互操作
📍 上接第 12 篇《性能优化全链路》。代码跑得快了。本文回答:代码怎么组织——require 和 import 为什么不能无缝混用?ESM 的三阶段加载是哪三步?循环依赖怎么破?
# 目录介绍
- 1. 案例与疑问引入
- 2. 架构全景概览
- 3. CJS的requ
- 4. ESM三阶段
- 5. Live绑定
- 6. 循环依赖处理
- 7. 双轨互操作陷阱
- 8. 动态加载机制
- 9. 双模发布实践
- 10. 综合案例串讲
# 1. 案例与疑问引入
# 1.1 require(
你的项目从 CJS 迁移到 ESM——改了文件后缀、改了 import 语法、改了 package.json 的 "type": "module"。CI 全绿。上线。两小时后告警:
// CJS 版本——正常运行了两年
const _ = require('lodash');
console.log(_.map); // → [Function: map]
// ESM 版本——同一个包、同一个变量名 _
import _ from 'lodash';
console.log(_.map); // → undefined!
console.log(_); // → { default: { map: [Function], filter: [Function], ... } }
console.log(_.default.map); // → [Function: map] ← 真正的 lodash 在 .default 里!
2
3
4
5
6
7
8
9
同一个包、同一个变量名 _——require 和 import 拿到的结构不同。require 拿到了 lodash 对象本身;import 拿到了 { default: lodash }。如果代码里有 _.map,ESM 版本读到的是 undefined——页面白屏。
为什么?因为 lodash 只提供了 CJS 入口(module.exports = { map, filter, ... })。Node.js 在让 CJS 的包被 ESM 的 import 消费时,必须做一次包装——把 CJS 的 module.exports(一个普通对象)包装成 ESM 的"命名导出"——于是 module.exports 变成了 { default: module.exports }。
// Node.js 内部做 CJS→ESM 包装的等价代码:
// 源文件(CJS):
// module.exports = { map, filter };
// Node 包装后(概念等价):
export default module.exports; // { map, filter } 变成了 default 导出
// 所以你 import _ from 'lodash' → _ = { default: { map, filter } }
2
3
4
5
6
7
# 1.2 顺藤摸到根因
假设 1:"那改写成
import { map } from 'lodash'不就行了?"——还是不行。因为 lodash 的 CJS 入口导出的是一个单一对象module.exports = { ... },不是多个命名导出。Node 包装后只有一个default导出——{ map }在 ESM 视角里找不到对应的命名导出。假设 2:"用打包器(Vite/Webpack)跑的时候为什么没问题?"——因为打包器对 CJS→ESM 的互操作做了自动解包。它们检测到
module.exports是一个普通对象时,自动展开为命名导出(export const map = ...)。但 Node.js 运行时没有这个"智能"——它严格按规范来。假设 3:"
exports和module.exports到底是不是一回事?"——exports是module.exports的初始引用(exports = module.exports = {})。往exports上挂属性(exports.map = map)等于往module.exports上挂。但重新赋值exports = { ... }会断链——之后require拿到的还是module.exports(初始的空对象)。假设 4:"循环依赖在 CJS 里为什么可能拿到
undefined,ESM 怎么解决的?"——因为 CJS 在require时如果遇到循环,返回的是当前已执行的module.exports的快照(可能还没执行到赋值那行)。ESM 把"分配命名空间"和"执行模块体"分成两个阶段——循环时,命名空间已经分配好了,只是绑定的值可能还没被初始化(触发 TDZ)。
这四个假设背后藏着至少 8 个原理点:
① CJS 的 require 内部怎么工作的?缓存、Function 包裹、module 对象的生命周期 → 第 3 章
② exports 和 module.exports 是什么关系?重新赋值为什么断链? → 第 3.2 节
③ ESM 的三阶段加载具体做了什么?为什么 Parse 阶段不执行代码? → 第 4 章
④ Live Binding 的底层实现:导入方和导出方共享同一个内存槽 → 第 5 章
⑤ CJS 循环依赖为什么可能拿到 undefined?返回未完成对象的策略 → 第 6.1 节
⑥ ESM 循环依赖怎么保证安全?Instantiate 阶段预分配命名空间的机制 → 第 6.2 节
⑦ .default 双层嵌套的根因:谁包了一层、什么时候会包? → 第 7.2 节
⑧ conditional exports 怎么让一个包同时支持 CJS 和 ESM 消费者而不需要消费者改代码? → 第 9 章
2
3
4
5
6
7
8
# 1.3 我们要回答什么
这个 lodash 的 _.default 案例就是本篇的主线案例。我们带着上面 8 个问号往下走:
- ① CJS 内核:手写
require的完整实现——读文件→包裹→注入变量→执行→缓存 - ② ESM 三阶段:Parse(静态分析)→ Instantiate(分配命名空间+Live Binding)→ Evaluate(后序执行)
- ③ Live Binding vs 值拷贝:为什么 ESM 的导出是"活的引用"——Tree Shaking 的前提
- ④ 循环依赖:CJS 返回未完成对象 vs ESM 预分配命名空间——两种策略各自的安全边界
- ⑤ 互操作与双模发布:conditional exports 的自动路由——消费者不需要知道包是 CJS 还是 ESM
本篇路线:
五世演进 (第 2 章) ─→ 为什么会有双轨——每世在解决什么问题
↓
CJS require (第 3 章) ─→ 手写实现 + exports 陷阱 + 缓存
↓
ESM 三阶段 (第 4 章) ─→ Parse → Instantiate → Evaluate
↓
Live Binding (第 5 章) ─→ 活的引用 vs 值拷贝——Tree Shaking 的前提
↓
循环依赖 (第 6 章) ─→ 两种策略对比 + 规避方案
↓
互操作陷阱 (第 7 章) ─→ .default 地狱 / 双包 hazard / require ESM 限制
↓
动态 import + 双模发布 (第 8-9 章) ─→ 代码分割 + conditional exports
↓
综合案例 (第 10 章) ─→ 双模 npm 包发布实战
2
3
4
5
6
7
8
9
10
11
12
13
14
15
📌 本篇定位:JavaScript 专栏中「代码怎么组织」的核心篇。第 14 篇的 Vite/Rollup 打包、第 15 篇的设计模式、第 16 篇的跨端——都建立在你理解了"模块是怎么被加载和链接的"这个基础之上。
# 2. 架构全景概览
# 2.1 模块化五世演进
1995 ─ 全局污染
│ var $ = window.jQuery; // 所有变量挂在 window 上
│ 问题:命名冲突、依赖顺序不可控
▼
2002 ─ IIFE(立即执行函数)
│ (function() { var privateVar = 1; window.myLib = { ... }; })();
│ 解决:作用域隔离。问题:加载顺序仍需手动 <script> 排好
▼
2009 ─ CommonJS(Node.js)
│ const fs = require('fs'); module.exports = { ... };
│ 解决:显式依赖声明。问题:同步 require 会卡 UI——浏览器不能用
▼
2010 ─ AMD(RequireJS)
│ define(['jquery'], function($) { return { ... }; });
│ 解决:浏览器异步加载。问题:写法啰嗦、和 Node 不兼容
▼
2014 ─ UMD(兼容胶水)
│ (function(root, factory) { if (typeof define === 'function') { define(factory); }
│ else if (typeof module === 'object') { module.exports = factory(); }
│ else { root.myLib = factory(); } })(this, function() { ... });
│ 解决:一份代码同时兼容 CJS + AMD + 全局。问题:只是过渡方案,不能解决本质问题
▼
2015 ─ ES Modules(语言标准)
│ import { map } from 'lodash-es';
│ export const add = (a, b) => a + b;
│ 解决:语言级标准、静态分析、Tree Shaking。问题:和 CJS 双轨并存→互操作地狱
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 2.2 每一步的「为什么
| 时代 | 当时的痛点 | 解决方案 | 方案带来的新问题 |
|---|---|---|---|
| 全局 | 变量冲突、十万行 <script> 顺序依赖 | IIFE 隔离作用域 | 加载顺序仍需手动管理 |
| CJS | 模块需要显式声明依赖、Node 需要模块系统 | require + module.exports | 同步加载在浏览器会卡 UI |
| AMD | 浏览器需要异步加载模块 | define + 回调 | 写法啰嗦、工具链另起炉灶 |
| UMD | CJS 和 AMD 不兼容 | 一份代码自动适配两种环境 | 多了胶水代码、无法 Tree Shaking |
| ESM | 需要语言级统一标准 + 静态分析 | import/export 进入 ES6 标准 | 和现存 CJS 生态双轨并存 |
双轨并存的本质矛盾:CJS 是"运行时"的——require 和 module.exports 在代码执行到那一行时才发生。ESM 是"编译期"的——import 和 export 在代码执行之前就被静态分析确定了。当 CJS 的包被 ESM 消费时——运行时信息需要被提升为编译期的静态声明——这就是所有互操作陷阱的根源。
# 3. CJS的requ
# 3.1 手写 30 行的
疑惑:const fs = require('fs') 这一行,Node 内部到底做了什么?
论证:Node 的 require 本质上做了五件事——解析路径→查缓存→读文件→包裹为函数→执行:
const path = require('path');
const fs = require('fs');
// Node.js 中 Module._load 的极简实现(30 行)
function myRequire(modulePath) {
// 1) 解析绝对路径——相对路径 → 绝对路径
const absPath = path.resolve(__dirname, modulePath);
// 2) 自动补全文件扩展名(.js → .json → .node → 目录/index.js)
// (实际 Node 的查找规则更复杂,这里简化)
const resolved = tryExtensions(absPath);
// 3) 检查缓存——同一路径只加载一次
if (myRequire.cache[resolved]) {
return myRequire.cache[resolved].exports;
}
// 4) 创建 module 对象
const mod = {
id: resolved,
exports: {}, // 导出对象
loaded: false,
filename: resolved,
parent: null // 调用方的 module
};
myRequire.cache[resolved] = mod; // 先缓存、再执行(处理循环依赖的关键!)
// 5) 读取源码
const code = fs.readFileSync(resolved, 'utf8');
// 6) 包裹为函数——注入 5 个变量
// 源码:console.log('hello')
// 包裹后:function(exports, require, module, __filename, __dirname) {
// console.log('hello')
// }
const wrapper = Function(
'exports', 'require', 'module', '__filename', '__dirname',
code + '\n return module.exports;'
);
// 7) 执行——this = module.exports
wrapper.call(mod.exports, mod.exports, myRequire, mod, resolved, path.dirname(resolved));
mod.loaded = true;
return mod.exports;
}
myRequire.cache = {};
function tryExtensions(absPath) {
// 依次尝试 .js / .json / .node / 目录下的 index.js
const extensions = ['.js', '.json', '.node'];
for (const ext of extensions) {
if (fs.existsSync(absPath + ext)) return absPath + ext;
}
if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) {
return path.join(absPath, 'index.js');
}
throw new Error(`Cannot find module '${absPath}'`);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
为什么先缓存、再执行:这是处理循环依赖的关键——require('./a.js') 触发 a.js 执行 → a.js 里 require('./b.js') → b.js 里 require('./a.js')。此时缓存里已经有 a 的 module 对象(虽然还没执行完)——直接返回缓存中 a 的 exports(可能是不完整的),这样避免了无限递归的同时程序还能继续跑。
# 3.2 module.e
疑惑:exports.a = 1 能正常工作,exports = { a: 1 } 却不行——为什么?
论证:因为 exports 只是 module.exports 的一个引用——不是独立变量:
// Node 在包裹你的代码时,等价于:
// function(exports, require, module, __filename, __dirname) {
// // 你的代码
// }
// 初始时:
// module.exports = {} ← Node 创建的
// exports = module.exports ← 同一个对象
// ✅ 往 exports 上加属性 → module.exports 也会变(因为是同一个引用)
exports.map = function() { ... }; // module.exports.map 也有了
exports.filter = function() { ... }; // module.exports.filter 也有了
// ❌ 重新赋值 exports → 断链!exports 指向新对象,module.exports 不变
exports = { map, filter };
// exports 现在指向新对象 { map, filter }
// 但 module.exports 仍然是初始的空对象 {}
// → require('./this') 返回 {} ← 拿到了修改前的 module.exports
// ✅ 正确做法:直接改 module.exports(或者给 exports 加属性,不重新赋值)
module.exports = { map, filter };
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
可视化:
初始状态:
module.exports ──► {} ◄── exports(同一引用)
exports.map = fn1:
module.exports ──► { map: fn1 } ◄── exports(仍然同一引用)
exports = { map, filter }: ← 断链!
module.exports ──► { map: fn1 } (不变!)
exports ──► { map, filter } (新对象)
2
3
4
5
6
7
8
9
# 3.3 require
缓存键 = 绝对路径。同一绝对路径的模块在整个进程生命周期内只执行一次:
// a.js 和 b.js 都 require('./utils')
// → 两者拿到的是同一个 utils 对象(单个实例)
const utils1 = require('./utils'); // 第一次:执行 utils.js + 缓存
const utils2 = require('./utils'); // 从缓存取,不重新执行
console.log(utils1 === utils2); // → true
// 强制刷新缓存(仅用于测试的热重载场景)
delete require.cache[require.resolve('./utils')];
const utils3 = require('./utils'); // 重新执行 utils.js——新实例
console.log(utils1 === utils3); // → false
2
3
4
5
6
7
8
9
10
缓存什么时候失效:进程重启。在同一个进程中,缓存永远不会自动失效——这就是为什么 .mjs 和 .cjs 会双包 hazard(第 7.3 节):两个文件系统路径 = 两个缓存键 = 两份独立实例。
# 4. ESM三阶段
# 4.1 Parse(解析)
引擎读取 .mjs 文件 → 解析为 AST → 不做任何执行,只找出所有 import/export 语句的静态位置。这是 Tree Shaking 的基础——"导出列表"在这一步就被确定了。
// 文件:math.mjs
export const add = (a, b) => a + b;
export function multiply(a, b) { return a * b; }
const PI = 3.14; // 没有 export → 这个模块的私有变量
2
3
4
Parse 阶段的产物:一个"模块记录(Module Record)",包含:
- 该模块
export了哪些绑定:['add', 'multiply'] - 该模块
import了哪些绑定(来自哪些模块) - 该模块需要哪些模块:"这段代码在执行前必须先加载
./foo.js和./bar.js"
# 4.2 Instanti
这一阶段仍然不执行任何模块体的代码。
为每个模块分配一个 Module Environment Record(模块环境记录),为每个 export 绑定分配命名的内存槽。Live Binding 在这一步建立——导入方和导出方指向同一个内存槽:
Exporting Module (math.mjs): Importing Module (main.mjs):
┌──────────────────────┐ ┌──────────────────────┐
│ Module Record: │ │ Module Record: │
│ exports: │ │ imports: │
│ add ─────────────┼───────────►│ add → [same slot] │
│ multiply ─────────┼───────────►│ multiply → [slot] │
│ │ │ │
│ (内存槽已分配, │ │ (导入方的 'add' 绑定 │
│ 但值尚未填入—— │ │ 是导出方 'add' 的 │
│ 等 Evaluate 阶段) │ │ 直接引用) │
└──────────────────────┘ └──────────────────────┘
2
3
4
5
6
7
8
9
10
11
关键设计:实例化阶段把"分配命名空间(谁 export 了什么、谁 import 了谁的什么)"和"执行模块体(计算出这些导出的值)"彻底解耦。这让循环依赖在 ESM 里成为可能——即使 A 和 B 互相 import,在 Instantiate 阶段它们的命名空间已经是完整的。
# 4.3 Evaluate
按依赖后序(叶子模块先跑)执行模块体。如果遇到顶层 await → 暂停当前模块 → 阻塞所有 import 了它的模块:
依赖图:App → Router → Auth → fetchConfig()
Stage 1 (Parse): 扫描全部 import/export,不执行任何代码
Stage 2 (Instantiate): 分配全部命名空间,建立 Live Binding
Stage 3 (Evaluate): 后序执行:
Step 1: fetchConfig() → await fetch('/api/config') → 等待网络...
↓
Step 2: Auth 被阻塞(因为 import fetchConfig) 等待...
Step 3: Router 被阻塞(因为 import Auth) 等待...
Step 4: App 被阻塞(因为 import Router) 等待...
↓
Step 5: fetchConfig resolve → 继续 Auth → 继续 Router → 继续 App
2
3
4
5
6
7
8
9
10
11
12
13
顶层 await 的设计原理:ECMAScript 规范规定——如果一个模块有顶层 await,它的 Evaluate 不能完全同步完成。任何 import 了这个异步模块的模块也必须等待。这就是"顶层 await 会阻塞整个依赖树"的原因——不是 bug,是规范的设计选择。
# 5. Live绑定
# 5.1 ESM 的导出是
// counter.mjs
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 ← 导入方看到了导出方的变化!
// import 的绑定就像 C++ 的引用——指向同一个内存位置
// 导出方甚至可以修改自己导出的绑定(let/var,const 不行)
// main.mjs 也看到变化——因为 Live Binding 是双向指向同一块内存
2
3
4
5
6
7
8
9
10
11
12
13
14
# 5.2 CJS 的导出是
// counter.cjs
let count = 0;
module.exports = {
count, // ← 这里 count 的值(0)被拷贝进对象
increment() { count++; } // count 是闭包中的变量
};
// main.cjs
const { count, increment } = require('./counter.cjs');
console.log(count); // → 0
increment();
console.log(count); // → 0 ← 拿到的永远是第一次 require 时的快照值!
// 因为 { count } 是解构赋值 = 把 module.exports.count 的值(0)拷贝给了变量 count
// 之后 module.exports.count 的字段不会再变——因为 CJS 导出的是对象属性
2
3
4
5
6
7
8
9
10
11
12
13
14
根本区别:
ESM: import { count } ← 活的引用 → 指向导出方的内存槽
导出方 count++ → 导入方的 count 立即变
CJS: const { count } = require() ← 值拷贝 → 拿到 module.exports.count 的当前值
导出方 count++ → module.exports.count 还是原来的值(因为 { count } 是解构 copy 的)
2
3
4
5
# 5.3 为什么 ESM
疑惑:"为什么 ESM 要设计成 Live Binding?直接像 CJS 一样导出值拷贝不更简单吗?"
论证:因为 Live Binding 让每个导出是一个命名的、可被静态追踪的引用——而不是运行时动态对象的属性:
// Tree Shaking 的分析过程(打包器视角):
import { map, filter, reduce } from 'lodash-es';
// 打包器分析:这个文件用了 map、filter、reduce 三个导出
// 其余 200 个导出(debounce、throttle、flatten...)——未被引用
// → 安全删除
// 如果 lodash-es 是 CJS:
// module.exports = { map, filter, ... 200 个函数 }
// 打包器看到:这个文件 require('lodash')——拿了整个 module.exports 对象
// 无法静态分析"这个对象里的哪些属性被用了"→ 全部保留 → 712KB
2
3
4
5
6
7
8
9
10
结论:Live Binding 不是为了"花哨"——它把每个导出变成了独立的可追踪实体。打包器能精确知道"哪个绑定被使用过、哪个没有"——这就是 Tree Shaking 能工作而 CJS 不行的原因。
# 6. 循环依赖处理
# 6.1 CJS 返回未完
疑惑:"Node 遇到循环 require 不应该是无限递归吗?为什么程序能跑?"
论证:因为 require 在第 3.1 节的第 4 步——先缓存、再执行。当循环发生时,返回的是"当前已执行的 exports 对象":
// a.js
console.log('a starting');
exports.done = false;
const b = require('./b.js'); // ← 进到 b.js
console.log('a: b.done =', b.done); // → true(b 已经执行完了 export)
exports.done = true;
console.log('a done');
// b.js
console.log('b starting');
exports.done = false;
const a = require('./a.js'); // ← 缓存命中!拿到 a 的"未完成版" exports
console.log('b: a.done =', a.done); // → false ← 因为 a 还没执行到 exports.done = true!
exports.done = true;
console.log('b done');
// 执行输出:
// a starting
// b starting
// b: a.done = false ← ⚠ a 还没跑完!
// b done
// a: b.done = true ← b 已跑完
// a done
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
CJS 循环依赖的风险:
| 风险 | 原因 | 结果 |
|---|---|---|
拿到 undefined | require 时模块还没执行到赋值那行 | require('./a').someFn → undefined |
| 拿到"一半"的对象 | 只执行了前面的 exports.xxx = ... | 有些属性有值、有些没有 |
| 函数引用丢失 | 函数定义在 exports.done = true 之后 | 调不到函数 |
# 6.2 ESM 保证引用
ESM 的 Instantiate 阶段(第 4.2 节)在执行任何模块体代码之前就为所有模块分配好了命名空间和 Live Binding 的内存槽。循环时——引用已经存在,只是绑定的值可能还没被初始化:
// a.mjs
import { bDone } from './b.mjs';
export let aDone = false;
console.log('a: bDone =', bDone); // → true
aDone = true;
// b.mjs
import { aDone } from './a.mjs';
export let bDone = false;
console.log('b: aDone =', aDone); // → TDZ 错误?还是 false?
// 实际:b: aDone = undefined 或者 TDZ——取决于引擎实现
// 因为 aDone 是 let 声明,在 InitializeBinding 之前处于 TDZ
2
3
4
5
6
7
8
9
10
11
12
CJS vs ESM 循环依赖对比:
| 维度 | CJS | ESM |
|---|---|---|
| 检测时机 | 运行时(require 时) | 编译期(Parse 阶段就发现循环) |
| 返回 | 未完成的对象 | 已分配的命名空间(Live Binding 存在但值可能 TDZ) |
| 函数可用性 | 可能 undefined(如果函数定义在 require 之后) | 可用(函数在 Instantiate 阶段已绑定) |
| 变量值 | 可能拿到旧值 | Live Binding——初始化后立即同步 |
# 6.3 循环依赖的常见场
常见场景:父子模块互相引用——User model 需要 Order model 来做关联查询,Order model 需要 User model 来做反向查询。
三种规避方案(按推荐度排序):
// 方案 A(最佳):提取共享接口到独立模块
// types.js
export class User {}
export class Order {}
// user.js
import { Order } from './types.js'; // ← 不依赖 order.js,依赖共享接口
export function getUserOrders(userId) { ... }
// order.js
import { User } from './types.js'; // ← 不依赖 user.js,依赖共享接口
export function getOrderUser(orderId) { ... }
// 方案 B:在函数内部延迟 require/import
// user.js
export function getUserOrders(userId) {
const { getOrderUser } = require('./order.js'); // 延迟到调用时才加载
// ...
}
// 方案 C(最差):重构架构——打破循环依赖
// 但成本高、需要重新设计数据关系
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
核心原则:让 A 和 B 都依赖共享模块 C,而 A 和 B 不互相依赖。把"类型定义"和"业务逻辑"分开——类型文件天然不依赖业务逻辑文件。
# 7. 双轨互操作陷阱
# 7.1 dirname
ESM 中没有 __dirname 和 __filename(CJS 的全局变量)。替代方式:
// CJS
console.log(__dirname); // → /Users/xxx/project/src
console.log(__filename); // → /Users/xxx/project/src/index.js
// ESM
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__dirname); // → /Users/xxx/project/src ✅
console.log(__filename); // → /Users/xxx/project/src/index.mjs ✅
2
3
4
5
6
7
8
9
10
11
12
13
# 7.2 default
疑惑:"有时候 import _ from 'lodash' 拿到的是 lodash 本身(_.map 能用),有时候拿到的是 { default: lodash }(_.map 是 undefined)——什么条件下多包了一层?"
论证:取决于"谁在处理这个 import":
// 条件 1:Node.js 运行时、lodash 是 CJS(module.exports = { map, filter })
import _ from 'lodash';
// Node 内部:module.exports = { map, filter } → export default module.exports
// _ = { map, filter } ← ✅ 直接拿到 lodash 对象
// (Node 的 CJS→ESM 包装:default 导出 = module.exports 本身)
// 条件 2:打包器(Babel/TypeScript)中转 CJS→ESM
import _ from 'lodash';
// Babel/TS 的互操作逻辑:
// module.exports = { map, filter, __esModule: true }
// 如果有 __esModule → 认为这是"已经是 ESM 的 CJS" → 直接在 default 上包一层
// _ = { default: { map, filter } } ← ❌ .default 地狱!
// 修复(Babel/TS 侧):
// tsconfig.json: { "esModuleInterop": true }
// 或源码中:import * as _ from 'lodash'; _.default.map(...)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.default 地狱的根因:CJS 的 module.exports 是一个单一导出——没有"命名导出"和"默认导出"的区分。ESM 有两种导出。当不同工具(Node 运行时 vs Babel vs TypeScript vs Webpack)对"CJS 的唯一导出应该映射到 ESM 的哪个槽"有不同理解时——就产生了不一致。
# 7.3 双包 hazard
疑惑:"同一个 npm 包同时提供了 .mjs 和 .cjs 版本——在不同的消费者那里会被加载两次吗?"
论证:会的。因为 require 的缓存键 = 绝对路径。.mjs 和 .cjs 是两个不同的文件 → 两个缓存键 → 两次初始化、两份独立的状态:
// 场景:my-lib 同时有 dist/index.cjs 和 dist/index.mjs
// consumer-a.js (CJS):
const { init, getState } = require('my-lib');
init('CJS state');
// consumer-b.mjs (ESM):
import { getState } from 'my-lib';
console.log(getState()); // → undefined!不是 'CJS state'!
// 因为 ESM 加载了 .mjs 版本 → 另一个 instance → 另一个内存空间中的 getState()
2
3
4
5
6
7
8
9
解决方案:用 package.json 的 exports 字段明确指定唯一入口——确保无论 CJS 还是 ESM 消费者,加载的是同一条导出链(只是入口文件语法不同,但底层状态共享)。
# 7.4 require
| 操作 | 是否允许 | 原因 | 绕过方式 |
|---|---|---|---|
require ESM | ❌ 禁止 | ESM 是异步加载(三阶段需要网络/IO),require 是同步 | 无(设计意图如此) |
import CJS | ✅ 允许 | Node 把 CJS 的 module.exports 包装为 { default: ... } | — |
import() CJS | ✅ 允许 | 动态 import 返回 Promise——天然异步 | — |
为什么 Node 禁了 require(ESM):ESM 的三阶段加载——尤其是顶层 await——可能导致模块体在 require 返回后很久才算出来。require 的调用者期望"函数返回 = 模块就绪"——这个同步假设在 ESM 中不成立。
# 8. 动态加载机制
# 8.1 代码分割的基石
import() 返回 Promise——动态加载模块。这是 Vite/Webpack 实现路由懒加载的底层基础:
// 静态 import——模块在应用启动时就被加载
import HeavyChart from './HeavyChart.vue'; // 首屏 bundle 增大 500KB
// 动态 import——模块只在访问时加载
const HeavyChart = () => import('./HeavyChart.vue');
// Vite 看到 import() → 自动拆出 HeavyChart.vue 为独立 chunk
// 用户访问 /dashboard 时浏览器才请求这个 chunk
2
3
4
5
6
7
# 8.2 顶层 await
// config.mjs
const config = await fetch('/api/config').then(r => r.json());
export default config;
// app.mjs
import config from './config.mjs';
// app.mjs 的执行被暂停——直到 config.mjs 的顶层 await 完成!
// 即使 app.mjs 自身没有 await,它的 Evaluate 也必须等 config.mjs 的 Evaluate 完成
// 影响范围:config.mjs → app.mjs → main.mjs
// → 整个依赖链被阻塞到网络请求完成
2
3
4
5
6
7
8
9
10
11
# 8.3 动态 impor
// 缓存:和 require 一样——同样的 URL 第二次 import() 复用缓存
const m1 = await import('./utils.mjs');
const m2 = await import('./utils.mjs');
console.log(m1 === m2); // → true(同一个模块命名空间)
// 强制重载:加查询参数改变 URL
const m3 = await import(`./utils.mjs?t=${Date.now()}`);
// 不同 URL = 不同缓存键 = 重新加载
2
3
4
5
6
7
8
# 9. 双模发布实践
# 9.1 conditio
{
"name": "my-lib",
"exports": {
".": {
"import": "./dist/index.mjs", // ESM 消费者走这里
"require": "./dist/index.cjs", // CJS 消费者走这里
"default": "./dist/index.mjs" // 兜底(其他环境)
},
"./utils": {
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
Node 根据调用方是 import 还是 require 自动选择对应入口——消费者不需要改代码:
// ESM 消费者 → Node 自动给 ./dist/index.mjs
import { add } from 'my-lib';
// CJS 消费者 → Node 自动给 ./dist/index.cjs
const { add } = require('my-lib');
2
3
4
5
# 9.2 同时支持 CJS
| 方案 | 做法 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 双源码 | .mjs + .cjs 各写一份 | 隔离彻底 | 维护两份代码 | 重型库(lodash 级别) |
| 源码 ESM + 构建 CJS | Rollup/esbuild 编译 CJS | 只维护一份 ESM | 需要构建步骤 | 最推荐——大多数 npm 包 |
| 纯 ESM | "type": "module" | 最简洁 | 旧消费者可能不兼容 | 新包、内部库 |
| CJS wrapper for ESM | CJS 入口用动态 import() | 兼容性最好 | CJS 消费者要处理 Promise | 过渡期 |
# 9.3 迁移旧 CJS
- 内部语法统一:代码内部全部用
import/export(TypeScript 或 Babel 转译) - 声明模块类型:加
"type": "module"或文件名改为.mjs - 配置 conditional exports:在
package.json的exports字段中同时提供import和require入口 - 渐进淘汰 CJS:保留 CJS 入口至少一个大版本作为兼容过渡,然后在下个主版本移除
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的 lodash 案例,五个疑问逐条作答:
| 疑问 | 答案 |
|---|---|
| ① CJS require 内核 | 第 3.1:解析路径→查缓存→读文件→Function 包裹→注入 exports/require/module→执行→缓存。先缓存再执行是处理循环依赖的关键 |
| ② ESM 三阶段 | 第 4 章:Parse(静态分析不执行)→ Instantiate(分配命名空间+Live Binding)→ Evaluate(后序执行)。顶层 await 阻塞整个依赖树 |
| ③ Live Binding vs 值拷贝 | 第 5 章:ESM 导出是活的引用(导入方和导出方共享同一内存槽);CJS 导出是 module.exports 的当前值——解构拿到的是快照。Live Binding 是 Tree Shaking 的前提 |
| ④ 循环依赖 | 第 6 章:CJS 返回未完成对象(可能拿到 undefined);ESM Instantiate 阶段已分配好命名空间(值可能 TDZ——但函数引用始终可用)。规避:提取共享接口 |
| ⑤ 互操作与双模发布 | 第 7+9 章:.default 地狱根因是 CJS 单一导出 ↔ ESM 双重导出映射不一致。conditional exports 让 Node 自动为 import/require 路由到对应入口 |
# 10.2 发布一个同时支持
// package.json
{
"name": "my-utils",
"version": "1.0.0",
"type": "module", // 源码是 ESM
"main": "./dist/index.cjs", // 向后兼容:CJS 消费者不检查 exports 字段时用这个
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "esbuild src/index.js --format=esm --outfile=dist/index.mjs && esbuild src/index.js --format=cjs --outfile=dist/index.cjs"
},
"files": ["dist"]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/index.js —— 源码(ESM)
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
// 消费者 1 (ESM):
import { add } from 'my-utils'; // Node 自动路由到 ./dist/index.mjs
// 消费者 2 (CJS):
const { add } = require('my-utils'); // Node 自动路由到 ./dist/index.cjs
// 两者都不需要关心 my-utils 内部是 CJS 还是 ESM
// → exports 字段充当了"协议转换层"
2
3
4
5
6
7
8
9
10
11
12
# 10.3 设计哲学回扣
哲学一·「模块化是组织复杂度的唯一正道——但标准的分裂留下了双轨债务」
从全局污染到 ESM——每一步的变革都在解决"上一步让代码不可维护"的问题。但 CJS→ESM 的过渡不是"换一个关键字"——它们是两套哲学:CJS 是运行时赋值(动态),ESM 是编译期声明(静态)。"动态 vs 静态"的根本差异就是所有互操作陷阱的根源——.default 地狱、双包 hazard、require(ESM) 被禁——每一条都可以追溯到"一个用运行时对象表达导出、另一个用编译期命名绑定表达导出"。
哲学二·「Live Binding 不是为了炫技——是为了让静态分析成为可能」
如果导出是一个在运行时才确定的值拷贝(CJS),打包器无法安全地删除未被使用的导出。Live Binding 把导出变成了"命名的、可被静态追踪的引用"——这使得 Tree Shaking 不仅仅是一种优化,而是一种架构层面的模块瘦身能力。一个 712KB 的 lodash 库中你只用 3KB——ESM + Live Binding 让剩下的 709KB 可以在构建时被安全删除。
哲学三·「conditional exports 是双轨并存的工程答案——不是语法是契约」
exports 字段不只是一个文件路径映射表。它是"模块作者向所有消费者提供的一份契约":如果你是 ESM——拿这个;如果你是 CJS——拿那个。双轨不会永远并存——但 transition 期间,conditional exports 是让过渡对消费者透明的唯一方法。消费者不需要知道包的内部是 ESM 还是 CJS——Node 在中间做了自动路由。
# 10.4 速查表:CJS
速查表 A:CJS vs ESM 九维对比
| 维度 | CJS | ESM |
|---|---|---|
| 加载方式 | 同步 require | 异步 import(三阶段) |
| 导出 | module.exports(运行时对象) | export(编译期声明 + Live Binding) |
| 导出值语义 | 值拷贝(require 那瞬间的值) | 活的引用(导出方变了、导入方立即可见) |
| 静态分析 | ❌(module.exports 是动态对象) | ✅(import/export 在 Parse 阶段确定) |
| Tree Shaking | ❌ | ✅ |
__dirname / __filename | ✅ | ❌(用 import.meta.url) |
| 顶层 await | ❌ | ✅ |
| 缓存键 | 绝对路径 | 模块 URL |
| 循环依赖 | 返回未完成对象(可能拿到 undefined) | Instantiate 阶段已分配命名空间(函数引用安全、值可能 TDZ) |
| Node 互调 | 不能 require(ESM) | 可以 import CJS(包装为 { default: ... }) |
速查表 B:双轨互操作核心陷阱速查
| 陷阱 | 现象 | 根因 | 修复 |
|---|---|---|---|
.default 地狱 | import x from 'cjs-pkg' → x.map 是 undefined | CJS 的单一导出被不同工具映射为不同的 ESM 导出 | esModuleInterop: true(TS)或 import * as x(兼容写法) |
| 双包 hazard | CJS 和 ESM 消费者拿到不同实例 | .cjs 和 .mjs 是两个缓存键 | exports 字段指定唯一入口 |
exports = {} 失联 | require 拿到空对象 | exports 重新赋值断链 | 只用 module.exports = {} 或 exports.xxx =(不重新赋值) |
__dirname 缺失 | ReferenceError | ESM 没有 CJS 的全局变量 | fileURLToPath(import.meta.url) |
速查表 C:循环依赖策略对比
| 维度 | CJS | ESM |
|---|---|---|
| 策略 | 返回未完成对象 | 预分配命名空间 + Live Binding |
| 函数是否可用 | ❌ 可能 undefined | ✅ 始终可用(Instantiate 阶段已绑定) |
| 变量值 | 上次执行到的那行代码的值 | Live Binding 实时同步(但可能 TDZ) |
| 最佳规避 | 提取共享接口到独立模块 | 同上 |
60 秒诊断清单:
# 1. import 拿到的结构和 require 不一样?
→ 检查包的入口:npm pack + 解压 → 看 package.json 的 main/exports
→ CJS 包被 ESM import → Node 包装为 { default: module.exports }
# 2. .default 地狱?
→ TypeScript: tsconfig.json 加 "esModuleInterop": true
→ 或源码改用 import * as _ from 'lodash'
# 3. 循环依赖?
→ 检查是否有 A import B 且 B import A
→ 提取共享接口到独立文件 → A 和 B 都依赖共享文件
# 4. exports = {} 不生效?
→ 改用 module.exports = {}(或只往 exports 上挂属性)
# 5. ESM 中需要 __dirname?
→ import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url))
# 6. 双包 hazard(两份实例)?
→ package.json 的 exports 字段统一入口
→ 不要同时发布 .mjs 和 .cjs 到不同路径
# 7. 发布双模 npm 包?
→ 源码 ESM → 构建 CJS → exports: { import: './dist/index.mjs', require: './dist/index.cjs' }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
下一步:模块写好了,怎么打包、测试、优化性能?进入 14.现代工程链三件套。