KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · 导出的是接线还是快照 — keel 龙骨
这一章回答:import 进来的是一个值,还是一条通向那块内存的接线?循环依赖为什么在一边是抛错、在另一边是静默的 undefined?
这一章回答:import 进来的是一个值,还是一条通向那块内存的接线?循环依赖为什么在一边是抛错、在另一边是静默的 undefined?
现场:同样调用 inc 三次,一边是 3,一边还是 0
一个计数模块,导出 n 和一个 inc()。两个主程序逻辑一模一样:先读三次 n,调三次 inc(),再看 n。
$ node esm/main-live.mjs
读取 n 三次: 0 0 0 | get() = 0
inc()x3 之后再看 n: 3 | get() = 3
结论: 同一块内存,导入方看到的是当前值
--- exit=0 ---
$ node cjs/main-snapshot.cjs
读 m.n 三次: 0 0 0
inc()x3 之后 m.n = 0 (快照,永远不会变)
解构得到的 n = 0 (同样是快照)
--- exit=0 ---
同一台机器、同一个 Node、同一天写的两份代码。CJS 那边 inc() 确实被调了三次——不然不会只有 n 不动而程序一切正常。可 m.n 还是 0。
先别往下看。你会怎么判断? 写下答案:为什么 ESM 这边一调 inc()、n 就跟着变,CJS 这边调了三次却纹丝不动?能不能简单地说"ESM 的绑定更新得比 CJS 快"?如果 CJS 那边把 const { n } = require(...) 换成 require(...).n,会变吗?
一、接线:ESM 里"变"的三种形态
三组实验,每组只改一个变量。
① let 被导出方自增 → 跟着变。 esm/main-live.mjs 先读三次拿到 0 0 0,调三次 inc() 后再读,n 变成 3,get() 也是 3。导出方那个 ++n 是唯一动作。
② let 被导出方重新赋值 → 跟着变。 esm/main-rebind.mjs:改前 x = 1,导出方重新赋值后 x = 2 (let 导出被重新赋值,导入方也看到)。注意导出方不是改了对象的属性,而是把 x 这个变量指到了另一个值上,导入方照样读到了新的那个。
③ const 导出对象、导出方改内部字段 → 跟着变。 esm/main-object.mjs:改前 cfg.flag = false,导出方动了 cfg.flag,变成 改后 cfg.flag = true (const 绑定,但内部可变)。const 锁的是"这个绑定不能再指向别的对象",不锁对象内部。
④ 反过来就不行:给导入的绑定赋值,esm/main-assign.mjs 直接抛:
TypeError: Assignment to constant variable.
at .../esm/main-assign.mjs:3:3
at ModuleJob.run (node:internal/modules/esm/module_job:343:25)
--- exit=1 ---
一条导入绑定为什么是"constant"?因为导入绑定在设计上就是只读视图——你只能读,写它的所有权在导出方。哪怕导出方导出的是 let,你这一侧也改不动。
前三种归纳成一句:ESM 的导入绑定不是一份拷贝,是一条通往导出方作用域里那个变量的接线。 每次读 n,读的是导出方那块槽位的当前值。所以 inc()x3 之后读出 3,不是"值被同步过去了",而是读的时候就直接读到了对面;而 ④ 说的是这条接线只有单方向——能读,不能写。
flowchart TD
A["导出方作用域里的一个绑定槽"] --> B{"这个槽处于哪种形态?"}
B -- "① export let n:导出方 ++n 三次" --> C["槽位里的值变了"]
B -- "② export let x:导出方重新赋值" --> C
B -- "③ export const obj:导出方改 obj 内部字段" --> C
B -.->|④ 导入方给导入的绑定赋值 n = 99| D["TypeError: Assignment to constant variable.<br/>栈顶 ModuleJob.run —— 运行期"]
C --> E["导入方再读一次<br/>读到的就是新值"]
E --> F["接线:读 = 现读那块内存,且只读"]
style D fill:#ffebee,color:#b71c1c
style F fill:#e8f5e9,color:#1b5e20
图里在说什么。 图从"导出方作用域里的一个绑定槽"出发,问的是"这个槽处于哪种形态"。①②③ 都是导出方那一侧的动作(自增、重新赋值、改对象内部字段),三条实线汇到同一个结果 槽位里的值变了,然后导入方再读一次就看到了新值——注意是"再读",不是"收到推送",所以接线这条线的本质是读取时现读那块内存,而且只读。④ 是导入方那一侧的动作,走虚线直接落到红框:给导入的绑定赋值不是"写回去",而是直接违规。图里红色那条虚线和三条实线的分界,就是"谁拥有写权限"的分界。
二、快照:CJS 里你拿到的到底是什么
| CJS 写法 | 导入方看到 | 证据 |
|---|---|---|
module.exports = { n, inc } |
快照,永远不变(0 → 0) | cjs/main-snapshot.cjs |
const { n } = require(...) |
同样快照(解构出来的是值) | 同上 |
getter(Object.defineProperty(..., {get})) |
跟着变(0 → 2) | cjs/main-getter.cjs |
exports.x = 1 → 之后 exports.x = 2 |
跟着变(1 → 2) | cjs/main-late.cjs |
这里有一组看起来矛盾的结果:同样是 CJS,m.n 永远是 0,m.x 却从 1 变成了 2。
module.exports = { n, inc } 里的 n 是简写属性,等价于 n: n。这一句执行的那一刻,把当时 n 的值读出来,写进了新对象的一个属性。 从那以后,对象上那个属性叫 n,模块里那个变量也叫 n,两个名字之间再没有任何关系。导出方后面 ++n 改的是模块作用域里那个变量,对象上的 n 早就被拍下来了。
exports.x = 1 不一样。它没有重造对象,是在已经存在的那个 module.exports 对象上加了个属性。而 require() 返回的就是这一个对象(cjs/main-late.cjs 的注释说得直白:require 返回的就是同一个 exports 对象),所以后面 exports.x = 2 改的是同一个对象的同一个属性,谁什么时候来读都拿到 2。
getter 为什么也能"活"?因为 getter 不是一个值,是一段每次读都会重新执行的函数。读 m.n 会现场跑一遍 return n,拿到模块变量当下的值。所以 inc()x2 之后读出来是 2。
三种行为的差别不在"更新得快不快",而在"被读的那一刻发生了什么":快照是"早就写死的常量",后改是"同一块内存上的属性换了值",getter 是"每次都重算一遍"。
三、区分点:你拿到的是一块内存,还是一个拷贝
现在回答开篇那个问题:不能说"ESM 的绑定更新得比 CJS 快"。这跟速度无关。
- ESM 的
import给你一条接线,接到导出方作用域里那个绑定槽。读的时候现读,所以导出方改了你就能看到。你这一侧没有写权限,写就TypeError。 - CJS 的
require给你一个对象(module.exports那个)。对象是同一块内存,所以"往对象上加属性 / 改属性"对谁都可见(exports.x后改有效);但如果某个属性在你拿到的那一瞬间就被填进了一个具体的值({ n }简写、解构),那它只是一个存下来的数字,之后模块变量怎么改都与它无关。
一句话记住:ESM 给的是"到那块内存的接线",CJS 给的是"一个对象,对象里装的是它构造时拍下的值"。 变与不变取决于你手上握着的是引用还是拷贝——不是语法,是数据结构的形状。
四、import 提升:为什么在 import 行上面就能用
ESM 版本(hoist/body-esm.mjs,副作用 import 写在源码第 2 行):
[side-a] 被求值 (副作用)
[body] 源码第 1 行执行
[body] 源码第 3 行执行
CJS 版本(hoist/body-cjs.cjs,require 写在源码第 2 行):
[body] 源码第 1 行执行
[side-a] 被求值 (副作用)
[body] 源码第 3 行执行
同一份源码顺序,输出顺序换了。原因是这两行在语言里根本不是一类东西:
import是声明,声明是静态的——Node 在执行这个模块的 body 之前,先把整张模块图上的依赖全部求值完(按依赖图深度优先),才回来跑第一行。这就是 import 提升:效果看起来像"所有import被挪到了文件最上面",更准确的说法是"依赖先于本模块求值"。require是表达式,是一个函数调用。函数调用按源码顺序发生,跑到那一行才去加载、才去执行对方的 body。
还有个更直观的结果,hoist/use-before-import.mjs:
[dep] 求值,导出 v
在这一行就用了 v: 来自 dep 的 v
import 行写在下面,但 v 已经可用
在 import 那一行的上面引用导入进来的 const 绑定,直接用,不报错——因为依赖早就求值完了,绑定早就接上了。换成 CJS,这一行会是 ReferenceError 或读到 undefined。所以在 ESM 里 import 写在哪不影响时序,在 CJS 里影响。
五、ESM 循环依赖:TDZ 错,和那行同时证明两条的实验
两个文件互相 import:tdz-a.mjs 导出 const a,tdz-b.mjs 在顶层读 a。
$ node cycle/run-tdz.mjs
file:///.../cycle/tdz-b.mjs:3
console.log(" [tdz-b] 想读 a:", a);
^
ReferenceError: Cannot access 'a' before initialization
at .../cycle/tdz-b.mjs:3:32
at ModuleJob.run (node:internal/modules/esm/module_job:343:25)
--- exit=1 ---
注意文案:不是 a is not defined,是 Cannot access 'a' before initialization。区别是全部要点所在——a 存在,它是一个已经声明、被接线接好的绑定;它只是还没被初始化。ESM 的做法是先把整张模块图的 import/export 关系接好(这一步能看到所有绑定的存在),再从入口逐个求值。接线时 a 就在那里,但它的值要等 tdz-a.mjs 的 body 跑到那一行才产生。在那之前访问它,就是 TDZ(暂时性死区)。
函数声明是例外。 cycle/run-fn.mjs 打出 [fn-b] typeof a = function、[fn-a] typeof b = function (函数声明提前初始化)、entry 调 a() = A、exit=0。把 const a = ... 换成 function a() {} 就不抛了——函数声明的值(那个函数对象)在模块实例化阶段就存在,不等 body 执行;const / let 没有这个待遇。
还有一条更狠的:typeof 也救不了你。 cycle2/run-typeof.mjs 里 typeof c 打在 cycle2/tdz-d.mjs 的顶层,同样抛 ReferenceError: Cannot access 'c' before initialization。typeof 平时对未声明的名字都能安全返回 "undefined",但打在一个处于 TDZ 的绑定上不行——这个绑定不是"未声明",是"已声明、未初始化"。
而 cycle2/run-ok.mjs 用一行代码同时证明上面两条:
console.log(" [ok-b] a =", typeof a, "| aVal =", typeof aVal);
输出是 ReferenceError: Cannot access 'aVal' before initialization,指在 typeof aVal 上。也就是说这行里 typeof a(a 是函数声明)没抛,typeof aVal(aVal 是 const)抛了——同一行、同一个 typeof、两个绑定,一个过了一个没过。
修法在 cycle/run-defer.mjs:把访问推迟了——不在顶层读,只把读的动作写在函数体里,等两边都求值完之后再调用,输出 getA() = A | getB() = {"b":"B","aSeen":"A"}、exit=0。注意 aSeen 是 "A"——接线确实是活的,只是你用它的时机必须晚于它被初始化。
六、CJS 循环依赖:不抛错,读半截 exports
同一个循环形状,搬到 CJS(cycle2/run-cjs.cjs):
$ node cycle2/run-cjs.cjs
entry 开始
[cjs-b] 读 a.a = undefined <- a 尚未执行完
[cjs-b] 此刻 a 的键 = []
[cjs-a] 现在读 b.b = "B"
entry 最终拿到 m 的键 = ["a"]
entry 结束(全程没有抛错)
(node:78972) Warning: Accessing non-existent property 'a' of module exports inside circular dependency
--- exit=0 ---
exit=0,一点没抛。 它读到 a.a = undefined,而且那一刻 a 的 exports 对象上的键是空数组 []——a 模块还没执行到设置 a.a 那一行。等到 entry 收尾再看,m 的键是 ["a"],值已经在了。
这就是 CJS 的加载机制:深度优先、边执行边往 module.exports 上填。遇到一个正在加载中的模块,它不等待、不报错,直接把那个模块当前的 exports 对象返回给你——哪怕它还是空的。你拿到的是一个对象引用,所以晚一点再读会看到后来填进去的东西。cycle2/run-late.cjs 把两种时机并排放着:早读 a.a = undefined,晚读 b.seeA() = "A"。同一个属性、同一次加载,差别只在读的时机。
那条警告也要看清原文:它是 Warning,不是 Error。出现的前提是"你访问了一个此刻还不存在的属性",而 Node 在这里选择提醒一声然后继续。所以它极易被日志淹掉——exit=0、程序照常跑完,只有这一行警告。
本章脉络
整章压成一张图。上半是 ESM,下半是 CJS,虚线是各自的失败路径:
flowchart TD
A["模块图里出现一条环<br/>a 依赖 b,b 依赖 a"] --> B{"按哪套规范加载?"}
B -- "ESM" --> C["先实例化:把所有 import/export 接线接好"]
C --> D["再从入口逐个求值"]
D -.->|顶层读一个还没初始化的 const/let 绑定| E["ReferenceError: Cannot access 'a' before initialization<br/>栈顶 ModuleJob.run —— 进程直接死"]
D -- "导出的是 function 声明" --> F["函数在求值前已初始化<br/>typeof a = function,不抛"]
D -- "把访问推迟到函数体里,两边求值完再调用" --> G["run-defer.mjs 输出<br/>aSeen = A,exit=0"]
B -- "CJS" --> H["深度优先,边执行边填 module.exports"]
H -.->|拿到正在加载中模块的 exports(哪怕还是空的)| I["读到 undefined<br/>Warning: Accessing non-existent property 'a' ...<br/>exit=0,不抛"]
I -- "晚一点再读同一个对象" --> J["读到 A"]
style E fill:#ffebee,color:#b71c1c
style I fill:#fff3e0,color:#e65100
style F fill:#e8f5e9,color:#1b5e20
style G fill:#e8f5e9,color:#1b5e20
style J fill:#e8f5e9,color:#1b5e20
图里在说什么。 同一个环,从"按哪套规范加载"分两路。ESM 那一路是"先实例化接线、再求值",所以顶层读一个还没初始化的 const / let 走虚线落到红框,进程直接死;两条绿线是它的活路——导出函数声明、或者把访问推迟。CJS 那一路是"边执行边填 exports",撞上正在加载中的模块走虚线落到橙框:undefined 加一条警告、exit=0;橙框下面还有一条绿线,同一块内存晚点再读就有值了。红框和橙框的差别就是全章那句结论——一边抛错,一边不吭声。
收成一句话:ESM 的循环是 fail fast(抛错),CJS 的循环是 fail silent(undefined + 一条警告)。 两条都不是"更对",而是两条设计路线:ESM 选择让"时机错了"这件事立刻暴露,CJS 选择让加载过程永远跑完。代价是:CJS 里这个 bug 会带着 exit=0 一路混到生产环境。
生产边界
- "改了就生效"不是 ESM 的优化,是它的语义。 别把它理解成"ESM 帮你同步值",否则你会写出依赖"某个时刻的值"的代码,而在 CJS 里完全失效。判据只有一条:你手上是引用还是拷贝。
- getter 是 CJS 里唯一能拿到"当前值"的常规手段,但不免费:每次读都执行一次函数,而且它挡不住"整个对象被替换"的情况。写之前先想清楚你要的是值还是接线的语义。
module.exports = { n, inc }里的n是拷贝,这不是 bug。 要暴露"可变的量",得用 getter、让调用方通过函数去问(get())、或者把状态挂在一个共享对象上。import提升意味着"把require挪到后面以控制初始化顺序"这类技巧在 ESM 里失效。 那是函数调用的技巧,ESM 里没有对应的东西。- 顶上那条
Warning: Accessing non-existent property ... inside circular dependency在生产里几乎等于没有告警:它不改变exit code,默认只打一次,会被日志卷走。要真看见 CJS 循环,得靠静态分析或 lint,不能靠运行时。 - 编译产物和源码的语义可能不一样。 Babel / TypeScript 把 ESM 编译成 CJS 之后,运行时看到的是 CJS 加载器——循环依赖的失败形态会从"抛 TDZ 错"变成"静默
undefined"。同一个环,源码里 fail fast,编译产物里 fail silent。这一条是从本章第五、六节两条实测推出来的,本实验台没有直接跑 Babel / TS 的产物验证(E8 只验证了moduleResolution与产物里的 import 形态,没做循环依赖的编译对照)。但它足以立一条纪律:改构建配置、改编译 target 时,"循环依赖会不会报错"是需要重新验证的行为。 typeof不是万能的安全探针。 它在 TDZ 绑定上会抛,所以"用typeof x === 'undefined'判断模块是否加载完"这种写法在 ESM 循环里会直接炸,而不是返回false。
动手:可观察结果
第一步,造一个能复现 TDZ 的最小两文件例子。 就这两个文件,别加别的:
// tdz-a.mjs
import { b } from "./tdz-b.mjs";
export const a = "A";
console.log(" [tdz-a] b =", b);
// tdz-b.mjs
import { a } from "./tdz-a.mjs";
console.log(" [tdz-b] 想读 a:", a);
export const b = "B";
入口 run-tdz.mjs 只有一句 import "./tdz-a.mjs";。期望结果:ReferenceError: Cannot access 'a' before initialization,指在 tdz-b.mjs:3,exit=1,栈顶 ModuleJob.run。跑不出这一条,说明环的形状不对——检查 tdz-b.mjs 是不是真在顶层读了 a。
第二步,三种改法各跑一遍,看"能修/不能修"的分界。
| 改法 | 怎么改 | 期望结果 | 这一格说明什么 |
|---|---|---|---|
| 改成函数声明 | export const a = "A" → export function a() { return "A"; } |
不抛。[fn-b] typeof a = function,收尾 entry 调 a() = A,exit=0 |
函数声明在求值前已初始化,顶层碰它没问题 |
| 改成延迟访问 | 把 console.log(..., a) 挪进函数体,等两边求值完再调用 |
不抛。getA() = A,getB() = {"b":"B","aSeen":"A"},exit=0 |
接线本来是活的;坏的只是访问时机,推迟就修好了 |
| 改成 CJS | 两个文件都改成 .cjs + require |
不抛。早读 a.a = undefined、此刻 a 的键是 [];晚读 "A";附警告 Accessing non-existent property 'a' ...,exit=0 |
"改成 CJS"是"不报错",不是"修好了":值仍然是错的,只是错的方式从抛错变成 undefined |
完成标志:你能在改之前就说出"这一改会不会让 exit code 变"。三种改法里前两种是真的修(语义正确、exit=0),第三种只是把报错换成静默的错值——跑完这三格还分不清这两者,就等于没读这一章。
故障注入
| 注入方式 | 观察 |
|---|---|
把 export const a 换成 export let a |
报错文案变不变?(期望:还是 Cannot access 'a' before initialization——TDZ 对 let / const 一视同仁) |
把顶层那句 console.log(..., a) 换成 console.log(typeof a) |
还抛吗?用来验证"typeof 打在 TDZ 绑定上照样抛" |
把 a 从 const 换成 function,typeof a 留在顶层 |
不抛了。再把函数换成 class 试试——class 声明也进 TDZ |
CJS 版本里把早读挪到 module.exports 赋值完成之后 |
从 undefined 变成 "A"。这就是 run-late.cjs 那一对:早读 vs 晚读 |
把 module.exports = { n, inc } 改成 exports.n = n |
还变吗?(期望:还是不变——写的仍是当时那个值) |
把那个 n 改成 Object.defineProperty(exports, "n", { get: () => n }) |
从"永远 0"变成"跟着变"——这一格是"快照 → 接线"的手工改造 |
在 ESM 循环的 import 行上面加一个 console.log |
输出顺序回到第四节那张 hoist 表——依赖先求值,body 后跑 |
自测题
- 为什么不能说"ESM 的绑定更新得比 CJS 快"?用"引用 / 拷贝"这组词给出正确说法。
export let n+ 导出方++n会跟着变,export const cfg+ 导出方改cfg.flag也会跟着变。这两者"跟着变"的机制是同一个吗?- 给导入进来的绑定赋值,报的是
TypeError: Assignment to constant variable.。可导出方用的是let,为什么你这一侧是"constant"? - CJS 里
m.n永远是 0、m.x却从 1 变成 2。这两个属性在构造方式上有什么本质区别? - ESM 里
import写在第 2 行,副作用却在第 1 行之前打印。请解释"import 提升"到底提升了什么。 Cannot access 'a' before initialization和a is not defined差在哪?为什么前者的意思是"a存在"?cycle2/run-ok.mjs那一行里,为什么typeof a安全而typeof aVal抛?自己造一个同为 ESM 循环、但导出class的版本,会抛吗?(说出理由,别只给结论)- CJS 循环读到
undefined时exit code是多少?这对生产环境的含义是什么? - "改成 CJS 能修好循环依赖的报错"——这句话哪里不准确?
现在能解释什么
- 为什么同样"调用
inc()三次",ESM 那边n从 0 变成 3、CJS 那边还是 0:一边给的是接线(现读导出方那块内存),一边给的是对象里拍下的值(拷贝); - 为什么
export const obj的内部字段能改、export let的重新赋值也能被看到——变的是槽位/对象里的内容,不是"值传得更快"; - 为什么给导入的绑定赋值会得到
TypeError: Assignment to constant variable.——导入绑定是只读视图,写权限只属于导出方; - 为什么 CJS 里
exports.x = 1之后改成 2 能被看到,而module.exports = { n }里的n永远不变——同一块内存 vs 一个拷贝,这是唯一的分界线; - 为什么 getter 能让 CJS"活"起来,以及为什么
import的副作用会先于 body 第一行; - 为什么 ESM 循环抛
Cannot access 'a' before initialization——绑定存在但未初始化,函数声明是唯一"提前初始化"的例外,typeof也救不了 TDZ; - 为什么 CJS 循环不抛错、只给
undefined加一条警告——fail fast 与 fail silent 的分野,以及为什么后者在企业日志里等于没有告警; - 为什么把 ESM 编译成 CJS 之后,"循环依赖会抛错"这件事不再成立——编译产物的行为不等于源码的语义。
下一步:03 章 · 一行 import 落到磁盘上哪个文件 —— 现在你知道绑定是接线还是快照了,接下来要看这条接线最初是怎么找到对面那个文件的:exports 字段如何把一整包划成一个包围盒,条件又是一条什么样的选择规则。