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 给的是"到那块内存的接线",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 行执行

同一份源码顺序,输出顺序换了。原因是这两行在语言里根本不是一类东西:

还有个更直观的结果,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 一路混到生产环境。

生产边界

动手:可观察结果

第一步,造一个能复现 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 后跑

自测题

  1. 为什么不能说"ESM 的绑定更新得比 CJS 快"?用"引用 / 拷贝"这组词给出正确说法。
  2. export let n + 导出方 ++n 会跟着变,export const cfg + 导出方改 cfg.flag 也会跟着变。这两者"跟着变"的机制是同一个吗?
  3. 给导入进来的绑定赋值,报的是 TypeError: Assignment to constant variable.。可导出方用的是 let,为什么你这一侧是"constant"?
  4. CJS 里 m.n 永远是 0、m.x 却从 1 变成 2。这两个属性在构造方式上有什么本质区别?
  5. ESM 里 import 写在第 2 行,副作用却在第 1 行之前打印。请解释"import 提升"到底提升了什么。
  6. Cannot access 'a' before initialization 和 a is not defined 差在哪?为什么前者的意思是"a 存在"?
  7. cycle2/run-ok.mjs 那一行里,为什么 typeof a 安全而 typeof aVal 抛?自己造一个同为 ESM 循环、但导出 class 的版本,会抛吗?(说出理由,别只给结论)
  8. CJS 循环读到 undefined 时 exit code 是多少?这对生产环境的含义是什么?
  9. "改成 CJS 能修好循环依赖的报错"——这句话哪里不准确?

现在能解释什么

下一步:03 章 · 一行 import 落到磁盘上哪个文件 —— 现在你知道绑定是接线还是快照了,接下来要看这条接线最初是怎么找到对面那个文件的:exports 字段如何把一整包划成一个包围盒,条件又是一条什么样的选择规则。

进入 keel 阅读