Appearance
第 21 章 毕业:跑绿真实库的全部测试
📖 最后的考题
消息城建好了:公告牌、中转站、订报人的一生、订阅小组、手动发刊、引擎和整车——二十章的努力,全都装进了 src/system.ts 和 src/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.ts 的 unlink,在"从 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.ts 的 update 计算值分支,开头加上和订报人一样的"先送孩子":
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,一读又触发重算,重算又读……直到栈溢出崩溃。
补丁:computed 的 read 里,"第一次被读"的条件加一个守卫——正在算的时候,别再算一遍:
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. 填空(补全)
flush 的 finally 里少了一行。补全它,让收尾真正干净:
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 次白醒凡是能写出"朴素版多干、我们少干"的场景,都是好考题——毕业之后,你已经有能力自己考自己了。
🏁 本章小结
- 毕业体检:把真实库自己的 200 条测试搬过来,考出 7 个隐藏毛病——它们藏在我们的 42 条测试照不到的角落;
- 毕业补丁:5 个补丁(摘线尾巴、核实验活、重算送孩子、防自递归、队列收尾),把 7 个红全修绿——我们的库和真实库,行为完全一致;
- 毕业表演:同样 2000 轮促销,朴素版白算 10 万层,我们 50 层——不白干,才是我们从第 1 章走到现在的答案。
🎓 毕业寄语
消息城的旅程,到这里就结束了。
还记得第 1 章吗?小铃冲进老墨的办公室,问"为什么 count 变了,money 不自己跟着变"。那时我们连"信号"两个字都解释不清。
现在,你的手里有一个真正的信号库——它的引擎只认线和章,它的整车是消息城;它有订阅小组、手动发刊、捂住耳朵的本事;它的行为,和全世界很多网站正在用的那个真实库一模一样,连它自己的测试都挑不出毛病。
你走过的 21 章,就是一条从"跟着抄代码"到"能自己设计考题"的路。消息城的公告牌会继续亮着——以后你再看到"信号""响应式""依赖图"这些词,你会想:
"哦,就是消息城里的那些线嘛。"
谢谢你来消息城做客。毕业快乐!🎓