Skip to content

第 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

一句话总结邮差的三条规矩:

  1. 盖过章的不再打扰(治重复通知);
  2. 订报人排队等跑,最后一起发刊;
  3. 中转站不急着算,被问到才重算(治顺序错乱——订报人读到的,一定是最新算好的)。

为什么这就治好了两个怪病

  • 重复通知:两个中转站都依赖同一个信号,信号一变,传播会路过两个中转站、各自往下传——可订报人第一次被盖章后,再被路过就跳过了。排队名单里,它只有一次。
  • 顺序错乱:订报人排在最后跑。它读到中转站时,中转站会被问到才重算——它读到的,一定是新消息。

✍️ 动手写代码

老规矩,在 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 步:给三种节点插上小旗子

SignalNodeComputedNode 都是"可变"(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;
}

(注意:ComputedNoderun 字段删掉了——这一章重算的事搬给 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.ts

src/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 发刊:订报人跑一遍——读到 sumsum 才发现自己盖着章,被问到才重算(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,两个计算值 c1c2 都依赖它,一个订报人同时听 c1c2

a(5) 这一次变化,数一数:c1 的规则跑了几次c2 呢?订报人跑了几次?和朴素版比一比,少了多少活?

👉 点开看答案

a(5) 这一次变化里:

  • c1 的规则跑 1 次(订报人读它时,被问到才重算);
  • c2 的规则跑 1 次(同上);
  • 订报人跑 1 次(被盖过一次章,只排一次队)。

朴素版里,订报人被 c1c2 各转发一遍,跑 2 次。现在只跑 1 次——重复通知治好了,谁也没白干。

用代码验证的话,给 c1c2 的规则各加一个计数器,a(5) 之后两个计数都只加 1,订报人的 runs 只加 1。

🏁 本章小结

  1. **传播(propagate)**沿着听众链边走边盖章:盖过"待核实"章的就不再打扰——重复通知治好了
  2. 中转站被问到才重算——订报人读到的,一定是最新算好的——顺序错乱治好了
  3. 诊断照片翻新成健康证明:红着的测试,真的变绿了——手术的第二刀,缝合完毕。

不过,细心的小铃发现中转站有时候会白算:面粉价涨到 5 又跌回 3,订报人一问,中转站还是老老实实把规则重算了一遍——明明结果根本没变!这不是浪费吗?

"问得好。"老墨说,"第 9 章,我们教中转站先核实——被问到先打电话问依赖们'你们真的变了吗',没变就不重算。这个'先核实再算'的本事,有个专门的名字,叫 checkDirty。"