Skip to content

第 6 章 朴素版的烦恼:消息城最大的乌龙

📖 头条印错了

促销日的风波刚过去两天,消息城又炸锅了——这一次,是报纸出事了。

第二天清早,小铃像往常一样去报摊拿报纸,却被眼前的头条吓了一跳:

🎉 特大好消息!蛋糕今日特价:12 元!

"12 元?!"小铃冲进蛋糕店,"老板,蛋糕 12 元?"

"怎么可能!"老板瞪大眼睛,"面粉价 5 元、糖价 3 元,蛋糕 19 元(5×2 + 3×3)!报纸谁写的?"

报纸是消息城自动印的——它的价格新闻,正是用小铃的信号库算出来的。

"是不是我写的代码有 bug?"小铃慌了。

"别急,"老墨赶到,"把昨天的代码翻出来,我们查。"

小铃打开昨天的"促销日"代码:

ts
const flour = signal(5);   // 面粉价
const sugar = signal(3);   // 糖价
const cake = computed(() => flour() * 2 + sugar() * 3);

effect(() => {
	console.log(`蛋糕 ${cake()} 元`);
});

"昨天这个跑得好好的啊……"小铃挠头,"今天怎么就把 19 印成 12 了?"

"注意,"老墨点了点屏幕,"昨天的促销是改了价之后立刻打印。而报纸是攒批的——它先把一批价格改完,再一起发刊。问题,就藏在攒批里。"

🎯 本章要解决什么

一句话问题:攒批都治不好的两种"怪病"——重复通知顺序错乱——它们的病根到底在哪?

读完这一章,你会知道:

  • 怪病一:白干两遍——一个变化,订报人却被通知了两遍;
  • 怪病二:印错价格——发刊顺序不对,订报人读到了旧消息;
  • 老墨的诊断:病根不在某个函数里,而在记录订阅关系的方式(Set)本身。

💡 概念讲解

怪病一:白干两遍(重复通知)

我们造一个"放大镜"场景:一个信号 a,两个计算值都依赖它,一个自动反应同时听这两个计算值:

mermaid
graph LR
    a["a 信号 🗞️"] --> c1["c1 计算值 🧮"]
    a --> c2["c2 计算值 🧮"]
    c1 --> e["自动反应 🧒"]
    c2 --> e

现在,a 变一下。会发生什么?

  1. a 通知它的听众:c1c2 都要重算;
  2. c1 重算完,转发给它的听众——自动反应跑了一遍;
  3. c2 重算完,又转发——自动反应又跑了一遍

同一个变化,订报人被通知了两遍,白干两遍活。为什么第 5 章的待办本子(Set)没拦住?因为待办本子只能拦住"排队的通知"——可中转站的转发是当场喊人,根本没去排队。两个中转站各喊各的,谁也拦不住谁。

怪病二:印错价格(顺序错乱)

再看"放大镜"二号场景:订报人直接听信号 b,还听计算值 c(依赖信号 a):

mermaid
graph LR
    a["a 信号 🗞️"] --> c["c 计算值 🧮"]
    c --> e["自动反应 🧒"]
    b["b 信号 🗞️"] --> e

小铃把 ba 放在一个攒批里改:

ts
startBatch();
b(2);   // 先改 b
a(2);   // 再改 a
endBatch();

发刊时,待办本子按记上去的先后顺序喊人:b(2) 先记下了订报人,a(2) 后记下了 c 的重算。于是:

  1. 订报人跑——它读 b(新值 ✓),又读 c——c 还没重算! 读到的是旧值 ❌
  2. 然后 c 才重算,再转发,订报人跑了一遍(这次才对)

第一遍跑出来的,就是印错的 12 元。 这就是"顺序错乱":谁先谁后全靠"谁先被记上本子",而 Set 不保证这个顺序是对的。

老墨的诊断

老墨把两个怪病写在黑板上,盯了很久,说了一句话:

病根不在某个函数里。病根在 Set 本身——它只记"谁在听",却记不住两件重要的事:

① 顺序:谁先谁后,它说了不算; ② 状态:这次通知,"你变过没有",它一无所知。

"攒批治的是'同一轮多个变化'的表象。这两个怪病,是 Set 天生记不住事。要治根,得换一种能记住顺序、还能记住状态的记录方式……"

老墨拉开抽屉,抽出一卷泛黄的图纸。图纸上画着一条一条的线,每条线连着一个订报人和一个消息源,线上还写着一个数字。

"这个方案,叫连接线。"老墨说,"每一条订阅,都有一根线;线上,写着这是第几期的消息。"

"第几期……?"小铃的眼睛亮了起来,"那是什么?"

"明天,你就知道了。"老墨把图纸卷起来,神秘地笑了。

✍️ 动手写代码

今天不动 src/signal.ts 一个字母——我们做的是取证:把两个怪病"拍成诊断照片",证明它们真实存在。

tests 文件夹里新建 naive-problems.spec.ts,把下面的代码抄进去:

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

describe('诊断照片一:重复通知(白干两遍)', () => {
	test('一个变化让两个中转站都转发,订报人跑了两遍', () => {
		let runs = 0;
		const a = signal(2);
		const c1 = computed(() => a() * 2);
		const c2 = computed(() => a() * 3);
		effect(() => {
			runs++;
			c1();
			c2();
		});
		expect(runs).toBe(1);   // 注册时跑一遍

		a(5);
		// 故障:c1 转发一遍、c2 再转发一遍 → 订报人一共跑了三遍
		// (正确应该是两遍:注册一遍 + 变化一遍)
		expect(runs).toBe(3);
	});
});

describe('诊断照片二:顺序错乱(印错价格)', () => {
	test('发刊顺序不对时,先跑的那次读到旧消息', () => {
		const logs: number[] = [];
		const a = signal(1);
		const b = signal(1);
		const c = computed(() => a() * 10);
		effect(() => {
			logs.push(b() + c());
		});
		expect(logs).toEqual([11]);

		startBatch();
		b(2);
		a(2);
		endBatch();
		// 故障:发刊时订报人先跑——那时 c 还没重算,读到旧值 10,
		// 算出错误的 12(正确应该是 22)
		expect(logs).toEqual([11, 12, 22]);
	});
});

然后叫检查员:

bash
npm test

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

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

全绿?对,全绿——这很正常,别慌!

等等,这些测试明明在记录 bug,为什么还绿?

因为这些测试不是合格证,是诊断照片

它们不是在检查"做得对不对",而是在拍下现在的样子:"看,现在确实是这样:订报人跑了两遍;确实会算出错误的 12。"

照片拍下了病,病当然就是照片里的样子——所以它"绿"是应该的。

第 8 章传播治好两个怪病时,我们会翻新这些照片:把期望改成"正确应该怎样"(跑一遍、价格 22 元)。到那时它们变绿,才是真正的健康证明。

🧰 TS 小课堂(三样旧工具复习)

这一章没有新语法,但三样老朋友值得复习一下——第 7 章起它们全会用到:

工具样子一句话记住它
类型标注count: number给数据贴标签
泛型signal<T>口袋,装什么类型全函数认
void(): void只干活,不递东西

📚 消息城词典

意思记住它
重复通知一个变化,订报人却被通知好几遍两个中转站都转发,白干两遍
顺序错乱谁先谁后没保证,先跑的读到旧消息订报人先跑,中转站还没重算
诊断照片记录"现在确实是这样"的测试不是合格证,是病历
连接线每笔订阅一根线,线上写着第几期老墨图纸上的方案(下一章见!)

📦 章末完整代码

这一章你只加了一个文件src/signal.ts 一个字没动:

signal-book/
├── demo/
│   └── ch01.mjs
├── node_modules/
├── package.json
├── tsconfig.json
├── src/
│   ├── money.ts
│   └── signal.ts      ← 没动!(还是第 5 章的版本)
└── tests/
    ├── money.spec.ts
    ├── signal.spec.ts
    ├── computed.spec.ts
    ├── batch.spec.ts
    └── naive-problems.spec.ts   ← 今天的诊断照片

tests/naive-problems.spec.ts 完整内容(和上面"动手写代码"一样):

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

describe('诊断照片一:重复通知(白干两遍)', () => {
	test('一个变化让两个中转站都转发,订报人跑了两遍', () => {
		let runs = 0;
		const a = signal(2);
		const c1 = computed(() => a() * 2);
		const c2 = computed(() => a() * 3);
		effect(() => {
			runs++;
			c1();
			c2();
		});
		expect(runs).toBe(1);   // 注册时跑一遍

		a(5);
		// 故障:c1 转发一遍、c2 再转发一遍 → 订报人一共跑了三遍
		// (正确应该是两遍:注册一遍 + 变化一遍)
		expect(runs).toBe(3);
	});
});

describe('诊断照片二:顺序错乱(印错价格)', () => {
	test('发刊顺序不对时,先跑的那次读到旧消息', () => {
		const logs: number[] = [];
		const a = signal(1);
		const b = signal(1);
		const c = computed(() => a() * 10);
		effect(() => {
			logs.push(b() + c());
		});
		expect(logs).toEqual([11]);

		startBatch();
		b(2);
		a(2);
		endBatch();
		// 故障:发刊时订报人先跑——那时 c 还没重算,读到旧值 10,
		// 算出错误的 12(正确应该是 22)
		expect(logs).toEqual([11, 12, 22]);
	});
});

跑通了? 只要 npm test 打出 17 passed,两份诊断照片就拍好了——怪病已确诊

🎮 动手试试

怎么玩

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

1. 预言输出(猜一猜)

消息城又搭了一座"三层链":a → c1 → c2、c3 → 自动反应(两个计算值都依赖 c1,自动反应同时听 c2c3):

ts
const logs: number[] = [];
const a = signal(1);
const c1 = computed(() => a() * 2);
const c2 = computed(() => c1() * 2);
const c3 = computed(() => c1() * 3);

effect(() => {
	logs.push(c2() + c3());
});

a(5);

猜猜 logs 最后长什么样?(提示:想想怪病一和怪病二会不会同时出现)

👉 点开看答案

logs[10, 26, 50]

  • 注册时:c1=2,c2=4,c3=6 → 10;
  • a(5) 后:c1 重算成 10,转发给 c2 和 c3;
  • c2 先重算(20),转发——自动反应跑第一遍:c2=20,c3 还是旧的 6 → 26(顺序错乱!);
  • c3 再重算(30),转发——自动反应跑第二遍:20 + 30 = 50(重复通知!)。

两个怪病同时发作:多跑了一遍,中间那次还算错了

2. 帮小铃找 bug(抓错)

小铃想治好怪病二。她的主意:"旧消息是因为缓存——计算值不重算就直接把旧结果交出去。那把 hasValue 的判断去掉,每次读都重算,不就永远是新消息了?"

她把 computedread 改成:

ts
	const read = (): T => {
		if (activeEffect !== undefined) {
			subscribers.add(activeEffect);
		}
		recompute();   // 不管有没有算过,每次都重算!
		return value;
	};

结果:怪病二是好了点,可 computed.spec.ts 里的缓存测试红了。为什么?她的"药"有什么副作用?

👉 点开看答案

副作用是缓存没了

computed.spec.ts 里有个测试:"缓存:依赖没变,不重新算"——runs 应该是 1(只算一次)。可她这么改,double() 每次读都重算,runs 变成 2,测试红了。

而且怪病一(白干两遍)还在:订报人照样被两个中转站各通知一遍。

记住这个教训:修一个病,不能把没坏的零件也拆了。缓存没坏,不该动它——该动的是"记录订阅关系"的方式,也就是老墨图纸上的连接线。

3. 填空(补全)

小铃要给"诊断照片一"加一个"计数器"。补全这行,让自动反应每跑一遍都记一笔:

ts
		effect(() => {
			/* 这里该写什么? */
			c1();
			c2();
		});
👉 点开看答案

runs++;

ts
		effect(() => {
			runs++;
			c1();
			c2();
		});

计数器告诉我们自动反应到底跑了几遍——这是拍诊断照片的关键道具。

4. 小挑战(翻新照片)

拿出诊断照片一(naive-problems.spec.ts 里第一个测试)。把它的期望从"跑三遍"改成"应该跑两遍":

ts
expect(runs).toBe(2);   // 翻新:这才是健康的样子

然后跑 npm test,看它变红

体会一下这个"红":它不是坏事,它在说——"现在(朴素版)还做不到健康的标准,但这正是接下来两章要修的"。等第 8 章造出传播、治好两个怪病,我们再来翻新这两张照片,让它们真正变绿。

👉 点开看答案

改完跑 npm testnaive-problems.spec.ts 第一个测试会红:实际是 3,期望是 2。

别慌,这就是第 8 章要完成的任务清单。你甚至可以现在就把两张照片的期望都改成"健康标准"(跑两遍、[11, 22]),让它们一直红到第 8 章——红着的测试,就是还没做完的手术

🏁 本章小结

  1. 怪病一(重复通知):一个变化让多个中转站都转发,订报人白干两遍——待办本子拦不住"当场喊人";
  2. 怪病二(顺序错乱):发刊顺序没保证,订报人先跑、读到旧消息——报纸印错了价,全城轰动
  3. 病根:Set 只记"谁在听",记不住顺序状态——老墨的诊断是:换一种能记住这两件事的记录方式——连接线

图纸已经摊开,明天,就是消息城最激动人心的一天:心脏手术