Appearance
第 8 章 传播:propagate
📖 邮差的盖章法
连接线造好的第二天,小铃一大早就蹲在老墨办公室门口。她心里惦记着那两张诊断照片——重复通知、印错价格,病还都没治呢。
老墨推开门,手里拎着一串黄铜的章:"今天,教你消息城的邮差怎么干活。"
"邮差?"
"对。我们现在的通知方式,是'先把听众名单抄一遍,再挨个去喊'——又慢又容易乱。真正的邮差不是这么干的。"老墨把章"啪"地按在桌上,"他是边送边盖章的:每敲开一家门,就盖一个'待核实'章。盖过章的,这趟就不再敲了——省得同一户人家被敲三遍门。"
小铃眼睛一亮:"那个章……不就是'你已经收到过通知了'的记号?"
"聪明。这就是为什么怪病一(重复通知)治得好——同一轮送信里,订报人只会被盖一次章。"
"那怪病二(印错价格)呢?"
"问得好。"老墨在桌上摆了三块积木,一块标着"面粉",一块标着"中转站",一块标着"订报人","邮差的规矩是:路过中转站,先盖个'待核实'章,不急着算。等订报人醒过来、去读中转站时,中转站才发现'咦,我盖着章呢'——被问到,才当场算。所以订报人读到的一定是新消息,不会再读到旧的。"
"所以,"小铃慢慢地说,"盖章治'重复',被问到才算治'顺序'……"
"没错。这套'边走边盖章、被问到才开算'的本事,有个名字——传播(propagate)。今天,我们把它造出来,然后把那两张诊断照片,翻新成健康证明。"
🎯 本章要解决什么
一句话问题:怎么让通知"不重不漏、顺序正确"?——发明传播(propagate)。
读完这一章,你会知道:
- 消息城的三个标志:可变(Mutable)、盯着(Watching)、待核实(Pending);
- 传播的完整流程:盖章去重 → 订报人排队 → 中转站被盖"待核实"章、不急着算;
- 两个怪病被当场治好的那一刻——翻新诊断照片!
💡 概念讲解
三个标志(小旗子)
从今天起,每个节点都带一面小旗子(flags),告诉邮差"我现在是什么状态"。我们先用三个:
| 标志 | 代码 | 谁有它 | 意思 |
|---|---|---|---|
| 可变 | Mutable | 信号、计算值 | 它是会变化的消息(不是订报人) |
| 盯着 | Watching | 自动反应 | 它在听消息,消息一变它要跑 |
| 待核实 | Pending | 传播时临时盖 | 这轮已经通知过它了,别再打扰 |
还有第四个"过期(Dirty)"的章,今天先认识它——它是第 9 章的伏笔,等中转站学会"先核实"才用得上。
传播的完整流程
邮差从消息源出发,沿着听众链一站一站走:
mermaid
graph LR
start["消息变了 📣"] --> walk["沿着听众链走"]
walk --> check{"盖过章了吗?<br/>(已是待核实)"}
check -- "盖过了,跳过" --> next["继续下一站"]
check -- "没盖过" --> stamp["盖'待核实'章"]
stamp --> who{"它是什么?"}
who -- "自动反应" --> queue["排队等跑 🧒"]
who -- "计算值" --> down["盖完章,继续往它的听众走 🧮"]
queue --> next
down --> next一句话总结邮差的三条规矩:
- 盖过章的不再打扰(治重复通知);
- 订报人排队等跑,最后一起发刊;
- 中转站不急着算,被问到才重算(治顺序错乱——订报人读到的,一定是最新算好的)。
为什么这就治好了两个怪病
- 重复通知:两个中转站都依赖同一个信号,信号一变,传播会路过两个中转站、各自往下传——可订报人第一次被盖章后,再被路过就跳过了。排队名单里,它只有一次。
- 顺序错乱:订报人排在最后跑。它读到中转站时,中转站会被问到才重算——它读到的,一定是新消息。
✍️ 动手写代码
老规矩,在 src/signal.ts 上动手术。这一章要改四处:节点加小旗子、写传播、改通知方式、改攒批。
第 1 步:给小旗子编号(枚举)
在文件最上面(ReactiveNode 图纸之前),加一个标志清单:
ts
// 小旗子(标志):告诉邮差"我现在是什么状态"
const enum ReactiveFlags {
None = 0, // 什么都没有(现在不用)
Mutable = 1, // 可变:我是会变化的消息(信号、计算值)
Watching = 2, // 盯着:我在听消息,消息一变我要跑(自动反应)
Dirty = 16, // 过期:需要重新算(第 9 章的伏笔,今天不用)
Pending = 32, // 待核实:这轮已经通知过我了,别再打扰
}const enum 是 TypeScript 的枚举——TS 小课堂会细讲。现在知道"每个名字背后是一个数字"就够了。
然后在 ReactiveNode 图纸里加一个 flags 字段:
ts
interface ReactiveNode {
deps?: Link; // 我的依赖链(链头)
depsTail?: Link; // 我的依赖链(链尾)
subs?: Link; // 我的听众链(链头)
subsTail?: Link; // 我的听众链(链尾)
flags: ReactiveFlags; // 我的小旗子
}第 2 步:给三种节点插上小旗子
SignalNode 和 ComputedNode 都是"可变"(Mutable),EffectNode 是"盯着"(Watching):
ts
interface SignalNode extends ReactiveNode {
value: unknown;
}
interface EffectNode extends ReactiveNode {
fn: () => void;
run: () => void;
}
interface ComputedNode extends ReactiveNode {
getter: () => unknown;
value: unknown;
hasValue: boolean;
}(注意:ComputedNode 的 run 字段删掉了——这一章重算的事搬给 update 统一管,计算值不再自己带"跑"的本事。)
建节点的时候,别忘了插旗:
- 信号:
flags: ReactiveFlags.Mutable - 计算值:
flags: ReactiveFlags.Mutable - 自动反应:
flags: ReactiveFlags.Watching
第 3 步:写传播(propagate)和重算(update)
在"连线和摘线"后面,加两大块。先加传播——邮差的核心。注意:传播只管盖章和排队,不重算——重算是中转站被问到时才做的事:
ts
// —— 传播和重算 ——
// 传播:消息变了,沿着听众链一站一站走,边盖章边下传
function propagate(link: Link): void {
let l: Link | undefined = link;
while (l !== undefined) {
const next: Link | undefined = l.nextSub; // 先记住下一站——敲门时换门牌也不迷路
const sub = 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) {
// 计算值:不急着算!盖完章,继续往它的听众走
if (sub.subs !== undefined) {
propagate(sub.subs); // 继续往它的听众走
}
}
}
l = next; // 走下一站
}
}然后加重算——它把计算值"跑一遍"的活搬了过来,还多一样本领:算完告诉你"值变了吗"(第 9 章会用到这个答案):
ts
// 重算:把计算值按规则重新算一遍,返回"值变了吗"
function update(node: ComputedNode): boolean {
const oldValue = node.value;
purgeAllDeps(node); // ① 清掉旧订阅
const prev = activeSub;
activeSub = node; // ② 举起耳朵
try {
node.value = node.getter(); // ③ 按规则算!读到的信号都会连上我
node.hasValue = true;
} finally {
activeSub = prev; // ④ 把耳朵还回去
}
node.flags = ReactiveFlags.Mutable; // ⑤ 算完了,恢复"干净"
return oldValue !== node.value; // ⑥ 值变了吗?
}update 的返回值(变了吗)这一章先收着——第 9 章造"核实"的时候,它会派上大用场。
第 4 步:通知排队(notify)和发刊(flush)
加两个全局状态(把攒批的 pending 本子删掉,换成排队名单):
ts
// 排队名单:被通知的自动反应,等着发刊时跑
const queued: (EffectNode | undefined)[] = [];
let notifyIndex = 0;
// 发通知:把自动反应记进排队名单
function notify(effect: EffectNode): void {
queued.push(effect);
}
// 发刊:把排队名单里的自动反应挨个跑一遍
function flush(): void {
while (notifyIndex < queued.length) {
const effect = queued[notifyIndex]!;
queued[notifyIndex++] = undefined;
effect.run();
}
notifyIndex = 0;
queued.length = 0;
}queued 是"排队名单",notify 是"叫号",flush 是"发刊":把名单上的订报人挨个叫起来跑,跑完清空名单。
第 5 步:改信号(signal)
write 有三处变化:值没变就不通知、通知改成传播、攒批结束时才发刊:
ts
const write = (next: T): void => {
if (node.value === next) {
return; // 消息没变?那就不发通知
}
node.value = next;
if (node.subs !== undefined) {
propagate(node.subs); // 传播:盖章、排队、重算中转站
}
if (batchDepth === 0) {
flush(); // 没在攒批?立刻发刊
}
};原来的"抄名单再喊"整个删掉——那是旧邮差的活法。
第 6 步:改自动反应(effect)
run 里加一件事:跑之前,重新戴上"盯着"章(因为传播时它被盖了"待核实"章,跑完要恢复):
ts
const run = (): void => {
node.flags = ReactiveFlags.Watching; // 重新戴上"盯着"章
purgeAllDeps(node); // ① 先清掉旧订阅
const prev = activeSub;
activeSub = node; // ② 举起耳朵
try {
node.fn(); // ③ 跑一遍——读到的信号都会连上我
} finally {
activeSub = prev; // ④ 把耳朵还回去
}
};第 6 步:改计算值(computed)
computed 是这一章变化最大的地方:被问到(被读到)才重算。read 里多了一个判断——盖着"待核实"章的,就要算一遍:
ts
const read = (): T => {
if (activeSub !== undefined) {
link(node, activeSub); // 记一笔:这位在听我
}
if (!node.hasValue || (node.flags & ReactiveFlags.Pending)) {
update(node); // 第一次被读 / 被问到:算一遍
}
return node.value as T;
};这正是"懒"的开始:没人读它,它就躺着——哪怕传播给它盖了"待核实"章,它也不起身。只有被读到的那一刻,它才爬起来算。
第 8 步:改攒批(batch)
pending 本子已经删了。攒批现在只是"把发刊推迟":传播照常盖章、排队,但 flush 要等最外层的 endBatch 才喊:
ts
export function startBatch(): void {
batchDepth++;
}
export function endBatch(): void {
batchDepth--;
if (batchDepth === 0) {
flush(); // 最外层结束,才发刊
}
}第 9 步:翻新诊断照片!(大时刻)
还记得第 6 章的两张诊断照片吗?它们记录的是旧毛病。现在传播治好了病,该翻新了——打开 tests/naive-problems.spec.ts,把期望改成健康的标准:
ts
describe('健康证明一:不再重复通知', () => {
test('一个变化让两个中转站都转发,订报人只跑一遍', () => {
let runs = 0;
const a = signal(2);
const c1 = computed(() => a() * 2);
const c2 = computed(() => a() * 3);
effect(() => {
runs++;
c1();
c2();
});
expect(runs).toBe(1);
a(5);
expect(runs).toBe(2); // 翻新!以前是 3(跑两遍),现在 2(只跑一遍)
});
});
describe('健康证明二:顺序不再错乱', () => {
test('发刊时订报人读到的一定是新消息', () => {
const logs: number[] = [];
const a = signal(1);
const b = signal(1);
const c = computed(() => a() * 10);
effect(() => {
logs.push(b() + c());
});
expect(logs).toEqual([11]);
startBatch();
b(2);
a(2);
endBatch();
expect(logs).toEqual([11, 22]); // 翻新!以前是 [11, 12, 22],现在没有那个错的 12 了
});
});这就是翻新照片
第 6 章我们说"红着的测试,就是还没做完的手术"。现在手术做完了——把期望改成健康标准,它们当场变绿。
两张照片,从"病历"变成了"健康证明"。
第 10 步:叫检查员!
bash
npm test🚀 现在跑一下,你应该看到:
✓ 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 6 passed (6) Tests 19 passed (19)还是 19 个——但这次,怪病一和怪病二已经治好了!
🧰 TS 小课堂:枚举 enum
今天的新语法是 const enum——枚举。
ts
const enum ReactiveFlags {
None = 0, // 什么都没有
Mutable = 1, // 可变
Watching = 2, // 盯着
Dirty = 16, // 过期
Pending = 32, // 待核实
}枚举是"名字和数字的对照表":人看名字(ReactiveFlags.Pending),电脑看数字(32)。
ts
sub.flags = flags | ReactiveFlags.Pending; // 人读:盖"待核实"章
// 电脑看到:sub.flags = flags | 32;还有那个神秘的 |——它是"或"运算,作用是把两面小旗子同时插上。小旗子用的都是"独占一位"的数字(1、2、16、32……),所以用 | 拼在一起不会弄混:
ts
flags | ReactiveFlags.Pending // 原来的旗子 + 新的"待核实"旗
flags & ReactiveFlags.Pending // 检查:"待核实"旗插着吗?(& 是"与")一个数,能同时记住好几面旗子——这就是为什么小旗子要用 1、2、16、32 这种"各自占一位"的数字,而不是 1、2、3、4。
📚 消息城词典
| 词 | 意思 | 记住它 |
|---|---|---|
| 传播 (propagate) | 消息一变,沿着听众链盖章、排队、重算、下传 | 邮差边送边盖章 |
| 待核实 (Pending) | 这轮已经通知过它了 | 盖过章的门,不再敲第二遍 |
| 盯着 (Watching) | 自动反应在听消息 | 消息一变它要跑 |
| 可变 (Mutable) | 信号/计算值是会变化的消息 | 不是订报人 |
| 发刊 (flush) | 把排队名单上的自动反应挨个跑一遍 | 攒批结束时才喊 |
| 重算 (update) | 把计算值重新算一遍,返回"变了吗" | 中转站被问到才会调它 |
📦 章末完整代码
这一章你的 signal-book 文件夹长这样(没有新文件,但两个文件被改造了):
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.tssrc/signal.ts 完整内容(第 8 章版):
ts
// src/signal.ts —— 传播版信号库(第 8 章)
// 小旗子(标志):告诉邮差"我现在是什么状态"
const enum ReactiveFlags {
None = 0, // 什么都没有(现在不用)
Mutable = 1, // 可变:我是会变化的消息(信号、计算值)
Watching = 2, // 盯着:我在听消息,消息一变我要跑(自动反应)
Dirty = 16, // 过期:需要重新算(第 9 章的伏笔,今天不用)
Pending = 32, // 待核实:这轮已经通知过我了,别再打扰
}
// 节点:消息城里的一切(公告牌、中转站、订报人)都是节点
interface ReactiveNode {
deps?: Link; // 我的依赖链(链头)
depsTail?: Link; // 我的依赖链(链尾)
subs?: Link; // 我的听众链(链头)
subsTail?: Link; // 我的听众链(链尾)
flags: ReactiveFlags; // 我的小旗子
}
// 连接线:一笔订阅
interface Link {
dep: ReactiveNode; // 被依赖的(消息源)
sub: ReactiveNode; // 依赖的(订阅者)
prevDep?: Link; // 依赖链上的前一根线
nextDep?: Link; // 依赖链上的后一根线
prevSub?: Link; // 听众链上的前一根线
nextSub?: Link; // 听众链上的后一根线
}
// 信号节点:公告牌
interface SignalNode extends ReactiveNode {
value: unknown;
}
// 自动反应节点:订报人
interface EffectNode extends ReactiveNode {
fn: () => void;
run: () => void;
}
// 计算值节点:中转站
interface ComputedNode extends ReactiveNode {
getter: () => unknown;
value: unknown;
hasValue: boolean;
}
// 耳朵:现在正在跑的是谁?
let activeSub: ReactiveNode | undefined;
// 攒批深度:现在攒了几层?
let batchDepth = 0;
// 排队名单:被通知的自动反应,等着发刊时跑
const queued: (EffectNode | undefined)[] = [];
let notifyIndex = 0;
// —— 连线和摘线 ——
function link(dep: ReactiveNode, sub: ReactiveNode): void {
// 先沿着依赖链检查:这根线是不是已经连过了?
for (let l = sub.deps; l !== undefined; l = l.nextDep) {
if (l.dep === dep) {
return; // 已经连过了,别重复连
}
}
const newLink: Link = {
dep,
sub,
prevDep: sub.depsTail,
nextDep: undefined,
prevSub: dep.subsTail,
nextSub: undefined,
};
// 接进 sub 的依赖链(挂在链尾)
if (sub.depsTail !== undefined) {
sub.depsTail.nextDep = newLink;
} else {
sub.deps = newLink;
}
sub.depsTail = newLink;
// 接进 dep 的听众链(挂在链尾)
if (dep.subsTail !== undefined) {
dep.subsTail.nextSub = newLink;
} else {
dep.subs = newLink;
}
dep.subsTail = newLink;
}
function unlink(link: Link): void {
const { dep, sub, prevDep, nextDep, prevSub, nextSub } = link;
// 从 sub 的依赖链上摘下来
if (prevDep !== undefined) {
prevDep.nextDep = nextDep;
} else {
sub.deps = nextDep;
}
if (nextDep !== undefined) {
nextDep.prevDep = prevDep;
} else {
sub.depsTail = prevDep;
}
// 从 dep 的听众链上摘下来
if (prevSub !== undefined) {
prevSub.nextSub = nextSub;
} else {
dep.subs = nextSub;
}
if (nextSub !== undefined) {
nextSub.prevSub = prevSub;
} else {
dep.subsTail = prevSub;
}
}
// 清空一个节点的所有订阅(从链尾往前摘)
function purgeAllDeps(sub: ReactiveNode): void {
let l = sub.depsTail;
while (l !== undefined) {
const prev = l.prevDep;
unlink(l);
l = prev;
}
}
// —— 传播和重算 ——
// 传播:消息变了,沿着听众链一站一站走,边盖章边下传
function propagate(link: Link): void {
let l: Link | undefined = link;
while (l !== undefined) {
const next: Link | undefined = l.nextSub; // 先记住下一站——敲门时换门牌也不迷路
const sub = 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) {
// 计算值:不急着算!盖完章,继续往它的听众走
if (sub.subs !== undefined) {
propagate(sub.subs); // 继续往它的听众走
}
}
}
l = next; // 走下一站
}
}
// 重算:把计算值按规则重新算一遍,返回"值变了吗"
function update(node: ComputedNode): boolean {
const oldValue = node.value;
purgeAllDeps(node); // ① 清掉旧订阅
const prev = activeSub;
activeSub = node; // ② 举起耳朵
try {
node.value = node.getter(); // ③ 按规则算!读到的信号都会连上我
node.hasValue = true;
} finally {
activeSub = prev; // ④ 把耳朵还回去
}
node.flags = ReactiveFlags.Mutable; // ⑤ 算完了,恢复"干净"
return oldValue !== node.value; // ⑥ 值变了吗?
}
// 发通知:把自动反应记进排队名单
function notify(effect: EffectNode): void {
queued.push(effect);
}
// 发刊:把排队名单里的自动反应挨个跑一遍
function flush(): void {
while (notifyIndex < queued.length) {
const effect = queued[notifyIndex]!;
queued[notifyIndex++] = undefined;
effect.run();
}
notifyIndex = 0;
queued.length = 0;
}
// —— 信号 ——
export function signal<T>(initialValue: T) {
const node: SignalNode = {
value: initialValue,
flags: ReactiveFlags.Mutable,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
};
const read = (): T => {
if (activeSub !== undefined) {
link(node, activeSub); // 记一笔:这位在听我
}
return node.value as T;
};
const write = (next: T): void => {
if (node.value === next) {
return; // 消息没变?那就不发通知
}
node.value = next;
if (node.subs !== undefined) {
propagate(node.subs); // 传播:盖章、排队、重算中转站
}
if (batchDepth === 0) {
flush(); // 没在攒批?立刻发刊
}
};
const signalFn = function (next?: T): T {
if (arguments.length > 0) {
write(next as T);
return node.value as T;
}
return read();
};
return signalFn;
}
// —— 自动反应 ——
export function effect(fn: () => void): void {
const node: EffectNode = {
fn,
flags: ReactiveFlags.Watching,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
run: () => {},
};
const run = (): void => {
node.flags = ReactiveFlags.Watching; // 重新戴上"盯着"章
purgeAllDeps(node); // ① 先清掉旧订阅
const prev = activeSub;
activeSub = node; // ② 举起耳朵
try {
node.fn(); // ③ 跑一遍——读到的信号都会连上我
} finally {
activeSub = prev; // ④ 把耳朵还回去
}
};
node.run = run;
run(); // 注册就立刻跑一遍(先送一期报纸)
}
// —— 计算值 ——
export function computed<T>(getter: () => T): () => T {
const node: ComputedNode = {
getter: getter as () => unknown,
value: undefined,
hasValue: false,
flags: ReactiveFlags.Mutable,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
};
const read = (): T => {
if (activeSub !== undefined) {
link(node, activeSub); // 记一笔:这位在听我
}
if (!node.hasValue || (node.flags & ReactiveFlags.Pending)) {
update(node); // 第一次被读 / 被问到:算一遍
}
return node.value as T;
};
return read;
}
// —— 攒批 ——
export function startBatch(): void {
batchDepth++;
}
export function endBatch(): void {
batchDepth--;
if (batchDepth === 0) {
flush(); // 最外层结束,才发刊
}
}tests/naive-problems.spec.ts 的完整内容,就是上面"第 9 步"翻新后的那份——抄全了就不用再看。
✅ 跑通了? 只要
npm test打出19 passed,两个怪病就正式治好了。心脏手术的第二刀,缝合完毕!
🎮 动手试试
怎么玩
先自己写答案,再点开对照。忍得住才点开哦!
1. 预言输出(猜一猜)
小铃跑了一段代码,猜猜 logs 最后长什么样?
ts
const logs: number[] = [];
const a = signal(1);
const b = signal(10);
const sum = computed(() => a() + b());
effect(() => {
logs.push(sum());
});
startBatch();
a(2);
b(20);
endBatch();
logs.push(sum());👉 点开看答案
logs 是 [11, 22, 22]。
- 注册时:
sum第一次算 = 1 + 10 = 11,推入 11; - 攒批里
a(2):传播路过sum,盖"待核实"章,继续往下传,订报人排队; - 攒批里
b(20):传播又路过sum——它已经盖过章了,跳过; endBatch发刊:订报人跑一遍——读到sum,sum才发现自己盖着章,被问到才重算(22),推入 22;- 最后
sum():还是 22,推入 22。
注意:sum 在攒批里一次都没算——直到发刊、订报人读它,它才被问到、算了一遍(22)。中间那个 12 根本不存在。谁也没白算,谁也没白跑。
2. 帮小铃找 bug(抓错)
小铃把 update 的最后一行删了:
ts
node.flags = ReactiveFlags.Mutable; // 算完了,恢复"干净"——被她删了!结果:信号变第一次,订报人跑得好好的;信号变第二次,订报人不跑了。为什么?
👉 点开看答案
因为"待核实"章盖上去就再也没摘掉。
第一次传播:中转站被盖"待核实"章 → 重算 → 往下传,订报人跑了一遍。可中转站的章没摘,它永远带着"待核实"。
第二次传播再路过它:!(flags & Pending) 是假的——"盖过章了,跳过"——它既不重算,也不往下传。订报人自然再也不跑了。
update 最后恢复"干净"(Mutable),是让中转站准备好迎接下一次传播。这一行,删不得。
3. 填空(补全)
propagate 里"盖过章的就跳过"的判断少了一部分,补全它:
ts
if (!(flags & /* 这里该写什么? */)) {
sub.flags = flags | ReactiveFlags.Pending;
// ……
}👉 点开看答案
写:
ts
if (!(flags & (ReactiveFlags.Pending | ReactiveFlags.Dirty))) {两种章都要跳过:已经"待核实"(这轮通知过了)的,和已经"过期"(第 9 章会用)的。| 把两面旗子拼在一起检查。
4. 小挑战(数一数)
回到"健康证明一"的场景:一个信号 a,两个计算值 c1、c2 都依赖它,一个订报人同时听 c1 和 c2。
a(5) 这一次变化,数一数:c1 的规则跑了几次?c2 呢?订报人跑了几次?和朴素版比一比,少了多少活?
👉 点开看答案
a(5) 这一次变化里:
c1的规则跑 1 次(订报人读它时,被问到才重算);c2的规则跑 1 次(同上);- 订报人跑 1 次(被盖过一次章,只排一次队)。
朴素版里,订报人被 c1、c2 各转发一遍,跑 2 次。现在只跑 1 次——重复通知治好了,谁也没白干。
用代码验证的话,给 c1、c2 的规则各加一个计数器,a(5) 之后两个计数都只加 1,订报人的 runs 只加 1。
🏁 本章小结
- **传播(propagate)**沿着听众链边走边盖章:盖过"待核实"章的就不再打扰——重复通知治好了;
- 中转站被问到才重算——订报人读到的,一定是最新算好的——顺序错乱治好了;
- 诊断照片翻新成健康证明:红着的测试,真的变绿了——手术的第二刀,缝合完毕。
不过,细心的小铃发现中转站有时候会白算:面粉价涨到 5 又跌回 3,订报人一问,中转站还是老老实实把规则重算了一遍——明明结果根本没变!这不是浪费吗?
"问得好。"老墨说,"第 9 章,我们教中转站先核实——被问到先打电话问依赖们'你们真的变了吗',没变就不重算。这个'先核实再算'的本事,有个专门的名字,叫 checkDirty。"