编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

    • 基础入门

    • 综合案例

    • 专栏博客

      • README
      • 引擎解析编译执行
      • 隐藏类与回收机制
      • 类型隐式转换精算
      • 作用域链闭包原理
      • 函数绑定规则组合
      • 原型链语法糖本质
      • 代理与元编程协议
      • 事件循环承诺机制
      • 工作线程并发调度
      • 页面渲染像素原理
      • 网络接口存储架构
      • 服务端运行时编程
      • 模块系统双轨操作
        • 1. 案例与疑问引入
          • 1.1 require(
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构全景概览
          • 2.1 模块化五世演进
          • 2.2 每一步的「为什么
        • 3. CJS的requ
          • 3.1 手写 30 行的
          • 3.2 module.e
          • 3.3 require
        • 4. ESM三阶段
          • 4.1 Parse(解析)
          • 4.2 Instanti
          • 4.3 Evaluate
        • 5. Live绑定
          • 5.1 ESM 的导出是
          • 5.2 CJS 的导出是
          • 5.3 为什么 ESM
        • 6. 循环依赖处理
          • 6.1 CJS 返回未完
          • 6.2 ESM 保证引用
          • 6.3 循环依赖的常见场
        • 7. 双轨互操作陷阱
          • 7.1 dirname
          • 7.2 default
          • 7.3 双包 hazard
          • 7.4 require
        • 8. 动态加载机制
          • 8.1 代码分割的基石
          • 8.2 顶层 await
          • 8.3 动态 impor
        • 9. 双模发布实践
          • 9.1 conditio
          • 9.2 同时支持 CJS
          • 9.3 迁移旧 CJS
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 发布一个同时支持
          • 10.3 设计哲学回扣
          • 10.4 速查表:CJS
      • 现代工程链三件套
      • 设计模式函数哲学
      • 跨端架构终局总结
  • CodeX
  • JavaScript入门
  • 专栏博客
杨充
2026-06-11
目录

模块系统双轨操作

# 13.模块系统双轨互操作

📍 上接第 12 篇《性能优化全链路》。代码跑得快了。本文回答:代码怎么组织——require 和 import 为什么不能无缝混用?ESM 的三阶段加载是哪三步?循环依赖怎么破?

# 目录介绍

  • 1. 案例与疑问引入
    • 1.1 require(
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构全景概览
    • 2.1 模块化五世演进
    • 2.2 每一步的「为什么
  • 3. CJS的requ
    • 3.1 手写 30 行的
    • 3.2 module.e
    • 3.3 require
  • 4. ESM三阶段
    • 4.1 Parse(解析)
    • 4.2 Instanti
    • 4.3 Evaluate
  • 5. Live绑定
    • 5.1 ESM 的导出是
    • 5.2 CJS 的导出是
    • 5.3 为什么 ESM
  • 6. 循环依赖处理
    • 6.1 CJS 返回未完
    • 6.2 ESM 保证引用
    • 6.3 循环依赖的常见场
  • 7. 双轨互操作陷阱
    • 7.1 dirname
    • 7.2 default
    • 7.3 双包 hazard
    • 7.4 require
  • 8. 动态加载机制
    • 8.1 代码分割的基石
    • 8.2 顶层 await
    • 8.3 动态 impor
  • 9. 双模发布实践
    • 9.1 conditio
    • 9.2 同时支持 CJS
    • 9.3 迁移旧 CJS
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 发布一个同时支持
    • 10.3 设计哲学回扣
    • 10.4 速查表:CJS

# 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 里!
1
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 } }
1
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 章
1
2
3
4
5
6
7
8

# 1.3 我们要回答什么

这个 lodash 的 _.default 案例就是本篇的主线案例。我们带着上面 8 个问号往下走:

  1. ① CJS 内核:手写 require 的完整实现——读文件→包裹→注入变量→执行→缓存
  2. ② ESM 三阶段:Parse(静态分析)→ Instantiate(分配命名空间+Live Binding)→ Evaluate(后序执行)
  3. ③ Live Binding vs 值拷贝:为什么 ESM 的导出是"活的引用"——Tree Shaking 的前提
  4. ④ 循环依赖:CJS 返回未完成对象 vs ESM 预分配命名空间——两种策略各自的安全边界
  5. ⑤ 互操作与双模发布: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 包发布实战
1
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 双轨并存→互操作地狱
1
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}'`);
}
1
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 };
1
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 }   (新对象)
1
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
1
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 → 这个模块的私有变量
1
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 阶段)   │            │   直接引用)           │
└──────────────────────┘            └──────────────────────┘
1
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
1
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 是双向指向同一块内存
1
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 导出的是对象属性
1
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 的)
1
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
1
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
1
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
1
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(最差):重构架构——打破循环依赖
// 但成本高、需要重新设计数据关系
1
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  ✅
1
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(...)
1
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()
1
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
1
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
// → 整个依赖链被阻塞到网络请求完成
1
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 = 不同缓存键 = 重新加载
1
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"
        }
    }
}
1
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');
1
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

  1. 内部语法统一:代码内部全部用 import/export(TypeScript 或 Babel 转译)
  2. 声明模块类型:加 "type": "module" 或文件名改为 .mjs
  3. 配置 conditional exports:在 package.json 的 exports 字段中同时提供 import 和 require 入口
  4. 渐进淘汰 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"]
}
1
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 字段充当了"协议转换层"
1
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' }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

下一步:模块写好了,怎么打包、测试、优化性能?进入 14.现代工程链三件套。

上次更新: 2026/06/16, 12:36:20
服务端运行时编程
现代工程链三件套

← 服务端运行时编程 现代工程链三件套→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式