Appearance
第 10 章 回归:手术后的全身体检
📖 体检单
心脏手术做完的那天晚上,老墨递给小铃一张皱巴巴的体检单:
消息城信号库 · 手术后体检单
- 把 21 条旧测试全部跑一遍——一个都不许红;
- 翻出第 3-6 章的朴素版病历,逐条对照——四个老毛病,是不是都治好了;
- 回答一个问题:我们到底赢在哪?
小铃看着体检单,心里直打鼓。她知道手术很成功——第 8 章翻新了诊断照片,第 9 章治好了白算。可是,万一哪里没缝好呢?
"别怕。"老墨说,"这就是'回归'的意义——手术做完,把旧功能全部再检查一遍。没弄坏任何东西,手术才算真正成功。检查员(vitest)会替我们盯着。"
"那……我们到底赢在哪?"小铃问。
"先体检,再回答。"老墨把体检单往前一推,"开始吧。"
🎯 本章要解决什么
一句话问题:手术做完了——我们赢了吗?怎么证明?
读完这一章,你会知道:
- 什么叫回归测试,为什么它是手术成功的最后一道关;
- 四张"病历"逐条对照:订阅污染、重复通知、顺序错乱、白算——全治好了吗?
- 用"数一数"的方式回答:我们到底赢在哪。
💡 概念讲解
体检一:回归测试(一个都不许红)
"回归"的"回",是退回去的意思——把以前的功能退回去重新检查一遍。
我们手里已经有 21 条测试,从第 2 章的"送报纸赚 2 元"到第 9 章的"不白算"。它们像 21 个哨兵,各守一个旧功能:
mermaid
graph LR
tests["21 条旧测试 🧪"] --> check["全部跑一遍"]
check --> green["全绿 ✅ 手术没弄坏任何功能"]
check --> red["有红的 ❌ 这里缝漏了!"]这就是回归测试:不写新功能,专门确认"旧功能还好好的"。第 8 章我们翻新照片时说过——红着的测试是还没做完的手术;现在,我们要的是全部变绿。
体检二:病历对照(四个老毛病)
第 3-6 章,消息城确诊过四个老毛病。现在逐条对照:
| 老毛病 | 在哪一章确诊 | 药方 | 证据测试 |
|---|---|---|---|
| 订阅污染(换了订阅,旧信号还来打扰) | 第 7 章 | 先清旧、再订新 | subscription.spec.ts |
| 重复通知(一个变化,订报人跑两遍) | 第 6 章 | 传播盖章去重 | naive-problems.spec.ts(翻新后) |
| 顺序错乱(发刊顺序不对,印错价格) | 第 6 章 | 被问到才重算 | naive-problems.spec.ts(翻新后) |
| 白算(改了又改回去,照算不误) | 第 9 章 | 核实 checkDirty | checkdirty.spec.ts |
四个老毛病,四个药方,四份证据测试。病历翻新成了健康证明。
体检三:我们赢在哪(数一数)
老墨在黑板左边写下"朴素版",右边写下"现在",中间画了一道竖线:
| 同一个场景 | 朴素版(第 3-5 章) | 现在(第 7-9 章) |
|---|---|---|
| 一个信号变了,两个中转站都转发(不攒批) | 订报人跑 2 遍,中间读到旧值 | 跑 1 遍,全是新值 |
| 攒批里"改上去又改回来"(净变化为零) | 中转站照样重算 1 遍(白算) | 0 遍——核实发现没真变 |
| 换了订阅对象后,旧信号再变 | 订报人照样被叫醒,多跑一遍 | 不被叫醒(旧线已退) |
注:"攒批里改两个信号只跑一遍"这件事,第 5 章的朴素版攒批就能做到——新版本的进步是在此基础上:不白算、不白跑、不印错价。
"看到了吗?"老墨敲了敲黑板,"我们赢的不是'跑得快'——是不白干:不白算、不白跑、不印错价。同一个场景,朴素版要么多跑、要么白算、要么被旧信号叫醒;我们每样都只干一遍,还全是新值。"
"至于跑得更快……"老墨顿了顿,"那是第三部分的事。体检先到这里,动手吧。"
✍️ 动手写代码
今天是体检日——代码几乎不用动,重点是跑和看。
第 1 步:回归(全部旧测试跑一遍)
打开终端,喊检查员:
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 个,一个都不红。 体检第一项:✅ 通过。
第 2 步:写综合体检测试
单独的测试各守一关,但老墨还想看一场"大合练"——信号、中转站、订报人、攒批、核实,全都上场,一次演完。
在 tests 文件夹里新建 checkup.spec.ts:
ts
import { describe, expect, test } from 'vitest';
import { signal, effect, computed, startBatch, endBatch } from '../src/signal';
describe('综合体检', () => {
test('一场大促销:多信号 + 中转站 + 订报人 + 攒批,一次都对', () => {
const logs: number[] = [];
const flour = signal(2);
const sugar = signal(3);
const cake = computed(() => flour() * 2 + sugar() * 3);
effect(() => {
logs.push(cake());
});
expect(logs).toEqual([13]);
startBatch();
flour(3); // 面粉涨价
sugar(2); // 糖降价
endBatch();
expect(logs).toEqual([13, 12]); // 3×2 + 2×3 = 12,一次印对
expect(cake()).toBe(12);
});
test('数一数:攒批 + 核实让谁少干活了', () => {
let cakeRuns = 0;
let effectRuns = 0;
const a = signal(1);
const b = signal(10);
const sum = computed(() => {
cakeRuns++;
return a() + b();
});
effect(() => {
effectRuns++;
sum();
});
expect(cakeRuns).toBe(1);
expect(effectRuns).toBe(1);
startBatch();
a(2);
b(20);
endBatch();
// 变化阶段:中转站重算 1 次、订报人跑 1 次——没有中间错价
expect(cakeRuns).toBe(2); // 含注册共 2 次
expect(effectRuns).toBe(2); // 订报人只跑 1 次(含注册共 2 次)
expect(sum()).toBe(22);
});
});第一个测试是"大合练":两个信号、一个中转站、一个订报人、一个攒批,全上场——最后只印对一次价。第二个测试是"数一数":把朴素版和现在的活儿数一遍,看看谁少干了。
第 3 步:再跑一遍
bash
npm test🚀 现在跑一下,你应该看到:
✓ tests/checkup.spec.ts (2 tests) ✓ 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 8 passed (8) Tests 23 passed (23)23 个,全绿。 体检单上打满勾:✅ 回归通过,✅ 病历对照通过,✅ 赢在哪——不白干。
🧰 TS 小课堂:全部语法体检
手术后,也给学过的 TypeScript 语法做个"体检"——从第 2 章到第 9 章,我们一共学了这些:
| 语法 | 在哪一章学的 | 一句话记住它 |
|---|---|---|
类型标注 count: number | 第 2 章 | 给数据贴标签 |
泛型 signal<T> | 第 3 章 | 口袋,装什么全函数认 |
void (): void | 第 5 章 | 只干活,不递东西 |
| interface + extends | 第 7 章 | 图纸;孩子继承爸爸的字段 |
as node.value as T | 第 7 章 | 我保证它是这个类型 |
const enum ReactiveFlags | 第 8 章 | 名字 ↔ 数字的对照表 |
| | 和 & 旗子运算 | 第 8 章 | 插旗 / 检查旗 |
递归 checkDirty 调自己 | 第 9 章 | 传话游戏,一层层往下,要有尽头 |
后面每一章,都会用到这些老朋友——就像消息城的每条街,都有它们的脚印。
📚 本章用语
| 词 | 意思 | 记住它 |
|---|---|---|
| 回归测试 | 手术做完,把旧功能全部重新检查一遍 | 一个都不许红 |
| 病历对照 | 把确诊过的老毛病和现在的证据逐条对 | 四个病,四个药方 |
| 综合体检 | 所有零件一起上场的"大合练" | 一个场景演完全部本事 |
注意:这些是本章用语,不是消息城术语——"回归测试"是程序员的通用词,所以不进书末尾的正式词典。
📦 章末完整代码
这一章你只加了一个文件,src/signal.ts 一个字都没动:
signal-book/
├── demo/
│ └── ch01.mjs
├── node_modules/
├── package.json
├── tsconfig.json
├── src/
│ ├── money.ts
│ └── signal.ts ← 没动!(还是第 9 章的版本)
└── 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 ← 今天的综合体检tests/checkup.spec.ts 完整内容(和上面"第 2 步"一样):
ts
import { describe, expect, test } from 'vitest';
import { signal, effect, computed, startBatch, endBatch } from '../src/signal';
describe('综合体检', () => {
test('一场大促销:多信号 + 中转站 + 订报人 + 攒批,一次都对', () => {
const logs: number[] = [];
const flour = signal(2);
const sugar = signal(3);
const cake = computed(() => flour() * 2 + sugar() * 3);
effect(() => {
logs.push(cake());
});
expect(logs).toEqual([13]);
startBatch();
flour(3); // 面粉涨价
sugar(2); // 糖降价
endBatch();
expect(logs).toEqual([13, 12]); // 3×2 + 2×3 = 12,一次印对
expect(cake()).toBe(12);
});
test('数一数:攒批 + 核实让谁少干活了', () => {
let cakeRuns = 0;
let effectRuns = 0;
const a = signal(1);
const b = signal(10);
const sum = computed(() => {
cakeRuns++;
return a() + b();
});
effect(() => {
effectRuns++;
sum();
});
expect(cakeRuns).toBe(1);
expect(effectRuns).toBe(1);
startBatch();
a(2);
b(20);
endBatch();
// 变化阶段:中转站重算 1 次、订报人跑 1 次——没有中间错价
expect(cakeRuns).toBe(2); // 含注册共 2 次
expect(effectRuns).toBe(2); // 订报人只跑 1 次(含注册共 2 次)
expect(sum()).toBe(22);
});
});✅ 跑通了? 只要
npm test打出23 passed,体检单就全打勾了。第二部分·心脏手术,正式收官!
🎮 动手试试
怎么玩
先自己写答案,再点开对照。忍得住才点开哦!
1. 预言输出(猜一猜)
小铃跑了一段代码,猜猜 cakeRuns 和 effectRuns 最后分别是几?
ts
let cakeRuns = 0;
let effectRuns = 0;
const a = signal(1);
const b = signal(2);
const c = computed(() => {
cakeRuns++;
return a() + b();
});
effect(() => {
effectRuns++;
c();
});
startBatch();
a(2);
a(1); // 改回去
endBatch();👉 点开看答案
cakeRuns 是 1,effectRuns 是 1。
注册时各跑一次(c = 3)。攒批里 a 走了 1 → 2 → 1,净变化是零——发刊时订报人排了队,可它一"量脏":核实发现 a 没真变(改了个来回),于是直接回去继续盯着,一次都没跑(effectRuns 还是 1)。中转站更没重算(cakeRuns 还是 1)。
第 15 章的小彩蛋
第 15 章给订报人装了"脏不脏门":排了队,不一定跑——只有真的脏了才起身。所以这里订报人也学会了偷懒。
2. 帮小铃找 bug(抓错)
小铃写"综合体检"时,把 startBatch / endBatch 忘了:
ts
flour(3); // 面粉涨价
sugar(2); // 糖降价
// 咦?攒批呢?结果测试红了:logs 变成了 [13, 15, 12]——多了一条 15。这个 15 是哪来的?
👉 点开看答案
15 是中间价:flour(3) 先通知一遍,订报人跑了一次——这时糖还是 3,蛋糕价 = 3×2 + 3×3 = 15;sugar(2) 再通知一遍,才变成 12。
不攒批,两次改动各发一次刊,中间价就泄漏给了订报人。攒批的意义:把改动攒成一批,一次发刊,谁也别想看到改到一半的价。这就是体检单上"中途读到的值一定是新的"那一行的由来。
3. 填空(补全)
综合体检里少了一个期望值。补全它,让"一次印对"成立:
ts
expect(logs).toEqual([13, /* 这里该写什么? */]);👉 点开看答案
写 12。
攒批里面粉 2→3、糖 3→2,蛋糕价 = 3×2 + 2×3 = 12。订报人只跑一遍,只看到这一个价——没有中间价。
4. 小挑战(设计自己的体检)
用三四个零件编一个你自己的场景,写进 checkup.spec.ts 跑绿。比如:小铃的温度计——温度信号、风扇计算值(超过 30 度就开)、空调计算值(超过 35 度才开)、一个订报人打印"风扇 X / 空调 Y";攒批里把温度从 20 改到 38 再改回 25,数一数两个计算值各重算了几次。
👉 点开看答案
一种写法(核心部分):
ts
let fanRuns = 0;
let acRuns = 0;
const temp = signal(20);
const fan = computed(() => { fanRuns++; return temp() > 30; });
const ac = computed(() => { acRuns++; return temp() > 35; });
effect(() => {
fan();
ac();
});
expect(fanRuns).toBe(1);
expect(acRuns).toBe(1);
startBatch();
temp(38);
temp(25); // 又凉快了
endBatch();
// 净变化:20 → 25,真的变了 → 两个计算值各重算一次
expect(fanRuns).toBe(2);
expect(acRuns).toBe(2);
expect(fan()).toBe(false); // 25 度,风扇关
expect(ac()).toBe(false); // 空调也关注意:temp 从 20 变到 38 又变到 25,净变化不是零(20→25),所以核实认为"真变了",两个计算值都重算。体检的价值就在于——你能亲手设计场景,验证消息城在任何剧情下都演得对。
🏁 本章小结
- 回归测试:手术做完,21 条旧测试一个都不许红——这是手术成功的最后一道关;
- 病历对照:订阅污染、重复通知、顺序错乱、白算——四个老毛病全部治愈,各有证据测试;
- 我们赢在哪:不是"跑得快",是不白干——不白算、不白跑、不印错价。
体检单打满了勾。老墨在第三部分的图纸上写下了第一个标题:
把递归换成"显式栈"——让传播不再一层层往下跳,而是自己扛着梯子走。
下一章,进入第三部分 · 优化之路。前面四章的硬核,终于要换成"更快"了。