Skip to content

第 21 章 毕业:跑绿真实库的全部测试

📖 最后的考题

消息城建好了:公告牌、中转站、订报人的一生、订阅小组、手动发刊、引擎和整车——二十章的努力,全都装进了 src/system.tssrc/index.ts

毕业的早上,老墨没有发新的图纸。他递过来一个文件夹,封面写着四个字:最终考验

"打开它。"老墨说。

小铃打开文件夹——里面是真实库(alien-signals)自己的测试

  • conformance.spec.ts——一套全球共享的体检标准reactive-framework-test-suite),任何响应式库都能用它对一对自己;
  • effect.spec.ts——订报人的一生、清理顺序、递归自动反应的刁钻用例;
  • effectScope.spec.ts——订阅小组的解散顺序;
  • trigger.spec.ts——手动发刊的各种边角。

"这些测试,"老墨说,"不是我们写的——是真实库的作者和他的朋友们写的。它们不认得我们的代码,只认得行为。两百条测试,一条一条地考:'你真的和真实库一样吗?'"

"如果我们考不过……"

"那就说明我们还有地方和真实库不一样——那些不一样,就是还没发现的隐藏毛病。考过了,你手里这个库,就是真正的 alien-signals。"

小铃深吸一口气,打开终端:"那就……开始吧。"

🎯 本章要解决什么

一句话问题:把真实库自己的测试搬过来跑——能考出几个隐藏毛病,又能修好几个

读完这一章,你会知道:

  • 怎么把参考库的测试接进自己的项目;
  • 毕业体检考出的7 个隐藏毛病,一个一个修好;
  • 毕业表演:把第 5 章的朴素版请回来,当面比一比。

💡 概念讲解

毕业考是什么

参考库的测试分三部分:

测试文件考什么多少条
conformance.spec.ts全球共享的响应式库体检标准179 条
effect.spec.ts订报人的一生:清理、嵌套、递归10 条
effectScope.spec.ts订阅小组的解散4 条
trigger.spec.ts手动发刊7 条

一共 200 条。它们不关心我们的代码长什么样——只问行为:消息变了订报人醒不醒、清理顺序对不对、订阅会不会漏……一条一条对。

为什么要用别人的测试

自己写的测试,容易"自己考自己"——写的人心里想着实现,写的测试往往正好绕过缺点。别人的测试不一样:它们不知道我们的实现,专挑行为下手。我们的 42 条测试全绿,不代表和真实库一样——只有 200 条外部测试全绿,才算数

(这一章,就是 ADR-0002 里"全量复刻参考库测试"的兑现时刻。)

✍️ 动手写代码

今天是毕业考。三步:接测试 → 考出毛病 → 修好毛病

第 1 步:装体检标准

参考库的 conformance 套件是一个独立包。打开终端,装它:

bash
npm install reactive-framework-test-suite

第 2 步:把参考库的测试搬进来

tests 文件夹里建一个 reference 子文件夹,把参考库的四个测试文件抄进来(它们在 参考/alien-signals/tests/ 里),改一处导入:

  • 所有的 from '../src' 改成 from '../../src/index'
  • effect.spec.ts 里的 import { ReactiveFlags } from '../src/system' 改成 from '../../src/system'

conformance.spec.ts 里那个包着整个测试的"框架适配器",也照抄——它用我们导出的 signal/computed/effect/effectScope/startBatch/endBatch/setActiveSub 把我们的库打扮成标准模样。这些 API 我们全都导出了,一个不缺。)

第 3 步:考一考!(第一次跑)

bash
npm test

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

✓ tests/reference/effectScope.spec.ts (4 tests)
✓ tests/reference/trigger.spec.ts (7 tests)
✗ tests/reference/effect.spec.ts (1 failed)
✗ tests/reference/conformance.spec.ts (6 failed)
...
Tests  193 passed | 7 failed (200)

200 条,7 条红了。 毕业体检,考出了 7 个隐藏毛病——它们一直藏在我们的 42 条测试照不到的角落。别慌,这正是毕业考的意义:考出来的毛病,才是真实库和我们的差距

让我们一条一条看这 7 个毛病,修好它们。

第 4 步:毕业补丁(修好 7 个毛病)

7 个红,其实是 5 个代码毛病(有的毛病被好几条测试同时考出来)。一个个来。

补丁①:摘线忘了更新"听众链的尾巴"(红 3 条:#26、#210、#217)

毛病unlink 摘线时,只更新了听众链的dep.subs),忘了更新尾巴dep.subsTail)——旧线摘掉后,尾巴还指着它。新订阅一来,挂到了这根残留线上,而信号的头部是空的——传播从头部走,一个订报人都找不到。

补丁:打开 src/system.tsunlink,在"从 dep 的听众链上摘下来"后面,补回两行——对称地更新尾巴:

ts
		if (prevSub !== undefined) {
			prevSub.nextSub = nextSub;
		} else if ((dep.subs = nextSub) === undefined) {
			unwatched(dep);   // 没人听它了:问整车怎么办
		}
		if (nextSub !== undefined) {
			nextSub.prevSub = prevSub;
		} else {
			dep.subsTail = prevSub;   // 摘的是尾巴,就更新尾巴
		}

(依赖链那边从头到尾都有对称的两对更新;听众链这边,尾巴那对在第 15 章加"歇业"时被弄丢了——现在补回来。)

补丁②:被辞职的订报人,核实后不该被叫醒重跑(红 1 条:#201)

毛病:订报人 A 在传播途中被辞职(比如中转站的规则里调了它的辞职按钮),可它的 run 还在核实——核实发现"脏了",就把已经辞职的 A 又拉起来跑了一遍

补丁:核实返回"脏"之前,先看看这个节点还活不活着(flags 是不是已经归零):

ts
			if (l === undefined) {
				if (tickets.length === 0) {
					return dirty && !!node.flags;   // 被辞职的节点 flags 归零 → 返回 false
				}

补丁③:中转站重算前,忘了送走旧孩子(红 1 条:effect.spec "旧内层清理要先于新内层建立")

毛病:订报人的一生里,父重跑前先送走孩子(第 15 章)。可中转站的重算(update)没有这一手——它 getter 里建的旧订报人,要等重算完才被清理,比新订报人的出生还晚

补丁src/index.tsupdate 计算值分支,开头加上和订报人一样的"先送孩子":

ts
	if ('getter' in node) {
		// —— 计算值:重算 ——
		if (node.flags & ReactiveFlags.HasChildEffect) {
			// 有孩子:先把上一期的子订报人送走(从链尾往前),旧的清理完才开新工
			let l = node.depsTail;
			while (l !== undefined) {
				const prev = l.prevDep;
				const dep = l.dep;
				if (!('getter' in dep) && !('currentValue' in dep)) {
					unlink(l);   // 摘掉子订报人 → 没人听它了 → 它自己辞职
				}
				l = prev;
			}
		}
		const oldValue = node.value;

补丁④:中转站"自己读自己"会无限递归(红 1 条:#153)

毛病:中转站的规则里如果读了它自己(自依赖),第一次计算会无限套娃——hasValue 还是 false,一读又触发重算,重算又读……直到栈溢出崩溃。

补丁computedread 里,"第一次被读"的条件加一个守卫——正在算的时候,别再算一遍

ts
		} else if (!node.hasValue && !(node.flags & ReactiveFlags.RecursedCheck)) {
			update(node);                  // 第一次被读:先算一遍(正在算的时候,别再算一遍!)
		}

("正在算"的章是第 13 章造的——RecursedCheck。它挡住的不只是自己写自己,还有自己读自己。)

补丁⑤:订报人跑崩时,发刊队列没收尾(红 1 条:#177)

毛病:一个订报人的规则抛错,flush 的循环当场中断——排队的收尾没做(名单没清空)。下次随便哪个信号一写,flush 从断掉的地方续跑,残留的订报人莫名其妙被叫醒。

补丁flush 套上 try / finally——就算跑崩了,也要收尾:剩下的订报人恢复"盯着"章,名单清空:

ts
function flush(): void {
	try {
		while (notifyIndex < queued.length) {
			const effect = queued[notifyIndex]!;
			queued[notifyIndex++] = undefined;
			effect.run();
		}
	} finally {
		// 就算有订报人跑崩了,也要收尾:剩下的恢复"盯着"章,清空名单
		while (notifyIndex < queued.length) {
			const effect = queued[notifyIndex]!;
			queued[notifyIndex++] = undefined;
			effect.flags |= ReactiveFlags.Watching | ReactiveFlags.Recursed;
		}
		notifyIndex = 0;
		queued.length = 0;
	}
}

第 5 步:再考一次!(补丁打完)

bash
npm test

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

✓ tests/reference/trigger.spec.ts (7 tests)
✓ tests/reference/effectScope.spec.ts (4 tests)
✓ tests/reference/effect.spec.ts (10 tests)
✓ tests/reference/conformance.spec.ts (179 tests)
✓ 我们自己全部 14 个测试文件 (42 tests)
Test Files  18 passed (18)
     Tests  242 passed (242)

242 个,全绿! 七个隐藏毛病,五个补丁,全部修好。我们的库和真实库,行为完全一致了。 🎓

第 6 步:毕业表演(和朴素版比一比)

最后一场表演:把第 5 章的朴素版从"时光机"里请出来,当面比一比。

先建"时光机"——在 demo 文件夹里新建 naive-signal.ts,内容就是第 5 章"章末完整代码"里那份朴素版(signal/effect/computed/startBatch/endBatch 五个函数,一字不差)。

然后建 tests/bench.spec.ts,导演一场大戏:一条 50 层的中转站链,每轮"改上去又改回去"(净变化永远为零),跑 2000 轮:

ts
import { test } from 'vitest';
import * as naive from '../demo/naive-signal';
import * as modern from '../src/index';

function runScenario(lib: typeof naive | typeof modern, rounds: number) {
	const a = lib.signal(0);
	let recomputes = 0;
	const chain: Array<() => number> = [];
	chain.push(
		lib.computed(() => {
			recomputes++;
			return a() * 2;
		}),
	);
	for (let i = 1; i < 50; i++) {
		const prev = chain[i - 1];
		chain.push(
			lib.computed(() => {
				recomputes++;
				return prev() + 1;
			}),
		);
	}
	let sink = 0;
	lib.effect(() => {
		sink = chain[49]();   // 读最底层!整条链才会被建起来
	});
	const start = performance.now();
	for (let i = 0; i < rounds; i++) {
		lib.startBatch();
		a(1);
		a(0);   // 改回去!净变化永远为零
		lib.endBatch();
	}
	const elapsed = performance.now() - start;
	if (sink === -1) {
		console.log('never');
	}
	return { elapsed, recomputes };
}

test('毕业表演:朴素版 vs 我们的库', () => {
	const rounds = 2000;
	const naiveRes = runScenario(naive, rounds);
	const modernRes = runScenario(modern, rounds);
	console.log(`朴素版:${naiveRes.elapsed.toFixed(1)} ms,白算 ${naiveRes.recomputes} 层`);
	console.log(`我们的库:${modernRes.elapsed.toFixed(1)} ms,白算 ${modernRes.recomputes} 层`);
	console.log(`快 ${(naiveRes.elapsed / modernRes.elapsed).toFixed(1)} 倍(${rounds} 轮)`);
});

跑一遍,看看谁的账单更漂亮。

🚀 你可能会看到(这台机器上的实测结果):

朴素版:8.0 ms,白算 100050 层
我们的库:5.9 ms,白算 50 层
快 1.4 倍(2000 轮)

同样的 2000 轮促销:朴素版白算 10 万层,我们只算了 50 层(还都是建链时该算的)。快 1.4 倍是结果,"不白干"才是原因——这正是第 9 章"核实"从第 5 章的账单里省出来的。🎉

🧰 TS 小课堂:try/finally——"就算崩了也要收尾"

补丁⑤用到了一个老朋友:try / finally。第 3 章我们就用它保护"耳朵"(activeSub)——不管 fn 出不出错,耳朵一定还回去。今天它又立了一功:保住发刊队列

ts
	try {
		// 该跑的订报人,挨个跑
	} finally {
		// 就算有人跑崩了,这里也一定执行:收尾
	}

finally 是"无论如何都要做的事"——哪怕中间的代码抛错,它也会执行。毕业考里它救场的地方,正是"一个订报人抛错,队列必须照样收尾"。

消息城里,try / finally 出现了三次,三次都是同一个道理:

位置finally 保证什么
第 3 章 effect 的 run耳朵一定还回去
第 9 章 untracked耳朵一定戴回来
本章 flush队列一定收尾

"不管发生什么,后事一定交代完"——这是消息城的处世哲学,也是程序健壮性的第一课。

📚 消息城词典

意思记住它
毕业体检用真实库自己的测试考自己别人的测试,专挑行为下手
毕业补丁考出来的毛病,修好它7 个红 → 5 个补丁 → 全绿
时光机把第 5 章的朴素版请回来对比demo/naive-signal.ts

毕业体检/毕业补丁/时光机是本章用语。

📦 章末完整代码

毕业时的 signal-book 文件夹长这样:

signal-book/
├── demo/
│   ├── ch01.mjs
│   └── naive-signal.ts      ← 时光机:第 5 章的朴素版
├── node_modules/
├── package.json
├── tsconfig.json
├── src/
│   ├── money.ts
│   ├── system.ts            ← 补丁①②(unlink 尾巴、checkDirty 验活)
│   └── index.ts             ← 补丁③④⑤(update 送孩子、read 防自递归、flush 收尾)
└── 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
    ├── cleanup.spec.ts
    ├── scope.spec.ts
    ├── trigger.spec.ts
    ├── batch-lifecycle.spec.ts
    ├── untracked.spec.ts
    ├── reference/           ← 真实库自己的测试(conformance/effect/effectScope/trigger)
    └── bench.spec.ts        ← 毕业表演

这章的"完整代码"就是第 4 步那 5 个补丁(每处都贴全了)——src/system.ts 补 2 处,src/index.ts 补 3 处,其余一字未动。四个参考库测试文件从 参考/alien-signals/tests/ 抄来、改两处导入即可;demo/naive-signal.ts 是第 5 章"章末完整代码"那份朴素版。

跑通了? 只要 npm test 打出 243 passed(242 条 + 毕业表演那条),消息城的信号库就正式毕业了。

🎮 动手试试

怎么玩

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

1. 预言输出(猜一猜)

毕业表演开演前,小铃让你猜:同样的 2000 轮"改上去又改回去",朴素版会白算大约多少层?我们的库呢?

👉 点开看答案

朴素版:大约 10 万层(每轮 50 层 × 2000 轮,加上建链的 50 层)。

我们的库:50 层(只有建链时那一次该算的——之后每轮核实发现"没真变",一层都不白算)。

同样是"改了个来回",朴素版把 50 层链从头到尾重算了两万遍;我们一次都不重算——这就是第 9 章"核实"的价值,毕业表演当场兑现

2. 帮小铃找 bug(抓错)

小铃打完补丁①-④,觉得⑤"没那么重要",跳过了。结果 npm test 还剩 1 个红——#177。为什么这个红必须打补丁⑤才能消?

👉 点开看答案

因为 #177 考的是跑崩后的收尾:一个订报人的规则抛错,flush 的循环中断——没有 finally 收尾,排队名单没清空、notifyIndex 停在半路。下次随便哪个信号一写,flush 从断掉的地方续跑,残留的订报人被莫名其妙叫醒。

别的补丁治"跑得对不对",补丁⑤治"跑崩了也不会坏"——健壮性和正确性一样重要,一条都不能少。

3. 填空(补全)

flushfinally 里少了一行。补全它,让收尾真正干净:

ts
	} finally {
		while (notifyIndex < queued.length) {
			const effect = queued[notifyIndex]!;
			queued[notifyIndex++] = undefined;
			effect.flags |= ReactiveFlags.Watching | ReactiveFlags.Recursed;
		}
		notifyIndex = 0;
		/* 这里该写什么? */
	}
👉 点开看答案

queued.length = 0;

ts
		notifyIndex = 0;
		queued.length = 0;   // 名单清空,下轮从零开始

notifyIndex 归位、名单清空——两样一起,队列才算真正收尾。漏了哪一样,下次 flush 都会从脏状态出发。

4. 小挑战(你的毕业考题)

用时光机(demo/naive-signal.ts)和我们的库,自己编一个**"朴素版会白算、我们不会"**的场景——然后写进 bench.spec.ts 或新测试里验证。

提示:想想朴素版哪些行为是"勤快过头"的——没人读它也重算?改了又改回去也重算?同一个订报人被通知两遍?

👉 点开看答案

参考("改了又改回去,连订报人也不白醒"):

ts
// 朴素版:flour 5→3,订报人被叫醒两遍、中转站白算两遍
// 我们的库:核实发现没真变,订报人排了队也不跑、中转站一次都不算
const naiveLogs: number[] = [];
const nf = naive.signal(3);
const nc = naive.computed(() => nf() * 3);
naive.effect(() => { nc(); naiveLogs.push(1); });

const modernLogs: number[] = [];
const mf = modern.signal(3);
const mc = modern.computed(() => mf() * 3);
modern.effect(() => { mc(); modernLogs.push(1); });

naive.startBatch(); nf(5); nf(3); naive.endBatch();
modern.startBatch(); mf(5); mf(3); modern.endBatch();

expect(naiveLogs.length).toBe(3);    // 注册 1 + 白醒 2
expect(modernLogs.length).toBe(1);   // 注册 1 + 0 次白醒

凡是能写出"朴素版多干、我们少干"的场景,都是好考题——毕业之后,你已经有能力自己考自己了

🏁 本章小结

  1. 毕业体检:把真实库自己的 200 条测试搬过来,考出 7 个隐藏毛病——它们藏在我们的 42 条测试照不到的角落;
  2. 毕业补丁:5 个补丁(摘线尾巴、核实验活、重算送孩子、防自递归、队列收尾),把 7 个红全修绿——我们的库和真实库,行为完全一致
  3. 毕业表演:同样 2000 轮促销,朴素版白算 10 万层,我们 50 层——不白干,才是我们从第 1 章走到现在的答案。

🎓 毕业寄语

消息城的旅程,到这里就结束了。

还记得第 1 章吗?小铃冲进老墨的办公室,问"为什么 count 变了,money 不自己跟着变"。那时我们连"信号"两个字都解释不清。

现在,你的手里有一个真正的信号库——它的引擎只认线和章,它的整车是消息城;它有订阅小组、手动发刊、捂住耳朵的本事;它的行为,和全世界很多网站正在用的那个真实库一模一样,连它自己的测试都挑不出毛病。

你走过的 21 章,就是一条从"跟着抄代码"到"能自己设计考题"的路。消息城的公告牌会继续亮着——以后你再看到"信号""响应式""依赖图"这些词,你会想:

"哦,就是消息城里的那些线嘛。"

谢谢你来消息城做客。毕业快乐!🎓