Skip to content

第 13 章 防重复传播:Recursed / RecursedCheck

📖 消息城最狡猾的对手

第三张图纸摊开的时候,老墨的表情比前两次严肃。

"小铃,"他说,"消息城有一个最狡猾的对手,前面的章节我们一直绕着它走。今天,必须正面碰一碰了。"

小铃咽了咽口水:"什么对手?"

"自己改自己。"老墨在纸上写了一段代码:

ts
const count = signal(0);

effect(() => {
	count(count() + 1);   // 订报人读 count,又写 count!
});

"看出来了?这个订报人读 count——它是 count 的订阅者;可它又写 count——写的时候,传播会沿着 count 的听众链走,走到谁?"

"走到它自己!"小铃倒吸一口凉气,"然后它又跑一遍,又写一遍,又跑一遍……"

"永远停不下来。"老墨点头,"这叫自己追自己——如果传播不认人,消息城就会永远卡在这里,像一只追着自己尾巴的猫。"

"那怎么办?"

"邮差需要一个记号,认出**'正在跑的自己'。"老墨在图纸上画了两面新旗子,"今天,消息城要添最后两面旗——正在跑(RecursedCheck)和递归记号**(Recursed)。"

🎯 本章要解决什么

一句话问题:订报人在跑的时候改了自己读的消息——传播怎么认出"正在跑的自己",不让它追着自己死循环?

读完这一章,你会知道:

  • 最后两面旗:正在跑(RecursedCheck)和递归记号(Recursed);
  • 邮差遇到四种人怎么区别对待(完整状态机);
  • 想故意"有上限地追自己"怎么办(getActiveSub)。

💡 概念讲解

问题:自己追自己

没有防护时,自写自读的订报人每跑一遍就写一次,写一次又触发自己跑一遍——无限循环

mermaid
graph LR
    run["订报人跑 📝"] --> write["写 count"]
    write --> propagate["传播 📣"]
    propagate --> run2["又走到它自己!"]
    run2 --> write

两面新旗

第 8 章之后,消息城有四面旗(可变、盯着、过期、待核实)。今天补上最后两面:

代码谁盖它意思
正在跑RecursedCheck订报人/中转站跑的时候邮差看到它:这位正在忙,别追着通知它
递归记号Recursed正在跑的时候被写过的节点记一笔"它跑着的时候挨过写"——下次传播时摘掉、重新通知

两面旗一起用,就是防死循环的钥匙。

邮差遇到四种人

现在的传播,有了完整的状态机。邮差沿听众链走,看对方的旗子:

遇到谁怎么办
干干净净(四面旗都没有)盖"待核实"章 → 订报人排队 / 中转站下钻
已经盖过章(待核实/过期,没在跑、没递归记号)这轮已经通知过了,跳过
带着递归记号(没在跑)摘掉递归记号 → 重新盖"待核实"章 → 排队/下钻
正在跑(戴着 RecursedCheck)只盖递归记号,不打扰——它跑完自然会发现

第四种人就是"正在跑的自己":邮差看到 RecursedCheck,只在它身上盖个"递归记号",不排队、不叫醒——它跑完,事情也结束了,循环被掐断。

一个细节:innerWrite(跑着的时候写的)

还有一件事要记:这次写,是不是发生在一个正在跑的节点里innerWrite)?

  • 是 → 被写的订报人除了盖"待核实"章,还要加盖"递归记号"(因为它是被"正在跑的人"写的,等下轮要特殊处理);
  • 不是 → 普通盖"待核实"章。

想故意追自己?getActiveSub

有的人生怕循环,有的人故意想追自己——比如"让 count 一直加到 5 就停"。怎么办?

消息城提供一把钥匙:getActiveSub()——拿到"现在正在跑的那个节点"。拿到之后,可以自己摘掉它的"正在跑"旗(flags &= ~RecursedCheck),让邮差不再拦它。然后写一个有上限的规则(比如 count() < 3 才写),追到自己设的界限就收手。

危险的钥匙

摘掉"正在跑"旗 = 允许自己追自己。必须配上上限,否则又会死循环——这把钥匙是给"明白自己在干什么"的人用的。

✍️ 动手写代码

今天的改动比较大:传播要升级成完整状态机,自动反应和中转站的"跑"要戴/摘"正在跑"旗。一步步来。

第 1 步:加两面旗,导出枚举

打开 src/signal.ts,把 ReactiveFlags 改成六面旗(注意顺序和编号,和真实库一致),并导出它(一会儿测试要用):

ts
// 小旗子(标志):告诉邮差"我现在是什么状态"
export const enum ReactiveFlags {
	None = 0,           // 什么都没有
	Mutable = 1,        // 可变:我是会变化的消息(信号、计算值)
	Watching = 2,       // 盯着:我在听消息,消息一变我要跑(自动反应)
	RecursedCheck = 4,  // 正在跑:我现在正在忙,别追着通知我
	Recursed = 8,       // 递归记号:我跑着的时候挨过写
	Dirty = 16,         // 过期:有新消息没消费 / 依赖真变了
	Pending = 32,       // 待核实:这轮已经通知过我了,先别打扰
}

然后在全局状态那里加一个计数器,记"现在有没有节点正在跑":

ts
// 正在跑的层数(>0 就说明:这次写发生在"正在跑的节点"里)
let runDepth = 0;

再加一把钥匙——getActiveSub

ts
// 钥匙:拿到"现在正在跑的那个节点"(想故意追自己时用)
export function getActiveSub(): ReactiveNode | undefined {
	return activeSub;
}

第 2 步:升级传播(完整状态机)

propagate 换成下面的完整版。它多了两个东西:innerWrite 参数(跑着的时候写的?)和 isValidLink(检查连接线还连不连):

ts
// 传播:边盖章边下传(认得"正在跑的自己")
function propagate(link: Link, innerWrite: boolean): void {
	const tickets: Link[] = [];        // 回程票
	let l: Link | undefined = link;    // 现在站在哪根线上

	while (l !== undefined) {
		const next: Link | undefined = l.nextSub;   // 同层的下一站(先记住)
		const sub: ReactiveNode = l.sub;
		let flags = sub.flags;

		if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed | ReactiveFlags.Dirty | ReactiveFlags.Pending))) {
			// ① 干干净净:盖"待核实"章(跑着的时候写的,再加"递归记号")
			sub.flags = flags | ReactiveFlags.Pending;
			if (innerWrite) {
				sub.flags |= ReactiveFlags.Recursed;
			}
		} else if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed))) {
			// ② 已经盖过章,没在跑、没递归记号:这轮不重复打扰
			flags = ReactiveFlags.None;
		} else if (!(flags & ReactiveFlags.RecursedCheck)) {
			// ③ 带着递归记号,但没在跑:摘掉记号,重新盖"待核实"章
			sub.flags = (flags & ~ReactiveFlags.Recursed) | ReactiveFlags.Pending;
		} else if (!(flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending)) && isValidLink(l, sub)) {
			// ④ 正在跑,而且这根线还连着:只盖递归记号,不打扰(防追着自己跑!)
			sub.flags = flags | ReactiveFlags.Recursed | ReactiveFlags.Pending;
			flags &= ReactiveFlags.Mutable;
		} else {
			// ⑤ 正在跑,而且已经盖过章:什么都不做
			flags = ReactiveFlags.None;
		}

		if (flags & ReactiveFlags.Watching) {
			notify(sub as EffectNode);   // 自动反应:排队
		}
		if (flags & ReactiveFlags.Mutable) {
			const subSubs: Link | undefined = sub.subs;
			if (subSubs !== undefined) {
				if (next !== undefined) {
					tickets.push(next);  // 这一层还有路:买张回程票
				}
				l = subSubs;             // 下钻
				continue;
			}
		}

		l = next ?? tickets.pop();       // 同层下一站,或掏出回程票
	}
}

对照"四种人"看五个分支,一一对应:① 干净、② 盖过章、③ 递归记号、④ 正在跑、⑤ 正在跑且盖过章。分支 ④ 是关键——正在跑的订报人被盖上递归记号,却不排队,循环当场掐断。

然后在"连线和摘线"部分末尾加 isValidLink

ts
// 这根连接线还连在吗?(正在跑的节点可能已经换过订阅)
function isValidLink(checkLink: Link, sub: ReactiveNode): boolean {
	let l = sub.depsTail;
	while (l !== undefined) {
		if (l === checkLink) {
			return true;
		}
		l = l.prevDep;
	}
	return false;
}

第 3 步:改信号(signal)

write 只有一处小改:传播要带上"这次写是不是发生在正在跑的节点里":

ts
	const write = (next: T): void => {
		if (node.pendingValue === next) {
			return;                       // 新消息没变?那就不发通知
		}
		node.pendingValue = next;         // 新消息进格
		node.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty;   // 盖上"过期"章
		if (node.subs !== undefined) {
			propagate(node.subs, runDepth > 0);   // 跑着的时候写的?带上 innerWrite
		}
		if (batchDepth === 0) {
			flush();                      // 没在攒批?立刻发刊
		}
	};

runDepth > 0 就是在问:"现在有节点正在跑吗?"有,就告诉传播——这是 innerWrite

第 4 步:改自动反应(effect)

run 要戴/摘"正在跑"的章,还要记账 runDepth

ts
	const run = (): void => {
		node.flags = ReactiveFlags.Watching | ReactiveFlags.RecursedCheck;   // 戴上"正在跑"的章
		++runDepth;                            // 记账:现在有节点在跑
		purgeAllDeps(node);                    // ① 先清掉旧订阅
		const prev = activeSub;
		activeSub = node;                      // ② 举起耳朵
		try {
			node.fn();                         // ③ 跑一遍——读到的信号都会连上我
		} finally {
			--runDepth;                        // 跑完,把账销掉
			activeSub = prev;                  // ④ 把耳朵还回去
			node.flags &= ~ReactiveFlags.RecursedCheck;   // 摘掉"正在跑"的章
		}
	};

关键:戴章要赶在跑之前,摘章要等跑完之后——这样跑的过程中被盖的"递归记号"才能留下来。

第 5 步:改重算(update)

中转站的 update 也一样——跑之前戴章,跑完摘章。找到计算值分支,把结尾的"恢复干净"改成"跑前戴章、跑完摘章":

ts
	if ('getter' in node) {
		// —— 计算值:重算 ——
		const oldValue = node.value;
		purgeAllDeps(node);                  // ① 清掉旧订阅
		node.flags = ReactiveFlags.Mutable | ReactiveFlags.RecursedCheck;   // 戴上"正在跑"的章
		const prev = activeSub;
		activeSub = node;                    // ② 举起耳朵
		try {
			node.value = node.getter();      // ③ 按规则算!
			node.hasValue = true;
		} finally {
			activeSub = prev;                // ④ 把耳朵还回去
			node.flags &= ~ReactiveFlags.RecursedCheck;   // 摘掉"正在跑"的章
		}
		const changed = oldValue !== node.value;
		if (changed && node.subs !== undefined) {
			shallowPropagate(node.subs);     // 真变了:给听众升级"过期"章
		}
		return changed;
	}

注意:别再把章一把抹掉

以前 update 跑完是"直接恢复成 Mutable"——那会把跑的过程中盖的递归记号也抹掉!现在改成"跑前戴章、跑完只摘'正在跑'"——递归记号留下来,下一轮传播才能认得它。

第 6 步:写新测试(自己改自己)

tests 文件夹里新建 recursion.spec.ts

ts
import { describe, expect, test } from 'vitest';
import { signal, effect, getActiveSub, ReactiveFlags } from '../src/signal';

describe('防重复传播:自己改自己', () => {
	test('默认防自追:订报人跑着改了自己读的消息,只跑一遍就收手', () => {
		const count = signal(0);
		let runs = 0;
		effect(() => {
			runs++;
			count(count() + 1);   // 读自己,又写自己
		});
		expect(runs).toBe(1);     // 没有被追着跑!
		expect(count()).toBe(1);  // 消息确实改了

		count(5);                 // 外来的写,还能叫醒它吗?
		expect(runs).toBe(2);     // 能!
		expect(count()).toBe(6);  // (它又自己 +1 了一次)
	});

	test('摘掉记号 + 设上限:可以"有上限地追自己"', () => {
		const count = signal(0);
		let runs = 0;
		effect(() => {
			getActiveSub()!.flags &= ~ReactiveFlags.RecursedCheck;   // 自己摘掉"正在跑"的章
			runs++;
			if (count() < 3) {
				count(count() + 1);
			}
		});
		expect(runs).toBe(4);     // 0 → 1 → 2 → 3,追到自己设的界限就收手
		expect(count()).toBe(3);
	});
});

第一个测试:订报人读自己又写自己——默认只跑一遍(邮差认出了"正在跑的自己",把循环掐断),而且外来的写还能正常叫醒它。第二个测试:用 getActiveSub 自己摘掉"正在跑"的章,配上 count() < 3 的上限——有上限地追自己,追到界限就收手。

第 7 步:叫检查员!

bash
npm test

🚀 现在跑一下,你应该看到

✓ tests/recursion.spec.ts (2 tests)
✓ 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  9 passed (9)
     Tests  25 passed (25)

25 个,全绿。 消息城最狡猾的对手——自己改自己——被两面旗治得服服帖帖。🎉

🧰 TS 小课堂:六面旗全集

到这里,消息城的六面旗全部到齐了!来认识一下全家福:

谁戴它一句话
None0——什么都没有
Mutable1信号、计算值我是会变化的消息
Watching2自动反应我在听消息
RecursedCheck4跑着的节点我正在忙,别追着通知我
Recursed8跑着时挨过写的节点我跑着的时候被写过
Dirty16有新消息没消费的信号过期了
Pending32刚被通知过的节点待核实

None = 0 不算旗,是"没戴旗"的意思。)

为什么值都是 1、2、4、8、16、32?因为每个值只占二进制的一位——用 | 随便拼、用 & 单独查、用 &= ~ 单独摘,谁也不会干扰谁。这是第 8 章就埋下的伏笔,今天六面旗全到,这套"插旗、查旗、摘旗"的本事也齐了。

📚 消息城词典

意思记住它
正在跑 (RecursedCheck)节点正在执行自己的规则/函数邮差看到它就不追着通知
递归记号 (Recursed)节点跑着的时候被写过下轮传播摘掉它、重新通知
innerWrite"这次写发生在正在跑的节点里"跑着写的,要加递归记号
自己追自己订报人读自己又写自己没有旗会死循环

这两个词(正在跑/递归记号)是正式消息城术语,已同步进附录·消息城词典

📦 章末完整代码

这一章你的 signal-book 文件夹长这样(signal.ts 改了三处 + 一个新测试文件):

signal-book/
├── demo/
│   └── ch01.mjs
├── node_modules/
├── package.json
├── tsconfig.json
├── src/
│   ├── money.ts
│   └── signal.ts      ← 改造:六面旗 + 完整传播 + 正在跑/递归记号
└── 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
    └── recursion.spec.ts   ← 今天的新测试

src/signal.ts其他部分和第 12 章"章末完整代码"里那份一样,今天改了四处:

  1. ReactiveFlags 变成六面旗(多了 RecursedCheck = 4Recursed = 8),并且导出
  2. 全局多了 runDepth 和导出的 getActiveSub
  3. propagate 升级成完整状态机(第 2 步那份),signalwriterunDepth > 0
  4. effectrunupdate 的计算值分支,戴上/摘掉"正在跑"的章(第 4、5 步那份)。

tests/recursion.spec.ts 的完整内容,就是上面"第 6 步"抄的那份——抄全了就不用再看。

跑通了? 只要 npm test 打出 25 passed,消息城最后两面旗就位——自己改自己,也翻不出浪花了

🎮 动手试试

怎么玩

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

1. 预言输出(猜一猜)

小铃写了一个"自己改自己、还没设上限"的订报人,然后又从外面写了一次。猜猜 runsx() 最后是多少?

ts
const x = signal(0);
let runs = 0;
effect(() => {
	runs++;
	x(x() + 1);   // 读自己,又写自己(没设上限!)
});

x(10);   // 外来的写
👉 点开看答案

runs2x()11

  • 注册时跑一遍(runs=1):x(1) 写下去,可传播认出"正在跑的自己"——只盖递归记号,不叫醒,循环当场掐断;
  • x(10) 外来的写:传播看到它带着递归记号(没在跑),摘掉记号、重新盖章、叫醒它(runs=2);
  • 它又跑一遍:x()=10,写 x(11)——又认出"正在跑的自己",吞掉。

默认的旗防的是死循环,不是防更新——外来的写,照样能叫醒它。

2. 帮小铃找 bug(抓错)

小铃写第 4 步时,把"戴上'正在跑'的章"那行忘了:

ts
	const run = (): void => {
		// 咦?戴"正在跑"的章呢?
		++runDepth;
		// ……
	};

结果:跑 npm test 时,recursion.spec.ts 第一个测试卡住不动了(像是死循环)。为什么?

👉 点开看答案

因为没有"正在跑"的章,邮差认不出正在跑的自己

订报人写 count 时,传播沿听众链走,看到它干干净净(没有 RecursedCheck)——走分支①:盖章、排队、叫醒!它又跑一遍,又写一遍……追着自己跑,永远停不下来

runDepth 只是记账(innerWrite 用),真正拦住循环的是"正在跑"的章。记账和戴章,缺一不可。

3. 填空(补全)

分支 ④(正在跑)的关键一行少了点什么。补全它:

ts
		} else if (!(flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending)) && isValidLink(l, sub)) {
			sub.flags = flags | /* 这里该写什么? */;
			flags &= ReactiveFlags.Mutable;
		}
👉 点开看答案

写:

ts
			sub.flags = flags | ReactiveFlags.Recursed | ReactiveFlags.Pending;

正在跑的订报人被盖上递归记号 + 待核实章,但紧接着 flags &= Mutable——对于订报人(不是 Mutable),flags 变成 0:不排队、不叫醒。记号留下了,循环掐断了。

4. 小挑战(有上限地追自己)

getActiveSub 摘掉"正在跑"的章,让 count 从 0 一路自己加到 10 然后停下。写完数一数 runs 是多少。

(提示:规则里加一个上限判断,追到界限就收手——不然又死循环了!)

👉 点开看答案

一种写法:

ts
const count = signal(0);
let runs = 0;
effect(() => {
	getActiveSub()!.flags &= ~ReactiveFlags.RecursedCheck;   // 摘掉"正在跑"的章
	runs++;
	if (count() < 10) {
		count(count() + 1);   // 追自己,但有上限
	}
});
expect(runs).toBe(11);   // 初始 1 次 + 0→10 共 10 次
expect(count()).toBe(10);

runs11:注册跑 1 次,然后 0→1、1→2……9→10 共 10 次,到 10 就不再写了。摘记号 = 允许追自己;上限 = 追到就停——两样一起用,才安全。

🏁 本章小结

  1. 消息城补上最后两面旗:正在跑(RecursedCheck)和递归记号(Recursed)——六面旗全部到齐;
  2. 传播升级成完整状态机,认得出"正在跑的自己":只盖递归记号、不叫醒——自己改自己,循环当场掐断;
  3. 想故意追自己?用 getActiveSub 摘掉"正在跑"的章,配上上限——有界地递归,安全又好玩。

六面旗齐了,状态机完整了。老墨在第四张图纸上写下的标题,让小铃想起了第 7 章的老朋友:

期号 version:连接线复用——每次都重新连线太慢,认得出'这线还用得着'吗?

下一章,回去找那根连接线——给它贴上期号