Skip to content

第 10 章 回归:手术后的全身体检

📖 体检单

心脏手术做完的那天晚上,老墨递给小铃一张皱巴巴的体检单

消息城信号库 · 手术后体检单

  1. 把 21 条旧测试全部跑一遍——一个都不许红
  2. 翻出第 3-6 章的朴素版病历,逐条对照——四个老毛病,是不是都治好了
  3. 回答一个问题:我们到底赢在哪?

小铃看着体检单,心里直打鼓。她知道手术很成功——第 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 章核实 checkDirtycheckdirty.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. 预言输出(猜一猜)

小铃跑了一段代码,猜猜 cakeRunseffectRuns 最后分别是几?

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();
👉 点开看答案

cakeRuns1effectRuns1

注册时各跑一次(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 = 15sugar(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),所以核实认为"真变了",两个计算值都重算。体检的价值就在于——你能亲手设计场景,验证消息城在任何剧情下都演得对。

🏁 本章小结

  1. 回归测试:手术做完,21 条旧测试一个都不许红——这是手术成功的最后一道关;
  2. 病历对照:订阅污染、重复通知、顺序错乱、白算——四个老毛病全部治愈,各有证据测试;
  3. 我们赢在哪:不是"跑得快",是不白干——不白算、不白跑、不印错价。

体检单打满了勾。老墨在第三部分的图纸上写下了第一个标题:

把递归换成"显式栈"——让传播不再一层层往下跳,而是自己扛着梯子走。

下一章,进入第三部分 · 优化之路。前面四章的硬核,终于要换成"更快"了。