Appearance
第 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 小课堂:六面旗全集
到这里,消息城的六面旗全部到齐了!来认识一下全家福:
| 旗 | 值 | 谁戴它 | 一句话 |
|---|---|---|---|
None | 0 | —— | 什么都没有 |
Mutable | 1 | 信号、计算值 | 我是会变化的消息 |
Watching | 2 | 自动反应 | 我在听消息 |
RecursedCheck | 4 | 跑着的节点 | 我正在忙,别追着通知我 |
Recursed | 8 | 跑着时挨过写的节点 | 我跑着的时候被写过 |
Dirty | 16 | 有新消息没消费的信号 | 过期了 |
Pending | 32 | 刚被通知过的节点 | 待核实 |
(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 章"章末完整代码"里那份一样,今天改了四处:
ReactiveFlags变成六面旗(多了RecursedCheck = 4、Recursed = 8),并且导出;- 全局多了
runDepth和导出的getActiveSub; propagate升级成完整状态机(第 2 步那份),signal的write传runDepth > 0;effect的run和update的计算值分支,戴上/摘掉"正在跑"的章(第 4、5 步那份)。
tests/recursion.spec.ts 的完整内容,就是上面"第 6 步"抄的那份——抄全了就不用再看。
✅ 跑通了? 只要
npm test打出25 passed,消息城最后两面旗就位——自己改自己,也翻不出浪花了。
🎮 动手试试
怎么玩
先自己写答案,再点开对照。忍得住才点开哦!
1. 预言输出(猜一猜)
小铃写了一个"自己改自己、还没设上限"的订报人,然后又从外面写了一次。猜猜 runs 和 x() 最后是多少?
ts
const x = signal(0);
let runs = 0;
effect(() => {
runs++;
x(x() + 1); // 读自己,又写自己(没设上限!)
});
x(10); // 外来的写👉 点开看答案
runs 是 2,x() 是 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);runs 是 11:注册跑 1 次,然后 0→1、1→2……9→10 共 10 次,到 10 就不再写了。摘记号 = 允许追自己;上限 = 追到就停——两样一起用,才安全。
🏁 本章小结
- 消息城补上最后两面旗:正在跑(RecursedCheck)和递归记号(Recursed)——六面旗全部到齐;
- 传播升级成完整状态机,认得出"正在跑的自己":只盖递归记号、不叫醒——自己改自己,循环当场掐断;
- 想故意追自己?用
getActiveSub摘掉"正在跑"的章,配上上限——有界地递归,安全又好玩。
六面旗齐了,状态机完整了。老墨在第四张图纸上写下的标题,让小铃想起了第 7 章的老朋友:
期号 version:连接线复用——每次都重新连线太慢,认得出'这线还用得着'吗?
下一章,回去找那根连接线——给它贴上期号。