Appearance
第 12 章 迭代版核实:打电话也背回程票
📖 升级的回程票
邮差的回程票缝好没几天,老墨又铺开了第二张图纸。小铃一看——上面画的还是核实(checkDirty)。
"核实也要改?"小铃有点意外,"它不就是……打电话问依赖'你们真的变了吗'吗?"
"对。可你想想,核实是怎么打电话的?"老墨指着图纸,"它沿着依赖链走,碰到'待核实'的中转站,就下钻去问那个中转站的依赖;碰到'过期'的信号,就让信号对一对新旧消息……这一层一层往下问,不也是递归吗?"
小铃恍然:"所以核实也怕'栈溢出'——图太深,跳板会不够用!"
"正是。"老墨点头,"而且核实的回程,比传播更麻烦。"
"更麻烦?"
"传播的回程票,只要记得'从哪下来的、回去接着走哪'就行。核实的回程票,还得记得'下去问出了什么'——如果下层真的变了,回来就要重算这个依赖;如果没变,回来就要摘掉它的'待核实'章。'脏没脏'这个结论,要跟着你一路走回来。"
"哦——"小铃眼睛一亮,"所以回程票要升级:不光记'路',还要记'问了谁',好回来决定'重算还是摘章'。"
"聪明。这一章,我们就把核实的递归换成这张升级版回程票。"
🎯 本章要解决什么
一句话问题:怎么把核实的递归换成显式栈,而且"脏没脏"的结论能跟着回程票一路带回来?
读完这一章,你会知道:
- 核实的递归藏在哪里(下钻问"依赖的依赖");
- 升级版回程票长什么样:记着"从哪根线下来 + 核实的是哪个依赖";
- "脏"是怎么跟着回程票一路传回来的——每层决定"重算还是摘章"。
大纲小说明
原来的大纲里,这一章还写着"浅传播"——它其实在第 9 章就提前上岗了(同源变化要给听众升级"过期"章)。所以这一章的主角,是迭代版核实。
💡 概念讲解
核实的递归:一层一层往下问
第 9 章的核实长这样(简化版):
ts
function checkDirty(node) {
// 沿依赖链走,问每个依赖
if (依赖盖着"过期"章) {
if (update(依赖)) return true; // 真变了!
} else if (依赖盖着"待核实"章) {
if (checkDirty(依赖)) { // ← 下钻!问依赖的依赖
if (update(依赖)) return true;
} else {
摘掉依赖的"待核实"章;
}
}
// …继续问下一根依赖
return false;
}checkDirty(依赖) 里的 checkDirty——它调用自己。依赖链多深,就下钻多深:
mermaid
graph TD
c["c 核实 c 🧮"] --> c2["c2 核实 c2 🧮"]
c2 --> a["a 信号:对一对新旧 📮"]
a --> up["真变了!带着'脏'往上走 🔺"]和传播一样:图太深,递归的跳板会不够用。所以,换成显式栈。
升级版回程票
传播的回程票只记"路";核实的回程票要记两样:
| 回程票上记的 | 用途 |
|---|---|
link | 从哪根依赖线下来的——回来接着问它的下一根 |
dep | 核实的是哪个依赖——回来决定"重算它"还是"摘它的章" |
ts
type Ticket = { link: Link; dep: ReactiveNode }; // 升级版回程票"脏"怎么传回来
下钻问完一层,回程时带着一个结论:"脏"(dirty)——下层真的变了吗?
mermaid
graph LR
down["下钻:问依赖的依赖 🔽"] --> result{"下层问出了什么?"}
result -- "真变了(脏)" --> up1["回来:重算这个依赖 🔺"]
up1 --> more{"重算后值变了吗?"}
more -- "变了" --> keep["带着'脏'继续往上走 🔺🔺"]
more -- "没变" --> next1["白激动:继续问同层下一根"]
result -- "没变" --> clear["回来:摘掉它的'待核实'章"]
clear --> next2["继续问同层下一根"]三条回程规则:
- 下层真变了 → 重算这个依赖 → 值真变了 → 带着"脏"继续往上;
- 下层真变了 → 重算这个依赖 → 值没变(白激动)→ 继续问同层下一根;
- 下层没变 → 摘掉它的"待核实"章 → 继续问同层下一根。
✍️ 动手写代码
今天只改一个函数:checkDirty。把"调自己"换成"while 循环 + 升级版回程票"。
第 1 步:换掉 checkDirty
打开 src/signal.ts,找到 checkDirty,整个替换成:
ts
// 核实:把递归换成显式栈——打电话也背回程票
function checkDirty(node: ComputedNode): boolean {
// 回程票:记着"从哪根依赖线下来、核实的是哪个依赖"
type Ticket = { link: Link; dep: ReactiveNode };
const tickets: Ticket[] = [];
let l: Link | undefined = node.deps; // 现在核实的依赖线
let dirty = false; // 下层问出"真变了"了吗?
while (true) {
if (l === undefined) {
// 这一层问完了:往回走
if (tickets.length === 0) {
return dirty; // 没真变 → false;有真变 → true
}
const ticket: Ticket = tickets.pop()!;
if (dirty) {
// 下层真的变了:核实这个依赖(重算它)
if (update(ticket.dep as SignalNode | ComputedNode)) {
continue; // 它重算后真的变了:带着"脏"继续往上
}
dirty = false; // 重算了但值没变:白激动,继续问同层
} else {
// 下层没变:摘掉它的"待核实"章
ticket.dep.flags &= ~ReactiveFlags.Pending;
}
l = ticket.link.nextDep; // 回到上一层,继续问下一根依赖
continue;
}
const dep: ReactiveNode = l.dep;
if (dep.flags & ReactiveFlags.Dirty) {
// 依赖盖着"过期"章:让它把新旧消息对一对
if (update(dep as SignalNode | ComputedNode)) {
dirty = true; // 真的变了!带着"脏"往回走
l = undefined;
} else {
l = l.nextDep; // 没真变:继续问下一根
}
} else if (dep.flags & ReactiveFlags.Pending) {
// 依赖盖着"待核实"章:下钻,先问它的依赖们
tickets.push({ link: l, dep });
l = (dep as ComputedNode).deps;
} else {
l = l.nextDep; // 干净的依赖:继续问下一根
}
}
}对照第 9 章的递归版,逐段看:
- 问依赖:还是三档——
Dirty章(对一对新旧)、Pending章(下钻)、干净(跳过)——一个字的判断逻辑都没变; - 下钻:递归版写
checkDirty(dep)(调自己);现在写tickets.push({ link: l, dep }); l = (dep as ComputedNode).deps;——买一张升级版回程票(记着路和依赖),然后把"要核实的线"换成那个依赖的依赖链; - 回程:这一层问完了(
l === undefined),掏出最上面的票;看dirty决定——脏了→重算ticket.dep(变了就继续往上,没变就继续问同层);没脏→摘掉ticket.dep的章; - 脏的起点:某个"过期"信号对完新旧真的变了,
dirty = true,从此一路带上回程。
第 2 步:叫检查员!(回归)
bash
npm test🚀 现在跑一下,你应该看到:
✓ tests/checkup.spec.ts (2 tests) ✓ tests/checkdirty.spec.ts (2 tests) ✓ tests/subscription.spec.ts (2 tests) ✓ tests/naive-problems.spec.ts (2 tests) ✓ tests/batch.spec.ts (4 tests) ✓ tests/computed.spec.ts (4 tests) ✓ tests/signal.spec.ts (5 tests) ✓ tests/money.spec.ts (2 tests) Test Files 8 passed (8) Tests 23 passed (23)23 个,一个都不红。 包括第 9 章那两个"不白算"和"递归核实"的测试——迭代版干的事,和递归版一模一样。🎉
🧰 TS 小课堂:type 别名和 !
今天有两样小语法。
1. type 别名(给类型起名字)
type 是"给一个类型起个小名":
ts
type Ticket = { link: Link; dep: ReactiveNode };以后写 Ticket,就是写 { link: Link; dep: ReactiveNode }。名字短了,读起来也明白——"回程票"比"一个装着 link 和 dep 的对象"清楚多了。
(其实第 7 章我们就用过 type Subscriber = EffectNode | ComputedNode——当时没细讲,今天补上。)
2. !(我保证它不是空)
ts
const ticket: Ticket = tickets.pop()!;tickets.pop() 的图纸类型是 Ticket | undefined——万一数组是空的,它会返回 undefined。可我们明明先检查了 tickets.length === 0 才走到这行,它不可能是空的。! 就是跟 TypeScript 保证:"我确定它不会是空,你别多问"。
和 as 一样,! 是一张"保证书"——用的时候要真的确定才行。
📚 本章用语
| 词 | 意思 | 记住它 |
|---|---|---|
| 升级版回程票 | 记着"从哪根线下来 + 核实的是哪个依赖"的票 | 好回来决定"重算还是摘章" |
| 脏 (dirty) | 下层问出"真的变了"的结论 | 一路带回程 |
| 白激动 | 下层说变了,重算却发现值没变 | 继续问同层,别往上带 |
这些是本章用语——"脏"在这里指核实带回来的结论,不是消息城的正式术语。
📦 章末完整代码
这一章你只换了一个函数,文件夹结构一个字没变:
signal-book/
├── demo/
│ └── ch01.mjs
├── node_modules/
├── package.json
├── tsconfig.json
├── src/
│ ├── money.ts
│ └── signal.ts ← 只换了 checkDirty(其余和第 11 章一模一样)
└── tests/
├── money.spec.ts
├── signal.spec.ts
├── computed.spec.ts
├── batch.spec.ts
├── naive-problems.spec.ts
├── subscription.spec.ts
├── checkdirty.spec.ts
└── checkup.spec.tssrc/signal.ts 的其他部分和第 11 章"章末完整代码"里那份一模一样——不需要重抄。只有 checkDirty 换成了第 1 步的迭代版,再贴一遍供对照:
ts
// 核实:把递归换成显式栈——打电话也背回程票
function checkDirty(node: ComputedNode): boolean {
// 回程票:记着"从哪根依赖线下来、核实的是哪个依赖"
type Ticket = { link: Link; dep: ReactiveNode };
const tickets: Ticket[] = [];
let l: Link | undefined = node.deps; // 现在核实的依赖线
let dirty = false; // 下层问出"真变了"了吗?
while (true) {
if (l === undefined) {
// 这一层问完了:往回走
if (tickets.length === 0) {
return dirty; // 没真变 → false;有真变 → true
}
const ticket: Ticket = tickets.pop()!;
if (dirty) {
// 下层真的变了:核实这个依赖(重算它)
if (update(ticket.dep as SignalNode | ComputedNode)) {
continue; // 它重算后真的变了:带着"脏"继续往上
}
dirty = false; // 重算了但值没变:白激动,继续问同层
} else {
// 下层没变:摘掉它的"待核实"章
ticket.dep.flags &= ~ReactiveFlags.Pending;
}
l = ticket.link.nextDep; // 回到上一层,继续问下一根依赖
continue;
}
const dep: ReactiveNode = l.dep;
if (dep.flags & ReactiveFlags.Dirty) {
// 依赖盖着"过期"章:让它把新旧消息对一对
if (update(dep as SignalNode | ComputedNode)) {
dirty = true; // 真的变了!带着"脏"往回走
l = undefined;
} else {
l = l.nextDep; // 没真变:继续问下一根
}
} else if (dep.flags & ReactiveFlags.Pending) {
// 依赖盖着"待核实"章:下钻,先问它的依赖们
tickets.push({ link: l, dep });
l = (dep as ComputedNode).deps;
} else {
l = l.nextDep; // 干净的依赖:继续问下一根
}
}
}✅ 跑通了? 只要
npm test打出23 passed,升级版回程票就缝好了——核实也再也不怕深。
🎮 动手试试
怎么玩
先自己写答案,再点开对照。忍得住才点开哦!
1. 预言输出(猜一猜)
小铃搭了一条三层链:a → c1 → c2 → c3 → 订报人。她给每层都装了计数器,然后在一个攒批里把 a 从 2 改成 4。猜猜 c1、c2、c3 各重算了几次?
(提示:a 真的变了,核实会带着"脏"一路传回来,每层都要重算自己的依赖。)
👉 点开看答案
各 2 次(注册时 1 次 + 变化后 1 次)。
a 从 2 变成 4,真的变了——核实从 c3 下钻到 c1,c1 对完新旧消息发现"真变",带着"脏"一路往上:重算 c1(变了)→ 继续往上重算 c2(变了)→ 再往上重算 c3(变了)。三层各重算一遍,dirty 一路带到底,最后 checkDirty 返回 true,订报人读到新值。
2. 帮小铃找 bug(抓错)
小铃写回程那段时,把"白激动"的处理忘了:
ts
if (dirty) {
// 下层真的变了:核实这个依赖(重算它)
if (update(ticket.dep as SignalNode | ComputedNode)) {
continue; // 变了:带着"脏"继续往上
}
// 咦?值没变呢?dirty 忘了复位!
}结果:checkdirty.spec.ts 里"改了又改回去"的测试红了——中转站白算了一次。为什么?
👉 点开看答案
因为"白激动"之后,dirty 还是 true。
值没变(重算结果和原来一样),本该"白激动,继续问同层"——可 dirty 没复位,它继续带着"脏"往上走,上面的每一层都以为"真的变了",跟着重算;最后 checkDirty 还返回 true,订报人那一层也重算——全白算了。
"改了又改回去"的测试里,中转站本该一次都不重算(runs 保持 1),现在多算了一遍(变 2),测试就红了。补上复位就好:
ts
dirty = false; // 白激动:别把"脏"往上带3. 填空(补全)
回程时"下层没变"要摘掉依赖的章,少了一行。补全它:
ts
} else {
/* 这里该写什么? */
}👉 点开看答案
写:
ts
ticket.dep.flags &= ~ReactiveFlags.Pending;下层没真变,这个依赖的"待核实"章就不该再盖着——摘掉它,下次传播才能再给它盖章、再核实。别忘了:摘的是回程票上记的那个依赖(ticket.dep),不是别的。
4. 小挑战(深链核实)
用循环造一条 100 层的中转站链,验证两件事:
- 攒批里源头真的变了(比如
1 → 2),最底层的订报人正确更新——核实带着"脏"走完 100 层回程; - 攒批里源头"改上去又改回来"(净变化为零),每一层都不重算——核实带着"没脏"走完 100 层回程,一路摘章。
数一数每层的计数器,验证你的答案。
👉 点开看答案
参考写法(核心部分):
ts
const base = signal(1);
let chain: () => number = () => base();
const counters: number[] = [];
for (let i = 0; i < 100; i++) {
counters.push(0);
const prev = chain;
const idx = i;
chain = computed(() => {
counters[idx]++;
return prev() + 1;
});
}
const logs: number[] = [];
effect(() => {
logs.push(chain());
});
expect(logs).toEqual([101]);
// ① 真的变了:每层都重算一次
startBatch();
base(2);
endBatch();
expect(logs).toEqual([101, 102]);
counters.forEach((c) => expect(c).toBe(2));
// ② 改回去:一层都不重算
startBatch();
base(5);
base(1); // 又回到 1?不对——现在 base 是 2,改到 5 再改回 2 才是"净零"
endBatch();注意第二件事的小陷阱:base 现在已经是 2 了,"改回去"要改回 2(base(5) 再 base(2)),净变化才是零。改完数一数——100 层计数器全都纹丝不动,核实带着"没脏"走完了 100 层回程。
🏁 本章小结
- 核实的递归藏在下钻里(问"依赖的依赖")——图太深同样会栈溢出;
- 升级版回程票多记一样:核实的是哪个依赖——好回来决定"重算它"还是"摘它的章";
- "脏"跟着回程票一路传:下层真变 → 每层重算自己的依赖;没变 → 一路摘章——23 条测试全绿,行为一字没改。
传播、核实,双双换上了回程票。老墨在第三张图纸上写下的标题,让小铃倒吸一口凉气:
防重复传播:Recursed / RecursedCheck——邮差怎么认出"这趟我自己跑过了"?
下一章,消息城要对付一个最狡猾的对手:自己改自己的消息。