Skip to content

第 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["继续问同层下一根"]

三条回程规则:

  1. 下层真变了 → 重算这个依赖 → 值真变了 → 带着"脏"继续往上;
  2. 下层真变了 → 重算这个依赖 → 值没变(白激动)→ 继续问同层下一根;
  3. 下层没变 → 摘掉它的"待核实"章 → 继续问同层下一根。

✍️ 动手写代码

今天只改一个函数: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.ts

src/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。猜猜 c1c2c3 各重算了几次?

(提示:a 真的变了,核实会带着"脏"一路传回来,每层都要重算自己的依赖。)

👉 点开看答案

2 次(注册时 1 次 + 变化后 1 次)。

a 从 2 变成 4,真的变了——核实从 c3 下钻到 c1c1 对完新旧消息发现"真变",带着"脏"一路往上:重算 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. 攒批里源头真的变了(比如 1 → 2),最底层的订报人正确更新——核实带着"脏"走完 100 层回程;
  2. 攒批里源头"改上去又改回来"(净变化为零),每一层都不重算——核实带着"没脏"走完 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 了,"改回去"要改回 2base(5)base(2)),净变化才是零。改完数一数——100 层计数器全都纹丝不动,核实带着"没脏"走完了 100 层回程。

🏁 本章小结

  1. 核实的递归藏在下钻里(问"依赖的依赖")——图太深同样会栈溢出;
  2. 升级版回程票多记一样:核实的是哪个依赖——好回来决定"重算它"还是"摘它的章";
  3. ""跟着回程票一路传:下层真变 → 每层重算自己的依赖;没变 → 一路摘章——23 条测试全绿,行为一字没改

传播、核实,双双换上了回程票。老墨在第三张图纸上写下的标题,让小铃倒吸一口凉气:

防重复传播:Recursed / RecursedCheck——邮差怎么认出"这趟我自己跑过了"?

下一章,消息城要对付一个最狡猾的对手:自己改自己的消息