Skip to content

第 11 章 显式栈:把递归换成迭代

📖 邮差的回程票

体检完的第二天,小铃早早来到老墨的办公室——第三部分"优化之路"的图纸已经摊开在桌上了。

第一张图纸画的是传播。可小铃盯着图纸看了半天,总觉得哪里不对。

"老墨,"她指着图纸说,"邮差走到中转站,要下钻到它的听众;走完再回来,继续走同层的下一站——这不就是我们第 8 章写的递归吗?为什么要重画?"

"问得好。"老墨放下茶杯,"递归有个隐藏的代价,你知道吗?"

小铃摇头。

"递归每下钻一层,程序就要在调用栈(call stack)上占一格'跳板'——记住'我是从哪下来的,回去要接着走哪'。第 8 章我们写的传播,每碰到一个中转站就往下跳一层,跳完再跳回来。"老墨在图纸上画了一个越来越深的楼梯,"消息城现在只有几个节点,无所谓。可老墨的梦想,是让信号库去处理几百万个节点的大图——"

"到那时……"

"到那时,楼梯会深到跳板不够用。程序'啪'地一声崩掉,这叫栈溢出(stack overflow)。"老墨拍了拍图纸,"所以,真实的信号库不用递归——它让邮差自己背着一沓回程票走。"

"回程票?"

"对。下钻之前,先买一张票,记着'这一层还有哪一站没走';走完一层,掏出票,回到上一层接着走。路记在自己手里,不占程序的一格跳板——楼梯多深都不怕。这个'自己背票走路'的办法,叫显式栈(explicit stack),也叫迭代(iteration)。"

小铃看着图纸上邮差口袋里那沓票,跃跃欲试:"那……改起来大吗?"

"不大。传播干的事一个字都不变——盖章、排队、下钻。变的只是'怎么记住回去的路':从'靠程序跳板',换成'靠自己的回程票'。"

🎯 本章要解决什么

一句话问题:怎么把传播的递归换成显式栈(迭代),干的事一模一样,却不再依赖调用栈?

读完这一章,你会知道:

  • 递归的隐藏代价:调用栈栈溢出
  • 什么是回程票(显式栈),邮差怎么用它走路;
  • 为什么说"行为一模一样,只是换了个记路的方法"——23 条测试一个字都不用改。

💡 概念讲解

递归的代价:调用栈(跳板)

程序每调用一个函数,都要在调用栈上压一格"跳板",记着"这个函数是从哪被叫的,办完事回哪去"。

递归下钻几层,跳板就压几格:

mermaid
graph TD
    p1["传播(第 1 层)🪜"] --> p2["传播(第 2 层)🪜"]
    p2 --> p3["传播(第 3 层)🪜"]
    p3 --> pn["传播(第 N 层)🪜"]
    pn --> boom["栈溢出 💥"]

图特别深(几百万个节点)的时候,跳板会用完——程序崩掉。这就是栈溢出

显式栈:自己背回程票

换一个思路:不让程序记路,让邮差自己记

邮差的手里攥着一沓回程票(tickets),规则只有两条:

  1. 下钻之前:如果这一层还有下一站没走,买一张票,记下"这一层剩下的路";
  2. 一层走完:掏出最上面那张票(后买的先掏),回到上一层接着走。
mermaid
graph LR
    a["a 的听众链:c1 → c2 → E2"] --> c1["下钻 c1 🧮"]
    c1 --> e1["c1 的听众:E1 🧒<br/>(买票:同层还有 E2)"]
    e1 --> back["走完,掏票回到 E2 🎫"]
    back --> e2["E2 🧒"]

"后买的先掏"有个正式名字:栈(stack),也叫后进先出(LIFO)——像一摞盘子,最后放上去的最先拿走。数组的 push(放上去)和 pop(拿下来),正好就是栈的两种动作。

为什么"行为一模一样"

第 8 章的递归和这一章的显式栈,走的路线完全一样:同一个顺序盖章、同一个顺序排队。变的只是"记住回去的路"的方式:

递归显式栈
路记在哪程序的调用栈(跳板)邮差自己的回程票
图很深时会怎样跳板用完,栈溢出 💥票在自己手里,多深都不怕
代码长相propagate(sub.subs)(调自己)while 循环 + 买票/掏票

所以:23 条测试一个字都不用改——它们验证的是"传播干了什么",而这一章,传播干的事一点没变。

✍️ 动手写代码

今天只改一个函数:propagate。把"调自己"换成"while 循环 + 回程票"。

第 1 步:换掉 propagate

打开 src/signal.ts,找到 propagate,整个替换成:

ts
// 传播:把递归换成显式栈——邮差自己扛着"回程票"走
function propagate(link: Link): void {
	const tickets: Link[] = [];        // 回程票:记着从哪下来的,回去继续走
	let l: Link | undefined = link;    // 现在站在哪根线上

	while (l !== undefined) {
		const next: Link | undefined = l.nextSub;   // 同层的下一站(先记住,防迷路)
		const sub: ReactiveNode = l.sub;
		const flags = sub.flags;
		if (!(flags & (ReactiveFlags.Pending | ReactiveFlags.Dirty))) {
			sub.flags = flags | ReactiveFlags.Pending;   // 盖"待核实"章
			if (flags & ReactiveFlags.Watching) {
				notify(sub as EffectNode);               // 自动反应:排队
			} else if (flags & ReactiveFlags.Mutable && sub.subs !== undefined) {
				if (next !== undefined) {
					tickets.push(next);                  // 这一层还有路:买张回程票
				}
				l = sub.subs;                            // 下钻:往它的听众走
				continue;
			}
		}
		l = next ?? tickets.pop();                       // 同层下一站,或掏出回程票
	}
}

对照第 8 章的递归版,逐段看:

  • 盖章、排队:一个字没变——Pending 章、notify 排队,还是老规矩;
  • 下钻:递归版写 propagate(sub.subs)(调自己);现在写 l = sub.subs(把"要走的线"换成它的听众链头),再 continue 回 while 循环继续走;
  • 回程票:下钻前,如果这一层还有下一站(next !== undefined),把它压进 tickets;走到底时,tickets.pop() 把最上面的票掏出来,回到上一层。

第 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 个,一个都不红。 这就是"行为一模一样"的铁证——我们只是换了记路的方式,传播干的事一点没变。🎉

🧰 TS 小课堂:??(空值合并)

今天的新语法藏在最后一行里:

ts
l = next ?? tickets.pop();

?? 读作"空值合并":左边是 undefined(或 null),就用右边;否则用左边

  • next 有值(这一层还有下一站)→ 用 next
  • nextundefined(这一层走完了)→ 用 tickets.pop()(掏回程票)。

它是 next !== undefined ? next : tickets.pop()短写法——一样的意思,更短、更清楚。

小心别和 || 搞混

|| 是"左边是假的就用右边"(0'' 也算假);?? 只认 undefinednull。数字 0 是"真值",0 ?? 500 || 5 却是 5。记:?? 只管"空",不管"假"

📚 本章用语

意思记住它
调用栈 (call stack)程序记"函数从哪来、回哪去"的跳板递归每层压一格
栈溢出 (stack overflow)跳板用完了,程序崩掉图太深就会发生
(stack)后进先出的一摞东西一摞盘子,后放的先拿
显式栈自己用数组 push/pop 记路回程票在自己手里
迭代 (iteration)用循环代替递归不调自己,走 while

这些是本章用语——"栈"是程序员的通用概念,不进书末尾的正式词典。

📦 章末完整代码

这一章你只换了一个函数,文件夹结构一个字没变:

signal-book/
├── demo/
│   └── ch01.mjs
├── node_modules/
├── package.json
├── tsconfig.json
├── src/
│   ├── money.ts
│   └── signal.ts      ← 只换了 propagate(其余和第 9 章一模一样)
└── 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其他部分和第 9 章"章末完整代码"里那份一模一样——不需要重抄。只有 propagate 换成了第 1 步的迭代版,再贴一遍供对照:

ts
// 传播:把递归换成显式栈——邮差自己扛着"回程票"走
function propagate(link: Link): void {
	const tickets: Link[] = [];        // 回程票:记着从哪下来的,回去继续走
	let l: Link | undefined = link;    // 现在站在哪根线上

	while (l !== undefined) {
		const next: Link | undefined = l.nextSub;   // 同层的下一站(先记住,防迷路)
		const sub: ReactiveNode = l.sub;
		const flags = sub.flags;
		if (!(flags & (ReactiveFlags.Pending | ReactiveFlags.Dirty))) {
			sub.flags = flags | ReactiveFlags.Pending;   // 盖"待核实"章
			if (flags & ReactiveFlags.Watching) {
				notify(sub as EffectNode);               // 自动反应:排队
			} else if (flags & ReactiveFlags.Mutable && sub.subs !== undefined) {
				if (next !== undefined) {
					tickets.push(next);                  // 这一层还有路:买张回程票
				}
				l = sub.subs;                            // 下钻:往它的听众走
				continue;
			}
		}
		l = next ?? tickets.pop();                       // 同层下一站,或掏出回程票
	}
}

跑通了? 只要 npm test 打出 23 passed,邮差的回程票就缝好了——干的事一模一样,路却记在自己手里了

🎮 动手试试

怎么玩

先自己写答案,再点开对照。忍得住才点开哦!

1. 预言输出(猜一猜)

小铃搭了一条"岔路":a → c1 → E1,还有一条直达的 a → E2。猜猜 logs 最后长什么样?

ts
const logs: string[] = [];
const a = signal(1);
const c1 = computed(() => a() * 2);
effect(() => {
	logs.push('E1');
	c1();
});
effect(() => {
	logs.push('E2');
	a();
});

a(5);
👉 点开看答案

logs['E1', 'E2', 'E1', 'E2']

  • 注册时:E1 先跑一遍(push 'E1'),E2 再跑一遍(push 'E2');
  • a(5) 变化时:传播先走到 c1(下钻),把 E1 排队;再回到同层,把 E2 排队——先下钻的订报人先跑,所以是 E1、E2。

排队顺序就是传播的走路顺序——递归和显式栈走的路一模一样,所以顺序也一模一样。

2. 帮小铃找 bug(抓错)

小铃写迭代版时,把两行重要的代码忘了:

ts
			} else if (flags & ReactiveFlags.Mutable && sub.subs !== undefined) {
				l = sub.subs;
				// 咦?少了 continue,也少了买票!
			}

结果:测试红了——E1 再也不跑了。为什么?

👉 点开看答案

少了 continue,程序会掉出 if,执行最后一行的 l = next ?? tickets.pop()——把刚换好的 lsub.subs,下钻的路)又覆盖成同层的下一站了。下钻的路被丢掉,c1 的听众(E1)永远走不到,自然再也不跑。

少了买票,就算补上 continue,下钻完也会丢掉"同层还有 E2 没走"的记忆——E2 也会被漏掉。

正确写法缺一不可:

ts
				if (next !== undefined) {
					tickets.push(next);   // 买票:记住同层剩下的路
				}
				l = sub.subs;             // 下钻
				continue;                 // 别让下面的代码覆盖 l!

3. 填空(补全)

迭代版最后一行少了一半,补全它:

ts
		l = next ?? /* 这里该写什么? */;
👉 点开看答案

tickets.pop()

ts
		l = next ?? tickets.pop();

这一层的下一站没了(nextundefined),就掏出最上面的回程票,回到上一层接着走。

4. 小挑战(深链)

for 循环造一条 100 层的中转站链(每个计算值都依赖上一个,+1),最后挂一个订报人。改一次源头的信号,验证最底层的订报人正确更新。

再想一想:如果消息城的图有几百万层,递归会栈溢出,显式栈为什么不怕?

👉 点开看答案

一种写法:

ts
const base = signal(1);
let chain: () => number = () => base();
for (let i = 0; i < 100; i++) {
	const prev = chain;
	chain = computed(() => prev() + 1);
}

const logs: number[] = [];
effect(() => {
	logs.push(chain());
});
expect(logs).toEqual([101]);

base(2);
expect(logs).toEqual([101, 102]);

100 层下钻,传播的tickets最多攒 99 张票——全在邮差自己手里,程序调用栈一格都不占。图再深,也只是票多几张,不会栈溢出。递归就不一样了——每层占一格跳板,深到几万层就会崩。这就是真实信号库把递归换成显式栈的原因。

🏁 本章小结

  1. 递归有个隐藏代价:每下钻一层占一格调用栈(跳板),图太深会栈溢出、程序崩溃;
  2. 显式栈让邮差自己背回程票tickets 数组 + push/pop):下钻前买票记住同层剩下的路,走完一层掏票回去——路记在自己手里
  3. 传播干的事一个字没变——23 条测试全绿就是铁证:递归和迭代,只是"记路"的方式不同。

回程票缝好了。老墨在第二张图纸上写下标题:

核实的递归,也换成回程票。

下一章,把 checkDirty 的递归也换成迭代——让"打电话核实"也一样不怕深。