Skip to content

第 9 章 核实:checkDirty

📖 白算了一整圈

传播上线后的第三天,消息城一切顺利。直到小铃盯着一块价格牌看了半天,发现了一个古怪的现象。

这块牌是蛋糕店的中转站:蛋糕价 = 面粉价 × 3。小铃做了个小实验:

ts
startBatch();
flour(5);   // 面粉涨价到 5
flour(3);   // 又跌回 3
endBatch();

"面粉涨上去又跌回来,蛋糕价应该还是 9 元。"小铃自言自语,"可是订报人一问,中转站还是老老实实把规则重算了一遍——结果算出来还是 9 元,白忙活!"

她跑去跟老墨汇报:"中转站有时候会白算!明明结果根本没变,它还是把整个规则从头算一遍。"

老墨放下茶杯,笑了:"你观察得很准。知道为什么吗?"

"因为……它被盖了'待核实'章,就得重算?"

"对。'待核实'只说'可能有变',可没说'真的变了'。面粉涨上去又跌回来——净变化是零——中转站却不管,照样重算。"老墨在纸上画了一个电话,"所以,我们要教它一件事:被问到的时候,先打电话核实——问依赖们'你们真的变了吗?',真的变了才重算。"

"这个'先核实再算'的本事,"小铃眼睛一亮,"就是你说的 checkDirty!"

"没错。今天这一章,消息城的中转站要学会打电话。"

🎯 本章要解决什么

一句话问题:怎么让中转站先核实、再重算——依赖没真变,就不白算?

读完这一章,你会知道:

  • 信号为什么要有两格消息(新消息 / 旧消息);
  • 过期(Dirty)这个章,到底盖在谁身上、什么时候盖;
  • 核实(checkDirty)怎么沿着依赖链一层一层往下打电话
  • 浅传播(shallowPropagate)这个小帮手是干什么的。

💡 概念讲解

信号的"两格信箱"

要判断"真的变了吗",信号得记住两样东西:

mermaid
graph LR
    box["信号的两格信箱 📮"]
    box --> cur["旧消息 currentValue<br/>上一次被'消费'掉的消息"]
    box --> pen["新消息 pendingValue<br/>最新写进来的消息"]
  • 新消息(pendingValue):每次写(flour(5)),新消息进这一格;
  • 旧消息(currentValue):上一次被消费(被核实过)的消息。

想判断"真的变了吗"?把两格对一对:一样 = 没真变;不一样 = 真变了

面粉 3 → 5 → 3:新消息格是 3,旧消息格还是 3——一样,没真变。白算,可以免了。

过期(Dirty):盖在"有新消息没消费"的信号上

从这一章起,信号一被写(有了新消息),就盖上**过期(Dirty)**的章——意思是:"我这里有新消息还没被消费,来核实我!"

等到有人来核实(update 把它消费掉),才摘掉过期章。

核实(checkDirty):一层一层打电话

中转站被问到(被读到)时,不再直接重算——先沿着依赖链打电话

  1. 打到信号:看它有没有过期章。有 → 让它把新旧消息对一对:真变了 → 重算;没真变 → 不重算;
  2. 打到中转站:它盖着"待核实"章?→ 再往它的依赖打(一层一层往下问)——问到底,真的变了才重算。
mermaid
graph LR
    ask["订报人读到中转站 🧒"] --> check{"它盖着'待核实'章?"}
    check -- "没盖" --> out["直接报出缓存的值"]
    check -- "盖着" --> call["打电话核实 📞"]
    call --> q1{"依赖是信号?"}
    q1 -- "是" --> diff["新旧消息对一对"]
    diff -- "真变了" --> recompute["重算 🧮"]
    diff -- "没变" --> skip["不重算,摘掉章"]
    q1 -- "是计算值" --> deeper["再往它的依赖打电话 🔁"]
    deeper --> q1

浅传播(shallowPropagate):给"待核实"升级

还有一个小帮手:当一个信号真的变了,光告诉当前这个中转站还不够——它所有盖着"待核实"章的听众,都应该升级成"过期"(因为它们的依赖真的变了,被问到就该直接重算,不用再打电话)。

ts
// 浅传播:把一层听众的"待核实"章升级成"过期"章

这个小动作只传一层,所以叫""传播。深的那层,靠核实里的递归去问。

✍️ 动手写代码

今天是"消息城装电话"的一天。要改的地方不少,跟着一步步来。

不想一步步抄?

每一步都有讲解。抄到一半乱了,直接跳"章末完整代码",那里有整份文件。

第 1 步:给信号换"两格信箱"

打开 src/signal.ts。先把 SignalNode 的图纸改掉——value 一格里放不下两样东西,拆成两格:

ts
// 信号节点:公告牌(有两格信箱:旧消息 + 新消息)
interface SignalNode extends ReactiveNode {
	currentValue: unknown;   // 旧消息:上一次被消费的
	pendingValue: unknown;   // 新消息:最新写进来的
}

然后改 signal 函数:建节点时两格都放初始值;只进新消息格(顺便盖"过期"章);报出新消息格(不消费):

ts
export function signal<T>(initialValue: T) {
	const node: SignalNode = {
		currentValue: initialValue,   // 旧消息格
		pendingValue: initialValue,   // 新消息格
		flags: ReactiveFlags.Mutable,
		deps: undefined,
		depsTail: undefined,
		subs: undefined,
		subsTail: undefined,
	};

	const read = (): T => {
		if (activeSub !== undefined) {
			link(node, activeSub);   // 记一笔:这位在听我
		}
		return node.pendingValue as T;   // 报出最新消息(不消费)
	};

	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);         // 传播:盖章、排队
		}
		if (batchDepth === 0) {
			flush();                      // 没在攒批?立刻发刊
		}
	};

	const signalFn = function (next?: T): T {
		if (arguments.length > 0) {
			write(next as T);
			return node.pendingValue as T;
		}
		return read();
	};

	return signalFn;
}

读不消费,是什么意思?

flour() 只把新消息出来,不把旧消息格更新——"消费"(对一对新旧)是核实(update)的活。

这样设计有个大好处:不管订报人先读哪个信号、后读哪个,谁都没偷偷'消费'掉别人的核实证据——顺序乱不了。

第 2 步:让 update 变成"认人的调度员"

update 现在要认两种人:计算值(重算)和信号(对一对新旧消息)。而且无论哪种,真变了就浅传播

ts
// 重算/消费:计算值重算一遍,信号把新旧消息对一对;返回"真变了吗"
function update(node: SignalNode | ComputedNode): boolean {
	if ('getter' in node) {
		// —— 计算值:重算 ——
		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;  // ⑤ 算完恢复"干净"
		const changed = oldValue !== node.value;
		if (changed && node.subs !== undefined) {
			shallowPropagate(node.subs);     // 真变了:给听众升级"过期"章
		}
		return changed;
	}
	// —— 信号:消费新消息,比较新旧 ——
	node.flags = ReactiveFlags.Mutable;      // 摘掉"过期"章
	const changed = node.currentValue !== node.pendingValue;
	node.currentValue = node.pendingValue;   // 旧消息格 = 新消息格(消费掉)
	if (changed && node.subs !== undefined) {
		shallowPropagate(node.subs);         // 真变了:给听众升级"过期"章
	}
	return changed;
}

注意信号分支的关键三行:

ts
const changed = node.currentValue !== node.pendingValue;
node.currentValue = node.pendingValue;
return changed;

读法:"新旧对一对——不一样,说明真变了;对完,旧消息格跟上新消息格。" 面粉 3 → 5 → 3,最后核实的时候新旧都是 3——changedfalse——不重算!这就是"白算"的解药。

第 3 步:写浅传播(shallowPropagate)

update 前面,加一个小帮手——它只做一件事:把听众的"待核实"章升级成"过期"章(不排队、不重算,只传一层):

ts
// 浅传播:给一层听众的"待核实"章升级成"过期"章
function shallowPropagate(link: Link): void {
	let l: Link | undefined = link;
	while (l !== undefined) {
		const sub = l.sub;
		if ((sub.flags & (ReactiveFlags.Pending | ReactiveFlags.Dirty)) === ReactiveFlags.Pending) {
			sub.flags = sub.flags | ReactiveFlags.Dirty;   // 待核实 → 过期
		}
		l = l.nextSub;
	}
}

=== ReactiveFlags.Pending 这个判断很讲究:"待核实"章在、而且"过期"章不在,才升级。已经过期的不用管,干净的也不用管。

第 4 步:写核实(checkDirty)——递归打电话

shallowPropagate 下面,加核实——这一章的主角。它沿着依赖链走,一层一层往下打电话

ts
// 核实:被问到的时候,沿着依赖链挨个问"你们真的变了吗?"
function checkDirty(node: ComputedNode): boolean {
	let l = node.deps;
	while (l !== undefined) {
		const dep = l.dep;
		if (dep.flags & ReactiveFlags.Dirty) {
			// 依赖盖着"过期"章:让它把新旧消息对一对
			if (update(dep as SignalNode | ComputedNode)) {
				return true;        // 真的变了!
			}
		} else if (dep.flags & ReactiveFlags.Pending) {
			// 依赖盖着"待核实"章:先问它的依赖们(递归!)
			if (checkDirty(dep as ComputedNode)) {
				if (update(dep as SignalNode | ComputedNode)) {
					return true;    // 它真的变了!
				}
			} else {
				dep.flags &= ~ReactiveFlags.Pending;   // 它没变,摘掉它的章
			}
		}
		l = l.nextDep;
	}
	return false;   // 谁都没真变——不用重算
}

注意第 11 行:checkDirty(dep as ComputedNode)——核实会打电话给依赖,依赖又打电话给它的依赖……一层一层往下问,这就是递归(TS 小课堂会细讲)。

第 5 步:改计算值的"读"——先核实,再重算

computedread 从"盖着章就算",升级成"先核实,再决定":

ts
	const read = (): T => {
		if (activeSub !== undefined) {
			link(node, activeSub);         // 记一笔:这位在听我
		}
		if (node.flags & ReactiveFlags.Dirty) {
			update(node);                  // 过期了:直接重算
		} else if (node.flags & ReactiveFlags.Pending) {
			// 待核实:先打电话问依赖们真的变了吗
			if (checkDirty(node)) {
				update(node);              // 真变了:重算
			} else {
				node.flags = ReactiveFlags.Mutable;   // 没真变:摘掉"待核实"章
			}
		} else if (!node.hasValue) {
			update(node);                  // 第一次被读:先算一遍
		}
		return node.value as T;
	};

三种情况,三种处理:

中转站的状态处理
盖着过期依赖真的变了,直接重算
盖着待核实打电话核实——真变了才重算,没变就摘章
干干净净报出缓存的值(或第一次读,先算一遍)

第 6 步:写新测试(不白算)

tests 文件夹里新建 checkdirty.spec.ts,测一测新本事:

ts
import { describe, expect, test } from 'vitest';
import { signal, effect, computed, startBatch, endBatch } from '../src/signal';

describe('核实:不白算', () => {
	test('改了又改回去,中转站一次都不白算', () => {
		let runs = 0;
		const flour = signal(3);
		const cake = computed(() => {
			runs++;
			return flour() * 3;
		});
		effect(() => {
			cake();
		});
		expect(runs).toBe(1);   // 注册时算了一次

		startBatch();
		flour(5);   // 涨价
		flour(3);   // 又跌回去——净变化是零!
		endBatch();

		expect(runs).toBe(1);   // 一次都没白算!
		expect(cake()).toBe(9);
	});

	test('核实会一层一层往下问(递归)', () => {
		let cRuns = 0;
		let c2Runs = 0;
		const a = signal(2);
		const c2 = computed(() => {
			c2Runs++;
			return a() * 10;
		});
		const c = computed(() => {
			cRuns++;
			return c2() + 1;
		});
		effect(() => {
			c();
		});
		expect(cRuns).toBe(1);
		expect(c2Runs).toBe(1);

		startBatch();
		a(3);
		a(4);   // 净变化:2 → 4,真的变了
		endBatch();

		expect(c2Runs).toBe(2);   // c2 重算了一次
		expect(cRuns).toBe(2);    // c 也重算了一次
		expect(c()).toBe(41);     // 4 × 10 + 1
	});
});

第一个测试就是小铃发现的"白算":面粉涨上去又跌回来,中转站一次都没重算。第二个测试是"打电话打到最底层":a 真的变了(2→4),c 的核实会一路打到 c2 再打到 a,两层都重算。

第 7 步:叫检查员!

bash
npm test

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

✓ 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  7 passed (7)
     Tests  21 passed (21)

21 个测试全绿!🎉 中转站学会打电话了——谁也没白算

🧰 TS 小课堂:递归(函数调用自己)

这一章没有新语法,但有一个新的思维递归——函数调用自己。

看核实里的这一行:

ts
if (checkDirty(dep as ComputedNode)) {   // checkDirty 在 checkDirty 里

checkDirty 检查依赖,发现依赖也是计算值——于是它打电话给依赖的核实,而那个核实又会打电话给它的依赖的核实……像传话游戏,一层一层往下传,直到传到一个信号为止。

mermaid
graph TD
    c["c 核实 c"] --> c2["c2 核实 c2"]
    c2 --> a["a 信号:新旧对一对 📮"]

递归有两个规矩,缺一不可:

  1. 要能"往下走":每次调用都要处理更小的范围——核实每次往下走一层依赖;
  2. 要有"尽头":走到底(依赖是信号,不是计算值)就不再往下调了——否则会无限套娃、把程序跑崩。

我们的核实走到信号就停(信号没有 checkDirty),尽头天然存在。

第 8 章的伏笔

第 8 章我们说过 update 的返回值"先收着"——现在派上用场了:核实就是靠它判断"依赖真变了吗"。

📚 消息城词典

意思记住它
核实 (checkDirty)被问到先打电话问依赖"真的变了吗"一层一层往下问,真变了才重算
过期 (Dirty)有新消息没消费 / 依赖真变了信号写完盖的章,核实才摘
浅传播 (shallowPropagate)把一层听众的"待核实"升级成"过期"只传一层,不排队不重算
新旧消息信号的 currentValue / pendingValue两格对一对,一样=没真变
消费把新消息格对进旧消息格update 的活,对完才知道变没变

📦 章末完整代码

这一章你的 signal-book 文件夹长这样(没有新文件,但 signal.ts 大改造,tests 加了一个文件):

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   ← 今天的新测试

src/signal.ts 完整内容(第 9 章版):

ts
// src/signal.ts —— 核实版信号库(第 9 章)
// 小旗子(标志):告诉邮差"我现在是什么状态"
const enum ReactiveFlags {
	None = 0,       // 什么都没有(现在不用)
	Mutable = 1,    // 可变:我是会变化的消息(信号、计算值)
	Watching = 2,   // 盯着:我在听消息,消息一变我要跑(自动反应)
	Dirty = 16,     // 过期:有新消息没消费 / 依赖真变了
	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 {
	currentValue: unknown;   // 旧消息:上一次被消费的
	pendingValue: 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 shallowPropagate(link: Link): void {
	let l: Link | undefined = link;
	while (l !== undefined) {
		const sub = l.sub;
		if ((sub.flags & (ReactiveFlags.Pending | ReactiveFlags.Dirty)) === ReactiveFlags.Pending) {
			sub.flags = sub.flags | ReactiveFlags.Dirty;   // 待核实 → 过期
		}
		l = l.nextSub;
	}
}

// 重算/消费:计算值重算一遍,信号把新旧消息对一对;返回"真变了吗"
function update(node: SignalNode | ComputedNode): boolean {
	if ('getter' in node) {
		// —— 计算值:重算 ——
		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;  // ⑤ 算完恢复"干净"
		const changed = oldValue !== node.value;
		if (changed && node.subs !== undefined) {
			shallowPropagate(node.subs);     // 真变了:给听众升级"过期"章
		}
		return changed;
	}
	// —— 信号:消费新消息,比较新旧 ——
	node.flags = ReactiveFlags.Mutable;      // 摘掉"过期"章
	const changed = node.currentValue !== node.pendingValue;
	node.currentValue = node.pendingValue;   // 旧消息格 = 新消息格(消费掉)
	if (changed && node.subs !== undefined) {
		shallowPropagate(node.subs);         // 真变了:给听众升级"过期"章
	}
	return changed;
}

// 核实:被问到的时候,沿着依赖链挨个问"你们真的变了吗?"
function checkDirty(node: ComputedNode): boolean {
	let l = node.deps;
	while (l !== undefined) {
		const dep = l.dep;
		if (dep.flags & ReactiveFlags.Dirty) {
			// 依赖盖着"过期"章:让它把新旧消息对一对
			if (update(dep as SignalNode | ComputedNode)) {
				return true;        // 真的变了!
			}
		} else if (dep.flags & ReactiveFlags.Pending) {
			// 依赖盖着"待核实"章:先问它的依赖们(递归!)
			if (checkDirty(dep as ComputedNode)) {
				if (update(dep as SignalNode | ComputedNode)) {
					return true;    // 它真的变了!
				}
			} else {
				dep.flags &= ~ReactiveFlags.Pending;   // 它没变,摘掉它的章
			}
		}
		l = l.nextDep;
	}
	return false;   // 谁都没真变——不用重算
}

// 发通知:把自动反应记进排队名单
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 = {
		currentValue: initialValue,   // 旧消息格
		pendingValue: initialValue,   // 新消息格
		flags: ReactiveFlags.Mutable,
		deps: undefined,
		depsTail: undefined,
		subs: undefined,
		subsTail: undefined,
	};

	const read = (): T => {
		if (activeSub !== undefined) {
			link(node, activeSub);   // 记一笔:这位在听我
		}
		return node.pendingValue as T;   // 报出最新消息(不消费)
	};

	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);         // 传播:盖章、排队
		}
		if (batchDepth === 0) {
			flush();                      // 没在攒批?立刻发刊
		}
	};

	const signalFn = function (next?: T): T {
		if (arguments.length > 0) {
			write(next as T);
			return node.pendingValue 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.flags & ReactiveFlags.Dirty) {
			update(node);                  // 过期了:直接重算
		} else if (node.flags & ReactiveFlags.Pending) {
			// 待核实:先打电话问依赖们真的变了吗
			if (checkDirty(node)) {
				update(node);              // 真变了:重算
			} else {
				node.flags = ReactiveFlags.Mutable;   // 没真变:摘掉"待核实"章
			}
		} else if (!node.hasValue) {
			update(node);                  // 第一次被读:先算一遍
		}
		return node.value as T;
	};

	return read;
}

// —— 攒批 ——

export function startBatch(): void {
	batchDepth++;
}

export function endBatch(): void {
	batchDepth--;
	if (batchDepth === 0) {
		flush();    // 最外层结束,才发刊
	}
}

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

跑通了? 只要 npm test 打出 21 passed,中转站就学会打电话了——谁也没白算。

🎮 动手试试

怎么玩

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

1. 预言输出(猜一猜)

小铃跑了一段代码,猜猜 runs 最后是几?

ts
let runs = 0;
const x = signal(1);
const y = computed(() => {
	runs++;
	return x() * 2;
});
effect(() => {
	y();
});
startBatch();
x(2);
x(3);
x(1);
endBatch();
👉 点开看答案

runs1

注册时算了一次(x=1,y=2)。攒批里 x 走了 1 → 2 → 3 → 1净变化是零。发刊时订报人读到 y,核实打电话给 x:新旧消息对一对——都是 1,没真变——不重算。runs 从头到尾只有 1。

2. 帮小铃找 bug(抓错)

小铃写"核实"时,把"没变就摘章"的 else 分支忘了:

ts
		} else if (node.flags & ReactiveFlags.Pending) {
			if (checkDirty(node)) {
				update(node);
			}
			// 少了 else 摘章!
		}

结果:第一次"没真变"之后,中转站再也不重算了。为什么?

👉 点开看答案

因为"待核实"章摘不掉了

第一次核实发现没变,可章还盖着。下次信号真的变了、传播路过它——propagate 一看"已经待核实",跳过——它连核实的电话都接不到了,自然永远不重算。

所以"没真变"的那条路必须收尾:node.flags = ReactiveFlags.Mutable;——摘掉章,让它准备好迎接下一轮

3. 填空(补全)

checkDirty 里,"依赖没变就摘掉它的章"少了一行。补全它:

ts
			} else {
				/* 这里该写什么? */
			}
👉 点开看答案

写:

ts
				dep.flags &= ~ReactiveFlags.Pending;

&= 是"与等于":把"待核实"那一面旗子按下去~Pending 是把那一位变成 0)。摘掉章,下次传播才能再盖。

4. 小挑战(造三级链)

小铃搭了一条三层链:a → c1 → c2 → c3 → 订报人(每层都 +1×2 随便你)。数一数:如果 a 在攒批里走了 5 → 2(改回去),c1c2c3 各重算了几次?

写代码验证你的答案(给每层加计数器)。

👉 点开看答案

一次都没重算——三层计数都保持 1(只有注册那次)。

核实会一层一层往下打电话:c3c2c2c1c1a——a 的新旧消息一对:还是 2,没真变。于是章从最底层一层一层摘上去,谁也没重算。这就是递归核实的威力:白算,从源头上就掐断了

验证代码(参考):

ts
let runs1 = 0, runs2 = 0, runs3 = 0;
const a = signal(2);
const c1 = computed(() => { runs1++; return a() * 2; });
const c2 = computed(() => { runs2++; return c1() + 1; });
const c3 = computed(() => { runs3++; return c2() * 10; });
effect(() => { c3(); });

startBatch();
a(5);
a(2);
endBatch();

expect(runs1).toBe(1);
expect(runs2).toBe(1);
expect(runs3).toBe(1);
expect(c3()).toBe(50);

🏁 本章小结

  1. 信号有了两格信箱(新消息/旧消息),"真变了吗"靠两格对一对来判断——**过期(Dirty)**章就盖在有新消息没消费的信号上;
  2. 核实(checkDirty)被问到先沿着依赖链递归打电话:真变了才重算,没变就一层层摘章——白算治好了
  3. **浅传播(shallowPropagate)**把"待核实"升级成"过期",让真变的消息能通知到所有听众。

到这里,心脏手术的三大件——连接线、传播、核实——全部完工。下一章,是手术后的大体检:回归——跑一遍全部测试,再和当初的朴素版比一比,看看我们到底赢在哪。