Appearance
第 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 变一下。会发生什么?
a通知它的听众:c1和c2都要重算;c1重算完,转发给它的听众——自动反应跑了一遍;c2重算完,又转发——自动反应又跑了一遍!
同一个变化,订报人被通知了两遍,白干两遍活。为什么第 5 章的待办本子(Set)没拦住?因为待办本子只能拦住"排队的通知"——可中转站的转发是当场喊人,根本没去排队。两个中转站各喊各的,谁也拦不住谁。
怪病二:印错价格(顺序错乱)
再看"放大镜"二号场景:订报人直接听信号 b,还听计算值 c(依赖信号 a):
mermaid
graph LR
a["a 信号 🗞️"] --> c["c 计算值 🧮"]
c --> e["自动反应 🧒"]
b["b 信号 🗞️"] --> e小铃把 b 和 a 放在一个攒批里改:
ts
startBatch();
b(2); // 先改 b
a(2); // 再改 a
endBatch();发刊时,待办本子按记上去的先后顺序喊人:b(2) 先记下了订报人,a(2) 后记下了 c 的重算。于是:
- 订报人先跑——它读
b(新值 ✓),又读c——c 还没重算! 读到的是旧值 ❌ - 然后
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,自动反应同时听 c2 和 c3):
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 的判断去掉,每次读都重算,不就永远是新消息了?"
她把 computed 的 read 改成:
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 test,naive-problems.spec.ts 第一个测试会红:实际是 3,期望是 2。
别慌,这就是第 8 章要完成的任务清单。你甚至可以现在就把两张照片的期望都改成"健康标准"(跑两遍、[11, 22]),让它们一直红到第 8 章——红着的测试,就是还没做完的手术。
🏁 本章小结
- 怪病一(重复通知):一个变化让多个中转站都转发,订报人白干两遍——待办本子拦不住"当场喊人";
- 怪病二(顺序错乱):发刊顺序没保证,订报人先跑、读到旧消息——报纸印错了价,全城轰动;
- 病根:Set 只记"谁在听",记不住顺序和状态——老墨的诊断是:换一种能记住这两件事的记录方式——连接线。
图纸已经摊开,明天,就是消息城最激动人心的一天:心脏手术。