Appearance
第 5 章 攒批:startBatch 与 endBatch
📖 促销日的大乱子
今天是消息城的促销日!蛋糕店老板大手一挥:
"面粉买一送一!糖买二送一!"
小铃高兴坏了,这意味着要同时改两个价。她打开代码:
ts
const flour = signal(5); // 面粉价
const sugar = signal(3); // 糖价
const cake = computed(() => flour() * 2 + sugar() * 3);
effect(() => {
console.log(`蛋糕 ${cake()} 元`);
});
// 促销开始!
flour(4); // 面粉降价
sugar(2); // 糖也降价小铃期待地等着打印出"蛋糕 14 元"(4×2 + 2×3)。可是屏幕上的输出让她傻了眼:
蛋糕 17 元 ← ??
蛋糕 14 元 ← 这才是对的多了第一行!而且中间还冒出了一个"半旧半新"的价格 17 元(面粉已经降到 4,糖还是 3:4×2 + 3×3 = 17)。
更糟的是,蛋糕店门口的价格牌也跟着闪了一下:先闪"17 元",再闪"14 元"。排队的大妈喊起来:"这牌子是不是坏了?一会儿一个价!"
小铃委屈地去找老墨:"我明明只改了两个价,为什么价格牌要闪两下?"
老墨叹了口气:"问题就出在'两个'上。你改一个价,消息城就通知一遍;改两个价,就通知两遍——中间还夹着一个改到一半的价。消息城有条规矩,你还没学会:"
改价,要攒批。
"攒批?"
"对。把要改的价都写好,最后喊一声'发刊'——消息城把这一批新消息一次送到所有订报人手里。中间那半旧半新的价,谁也看不见。"
🎯 本章要解决什么
一句话问题:怎么让"同一轮里的好几个变化"攒起来、一次通知,不让订报人看到改到一半的价、也不白跑两遍?
读完这一章,你会知道:
startBatch和endBatch这两个函数怎么用;- "攒批深度"(batchDepth)和"待办本子"(pending)是怎么回事;
- 攒批能治好什么,治不好什么(第 6 章的伏笔!)。
💡 概念讲解
现在的情况:一个价一条消息
现在的信号,每改一次就立刻通知所有订阅者:
mermaid
graph LR
f1["flour(4) 降价"] --> n1["通知!"]
n1 --> c1["cake 重算 → 17 元"]
s2["sugar(2) 降价"] --> n2["通知!"]
n2 --> c2["cake 重算 → 14 元"]
c1 --> bad["订报人看到 17 元 💥 半旧半新"]
c2 --> good["订报人看到 14 元 ✅"]两次通知,两次重算,中间那次还是错的。
攒批之后:一批一次发刊
startBatch() 宣布"开始攒",endBatch() 宣布"发刊"。中间的所有通知都先记在待办本子上,发刊时一起送:
mermaid
graph LR
start["startBatch() 开始攒 📒"] --> f["flour(4) → 记上待办"]
f --> s["sugar(2) → 记上待办"]
s --> end["endBatch() 发刊 📣"]
end --> once["cake 重算一次 → 14 元 ✅"]
once --> good2["订报人只看到 14 元"]注意待办本子用的是 Set——同一个订报人就算被记了两次,本子上也只有一个名字,发刊时只跑一遍。
两个新零件
待办本子(pending):一个全局的 Set,攒批期间"谁被通知了"都先记在这里。
攒批深度(batchDepth):一个计数器。为什么要计数?因为攒批可以套娃——一家店在促销,店里某个柜台又在搞小促销,两个 startBatch 叠在一起。只有最外层的 endBatch 才真正发刊:
startBatch(); // 深度 1
startBatch(); // 深度 2
...改价...
endBatch(); // 深度 1——还有一层,先不发刊
endBatch(); // 深度 0——发刊!✍️ 动手写代码
今天改两处:给 signal 的"写"加攒批判断,再在文件末尾加上 startBatch 和 endBatch。
第 1 步:改"写"——攒批时先记待办
打开 src/signal.ts,把 write 改成这样:
ts
// 写:换上新消息,挨个通知听众
const write = (next: T): void => {
value = next;
if (batchDepth > 0) {
// 攒批中:先记在待办本子上,发刊时再通知
for (const sub of subscribers) {
pending.add(sub);
}
return;
}
// 平时:直接通知
for (const sub of subscribers) {
sub();
}
};改动只有一处:攒批期间不直接喊人,而是把名字记上待办本子。等到发刊,本子上的名字一起被叫到。
第 2 步:在文件顶部加两个全局变量
在 activeEffect 那行下面,加:
ts
// 攒批状态
let batchDepth = 0; // 攒批深度:现在攒了几层?
const pending = new Set<Effect>(); // 待办本子:攒批期间要通知谁第 3 步:写 startBatch 和 endBatch
在文件末尾(computed 后面)追加:
ts
// 攒批:开始攒
export function startBatch(): void {
batchDepth++;
}
// 发刊:攒够了,一次通知所有人
export function endBatch(): void {
batchDepth--;
if (batchDepth > 0) {
return; // 还有外层在攒,先不发刊
}
while (pending.size > 0) {
const queue = [...pending];
pending.clear();
for (const sub of queue) {
sub(); // 通知!注意:通知过程中可能又攒新的,所以用 while 循环
}
}
}两个函数都"什么都不返回"——它们只是喊一声"开始攒"和"发刊"。while (pending.size > 0) 是"只要待办本子上还有人,就继续发"——因为发刊过程中,某个订报人可能又改了个价、又攒了新待办,得一起发完才收工。
第 4 步:写测试
在 tests 文件夹里新建 batch.spec.ts:
ts
import { describe, expect, test } from 'vitest';
import { signal, effect, computed, startBatch, endBatch } from '../src/signal';
describe('攒批:一次发刊', () => {
test('批里的自动反应只跑一遍,而且读到的是新消息', () => {
const logs: number[] = [];
const a = signal(1);
const b = signal(10);
effect(() => {
logs.push(a() + b());
});
expect(logs).toEqual([11]);
startBatch();
a(2);
b(20);
endBatch();
expect(logs).toEqual([11, 22]); // 只多了一条!
});
test('不攒批时,还是老样子一条一条通知', () => {
const logs: number[] = [];
const a = signal(1);
effect(() => {
logs.push(a());
});
a(2);
a(3);
expect(logs).toEqual([1, 2, 3]);
});
});
describe('攒批:嵌套与计算值', () => {
test('嵌套攒批:最外层结束才发刊', () => {
const logs: number[] = [];
const a = signal(1);
effect(() => {
logs.push(a());
});
startBatch();
startBatch();
a(2);
endBatch(); // 还有一层,先不发
expect(logs).toEqual([1]);
endBatch(); // 最外层结束,发刊
expect(logs).toEqual([1, 2]);
});
test('批里的计算值:中转站也攒着,发刊时才重算', () => {
const logs: number[] = [];
const a = signal(1);
const b = signal(10);
const sum = computed(() => a() + b());
effect(() => {
logs.push(sum());
});
expect(logs).toEqual([11]);
startBatch();
a(2);
b(20);
endBatch();
expect(logs).toEqual([11, 22]);
});
});第 5 步:叫检查员!
bash
npm test🚀 现在跑一下,你应该看到:
✓ 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 4 passed (4) Tests 15 passed (15)15 个测试全绿!🎉 促销日的价格牌,现在只会"闪一下"了——直接闪到正确的价。
🧰 TS 小课堂:void 类型
你肯定注意到了:startBatch 和 endBatch 的签名都是 (): void。这个 void 是什么?
void = "什么都不返回"。
一个函数,要么返回点什么(数字、文字、数组……),要么什么都不返回——只干活、不交东西。void 就是给"只干活不交东西"的函数贴的标签:
ts
export function startBatch(): void {
batchDepth++;
// 没有 return 东西——它只是喊一声"开始攒"
}还记得第 3 章的 effect 吗?它也是 (): void。以后看到 void,就知道:这个函数是来干活的,不是来递东西的。
📚 消息城词典
| 词 | 意思 | 记住它 |
|---|---|---|
| 攒批 (batch) | 把一批变化攒起来,一次通知 | 报纸不一份一份送,攒一批一起发刊 |
| 开始攒 (startBatch) | 宣布"接下来都记待办" | 深度加一 |
| 发刊 (endBatch) | 攒够了,一次通知所有人 | 深度减到零才真正发刊 |
| 待办本子 (pending) | 攒批期间"要通知谁"的本子 | 用 Set,同一个名字只记一次 |
| 攒批深度 (batchDepth) | 攒了几层(支持套娃) | 最外层结束才发刊 |
📦 章末完整代码
这一章你的 signal-book 文件夹长这样:
signal-book/
├── demo/
│ └── ch01.mjs
├── node_modules/
├── package.json
├── tsconfig.json
├── src/
│ ├── money.ts
│ └── signal.ts ← 今天加了攒批
└── tests/
├── money.spec.ts
├── signal.spec.ts
├── computed.spec.ts
└── batch.spec.tssrc/signal.ts 完整内容(第 4 章 + 今天的攒批):
ts
// src/signal.ts —— 朴素版信号库(signal + effect + computed + batch)
// 一个自动反应,就是一段"跑一遍"的代码
type Effect = () => void;
// 全局的"耳朵":现在正在跑的自动反应是谁?
let activeEffect: Effect | undefined;
// 攒批状态
let batchDepth = 0; // 攒批深度:现在攒了几层?
const pending = new Set<Effect>(); // 待办本子:攒批期间要通知谁
export function signal<T>(initialValue: T) {
let value = initialValue; // ① 消息本身
const subscribers = new Set<Effect>(); // ② 听众本子:谁在听我
// 读:报出消息,顺便记下"谁在听"
const read = (): T => {
if (activeEffect !== undefined) {
subscribers.add(activeEffect);
}
return value;
};
// 写:换上新消息,挨个通知听众
const write = (next: T): void => {
value = next;
if (batchDepth > 0) {
// 攒批中:先记在待办本子上,发刊时再通知
for (const sub of subscribers) {
pending.add(sub);
}
return;
}
// 平时:直接通知
for (const sub of subscribers) {
sub();
}
};
// ③ 公告牌:空手喊 = 读;举着新消息喊 = 写
const signalFn = function (next?: T): T {
if (arguments.length > 0) {
write(next as T);
return value;
}
return read();
};
return signalFn;
}
export function effect(fn: () => void): void {
const run = (): void => {
const prev = activeEffect;
activeEffect = run; // 举起我的耳朵:现在是我在听
try {
fn(); // 跑一遍——读到的信号都会记下我
} finally {
activeEffect = prev; // 跑完,把耳朵还给上一个
}
};
run(); // 注册就立刻跑一遍(先送一期报纸)
}
export function computed<T>(getter: () => T): () => T {
let value: T; // 缓存:上次算好的结果
let hasValue = false; // 算过没有?(还没算 = false)
const subscribers = new Set<Effect>(); // 谁在听我
// 重算:跑一遍规则,顺便订阅读到的信号
const recompute = (): void => {
const isFirst = !hasValue; // 这是第一次算吗?
const prev = activeEffect;
activeEffect = recompute; // 举起耳朵:现在是我在听
try {
value = getter(); // 按规则算!
hasValue = true;
} finally {
activeEffect = prev; // 把耳朵还回去
}
// 算完了,告诉所有在听我的人(第一次算不算"更新",不通知)
if (!isFirst) {
for (const sub of subscribers) {
sub();
}
}
};
// 计算值只有"读",没有"写"——它是算出来的,不能被人手动改
const read = (): T => {
if (activeEffect !== undefined) {
subscribers.add(activeEffect); // 记下:这位在听我
}
if (!hasValue) {
recompute(); // 第一次被读:先算一遍
}
return value;
};
return read;
}
// 攒批:开始攒
export function startBatch(): void {
batchDepth++;
}
// 发刊:攒够了,一次通知所有人
export function endBatch(): void {
batchDepth--;
if (batchDepth > 0) {
return; // 还有外层在攒,先不发刊
}
while (pending.size > 0) {
const queue = [...pending];
pending.clear();
for (const sub of queue) {
sub(); // 通知!注意:通知过程中可能又攒新的,所以用 while 循环
}
}
}tests/batch.spec.ts 完整内容(和上面第 4 步一样):
ts
import { describe, expect, test } from 'vitest';
import { signal, effect, computed, startBatch, endBatch } from '../src/signal';
describe('攒批:一次发刊', () => {
test('批里的自动反应只跑一遍,而且读到的是新消息', () => {
const logs: number[] = [];
const a = signal(1);
const b = signal(10);
effect(() => {
logs.push(a() + b());
});
expect(logs).toEqual([11]);
startBatch();
a(2);
b(20);
endBatch();
expect(logs).toEqual([11, 22]); // 只多了一条!
});
test('不攒批时,还是老样子一条一条通知', () => {
const logs: number[] = [];
const a = signal(1);
effect(() => {
logs.push(a());
});
a(2);
a(3);
expect(logs).toEqual([1, 2, 3]);
});
});
describe('攒批:嵌套与计算值', () => {
test('嵌套攒批:最外层结束才发刊', () => {
const logs: number[] = [];
const a = signal(1);
effect(() => {
logs.push(a());
});
startBatch();
startBatch();
a(2);
endBatch(); // 还有一层,先不发
expect(logs).toEqual([1]);
endBatch(); // 最外层结束,发刊
expect(logs).toEqual([1, 2]);
});
test('批里的计算值:中转站也攒着,发刊时才重算', () => {
const logs: number[] = [];
const a = signal(1);
const b = signal(10);
const sum = computed(() => a() + b());
effect(() => {
logs.push(sum());
});
expect(logs).toEqual([11]);
startBatch();
a(2);
b(20);
endBatch();
expect(logs).toEqual([11, 22]);
});
});✅ 跑通了? 只要
npm test打出15 passed,促销日的风波就算摆平了。
🎮 动手试试
怎么玩
先自己写答案,再点开对照。忍得住才点开哦!
1. 预言输出(猜一猜)
小铃跑了一段代码,猜猜 logs 最后长什么样?
ts
const logs: number[] = [];
const x = signal(1);
const y = signal(100);
effect(() => {
logs.push(x() + y());
});
startBatch();
x(2);
y(200);
endBatch();👉 点开看答案
logs 是 [101, 202]。
第一行 101:effect 注册时先跑一遍(1 + 100)。第二行 202:攒批里改了 x 和 y,待办本子只记了一次这个自动反应,发刊时跑一遍(2 + 200 = 202)。只有一条新的,没有中间价!
2. 帮小铃找 bug(抓错)
小铃写完攒批,可测试还是红——批里的自动反应跑了两遍。她检查了 endBatch,没问题;又检查了 write,发现了这个:
ts
const write = (next: T): void => {
value = next;
for (const sub of subscribers) {
pending.add(sub); // 咦?少了点什么?
}
};少了什么?
👉 点开看答案
少了攒批判断!她的 write 无条件把订阅者记进待办本子——可待办本子只在攒批期间才用。平时(没攒批)pending 永远不会被发刊,通知就全丢了;而攒批期间,因为少了 if (batchDepth > 0) 的分支,行为全乱。
正确写法:
ts
if (batchDepth > 0) {
for (const sub of subscribers) {
pending.add(sub);
}
return;
}
for (const sub of subscribers) {
sub();
}3. 填空(补全)
endBatch 里缺了一行,补上它,让"还有外层在攒"时不发刊:
ts
export function endBatch(): void {
batchDepth--;
if (/* 这里该写什么? */) {
return; // 还有外层在攒,先不发刊
}
// ……发刊……
}👉 点开看答案
写 batchDepth > 0:
ts
if (batchDepth > 0) {
return;
}深度减一之后还大于 0,说明外面还有一层 startBatch 没结束——轮不到我们发刊。
4. 小挑战(数一数)
小铃想亲眼看看攒批省了多少活。帮她写一个"计数器"实验:
- 一个
effect,每次跑都让runs加一,它读两个信号a和b; - 不攒批:
a(2); b(20);—— 猜猜runs变成几? - 攒批:
startBatch(); a(3); b(30); endBatch();—— 猜猜runs变成几?
写进测试里验证你的猜测。
👉 点开看答案
不攒批:runs 加 2(a 通知一次、b 通知一次,共跑两遍)。
攒批:runs 只加 1(待办本子用 Set,同一个自动反应只记一次,发刊时跑一遍)。
测试可以这样写:
ts
let runs = 0;
const a = signal(1);
const b = signal(10);
effect(() => {
runs++;
a();
b();
});
a(2);
b(20);
expect(runs).toBe(3); // 注册 1 次 + 不攒批 2 次
startBatch();
a(3);
b(30);
endBatch();
expect(runs).toBe(4); // 攒批只加了 1 次!🏁 本章小结
- 攒批(
startBatch/endBatch)把一批变化攒起来、一次通知,订报人不会看到"改到一半"的价,也不会白跑两遍; - 待办本子(pending)用 Set 去重,攒批深度(batchDepth)支持套娃,最外层才发刊;
- 攒批治好了"同一轮改多个信号"的毛病——但还有两种怪病它治不好:一个变化让好几个中转站都转发、同一个订报人收到好几遍通知;发刊顺序不对时还会读到旧消息。这两种病,第 6 章会给消息城带来一场真正的大乱子……