Skip to content

第 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 元"。排队的大妈喊起来:"这牌子是不是坏了?一会儿一个价!"

小铃委屈地去找老墨:"我明明只改了两个价,为什么价格牌要闪两下?"

老墨叹了口气:"问题就出在'两个'上。你改一个价,消息城就通知一遍;改两个价,就通知两遍——中间还夹着一个改到一半的价。消息城有条规矩,你还没学会:"

改价,要攒批。

"攒批?"

"对。把要改的价都写好,最后喊一声'发刊'——消息城把这一批新消息一次送到所有订报人手里。中间那半旧半新的价,谁也看不见。"

🎯 本章要解决什么

一句话问题:怎么让"同一轮里的好几个变化"攒起来、一次通知,不让订报人看到改到一半的价、也不白跑两遍?

读完这一章,你会知道:

  • startBatchendBatch 这两个函数怎么用;
  • "攒批深度"(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 的"写"加攒批判断,再在文件末尾加上 startBatchendBatch

第 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 类型

你肯定注意到了:startBatchendBatch 的签名都是 (): 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.ts

src/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:攒批里改了 xy,待办本子只记了一次这个自动反应,发刊时跑一遍(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 加一,它读两个信号 ab
  • 不攒批a(2); b(20); —— 猜猜 runs 变成几?
  • 攒批startBatch(); a(3); b(30); endBatch(); —— 猜猜 runs 变成几?

写进测试里验证你的猜测。

👉 点开看答案

不攒批:runs2a 通知一次、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 次!

🏁 本章小结

  1. 攒批startBatch / endBatch)把一批变化攒起来、一次通知,订报人不会看到"改到一半"的价,也不会白跑两遍;
  2. 待办本子(pending)用 Set 去重,攒批深度(batchDepth)支持套娃,最外层才发刊;
  3. 攒批治好了"同一轮改多个信号"的毛病——但还有两种怪病它治不好:一个变化让好几个中转站都转发、同一个订报人收到好几遍通知;发刊顺序不对时还会读到旧消息。这两种病,第 6 章会给消息城带来一场真正的大乱子……