Skip to content

第 18 章 正式攒批:批量精修

📖 老朋友,正式登场

"老墨,"小铃这天翻着代码,忽然感慨,"咱们的攒批,好像跟第 5 章不太一样了。"

"哦?哪里不一样?"

"第 5 章的攒批,是用一个待办本子(pending Set)——攒批期间的通知都记在本子上,发刊时挨个叫。现在……"小铃指着 startBatchendBatch,"就一个计数器加一个 flush,简洁多了!"

"对。"老墨点点头,"第 5 章的攒批是'先记下来,发刊时一起叫';现在的攒批是'传播照常盖章排队,只是把发刊推迟到 endBatch'。前者要养一个本子,后者只靠一个深度计数器——而且它和订报人的一生配合得天衣无缝。"

"配合?"

"批里的订报人该重跑的重跑、该清理的清理——全都攒到发刊那一刻才动。没有中间价、没有半截清理。这是攒批的正式形态。"老墨拍了拍图纸,"今天,咱们给这位老朋友办个'入职仪式':公开它的深度表getBatchDepth),再补上'攒批 × 一生'的配合测试。"

"深度表?"

"getBatchDepth()——随时能问'现在攒了几层'。参考库里公开着这个读数,咱们也补上。"

🎯 本章要解决什么

一句话问题:攒批的正式形态是什么?它和订报人的一生怎么配合?

读完这一章,你会知道:

  • 攒批的演变:从第 5 章的"待办本子"到现在的"推迟发刊";
  • getBatchDepth:随时查看"攒了几层";
  • 攒批 × 一生:批里的清理、重跑、嵌套,全都攒到发刊那一刻,顺序不乱。

💡 概念讲解

攒批的两次变身

第 5 章(朴素版)现在(正式版)
攒批期间的通知记进待办本子(pending Set)传播照常盖章排队
发刊发刊时把本子上的挨个叫推迟 flush,endBatch 才跑
靠什么一个 Set一个深度计数器(batchDepth)

第 5 章的本子早就退休了——现在的攒批是"传播不拦,发刊推迟":消息照样传播、订报人照样排队,只是排了队不跑,等最外层的 endBatch 一声令下。

深度表(getBatchDepth)

startBatch 加一、endBatch 减一——这个"现在攒了几层"的数字,正式公开给外面看:

ts
getBatchDepth();   // 0(没在攒批)
startBatch();
getBatchDepth();   // 1
startBatch();
getBatchDepth();   // 2(套娃)
endBatch();
getBatchDepth();   // 1
endBatch();
getBatchDepth();   // 0(发刊了)

攒批 × 一生

攒批里订报人该重跑、该清理——全攒到发刊那一刻,顺序一个不破

mermaid
graph LR
    batch["startBatch 开始攒 📒"] --> change["批里改消息(订报人排队、不跑)"]
    change --> end["endBatch 发刊 📣"]
    end --> order["先清理旧账 🧹 → 再跑新一期 📝<br/>(嵌套:内层先清理,LIFO 不破)"]

✍️ 动手写代码

今天的改动很小:导出一个读数 + 补几条配合测试。攒批本身一个字不用改——它已经是最简形态了。

第 1 步:导出深度表

打开 src/signal.ts,在 startBatch 旁边加一个导出的读数函数:

ts
// 深度表:现在攒了几层?
export function getBatchDepth(): number {
	return batchDepth;
}

第 2 步:写新测试(攒批 × 一生)

tests 文件夹里新建 batch-lifecycle.spec.ts

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

describe('攒批的正式形态', () => {
	test('批里订报人重跑,清理函数也在发刊时执行', () => {
		const log: string[] = [];
		const a = signal(1);
		effect(() => {
			a();
			log.push('run');
			return () => log.push('cleanup');
		});
		expect(log).toEqual(['run']);

		startBatch();
		a(2);
		expect(log).toEqual(['run']);      // 批里:还没发刊
		endBatch();
		expect(log).toEqual(['run', 'cleanup', 'run']);   // 发刊时:先清理再跑
	});

	test('批里的嵌套订报人,重跑顺序不破(内层先清理)', () => {
		const log: string[] = [];
		const a = signal(0);
		effect(() => {
			a();
			log.push('outer:run');
			effect(() => {
				log.push('inner:run');
				return () => log.push('inner:cleanup');
			});
			return () => log.push('outer:cleanup');
		});
		expect(log).toEqual(['outer:run', 'inner:run']);

		startBatch();
		a(1);
		endBatch();

		expect(log).toEqual([
			'outer:run',
			'inner:run',
			'inner:cleanup',
			'outer:cleanup',
			'outer:run',
			'inner:run',
		]);
	});

	test('getBatchDepth 能看出攒了几层', () => {
		expect(getBatchDepth()).toBe(0);

		startBatch();
		expect(getBatchDepth()).toBe(1);
		startBatch();
		expect(getBatchDepth()).toBe(2);
		endBatch();
		expect(getBatchDepth()).toBe(1);
		endBatch();
		expect(getBatchDepth()).toBe(0);
	});
});

三个测试:第一个证明"批里不跑、发刊才跑、先清理再跑";第二个证明嵌套顺序在批里也不破;第三个是深度表的读数测试。

第 3 步:叫检查员!

bash
npm test

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

✓ tests/batch-lifecycle.spec.ts (3 tests)
✓ tests/trigger.spec.ts (3 tests)
✓ tests/scope.spec.ts (3 tests)
✓ tests/cleanup.spec.ts (5 tests)
✓ tests/recursion.spec.ts (2 tests)
✓ 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  13 passed (13)
     Tests  39 passed (39)

39 个,全绿。 攒批这位老朋友,正式入职了。🎉

🧰 TS 小课堂:查询函数

今天的新函数 getBatchDepth 很简单——它属于一类很常用的小函数:查询函数(只读不写,专门"报数")。

ts
// 查询函数:只看一眼,不动任何东西
export function getBatchDepth(): number {
	return batchDepth;
}

消息城里已经有两个查询函数了:

查询函数报什么数
getActiveSub()现在正在跑的是谁?(第 13 章)
getBatchDepth()现在攒了几层?(本章)

查询函数有三个好处:

  1. 只看不动:它们只读全局状态,不改任何东西——安全;
  2. 挡住直接翻箱倒柜:外面的人不用碰内部变量,问一声就有答案;
  3. API 对齐:真实库也公开着这两个读数——咱们的库和它越来越像了。

(第 19 章还会有第三个查询函数,先卖个关子。)

📚 消息城词典

意思记住它
深度表 (getBatchDepth)现在攒了几层攒批套娃的深度计
推迟发刊攒批期间传播照常、发刊推到 endBatch排了队不跑
查询函数只读不写、专门报数的小函数看一眼,不动手

攒批是正式消息城术语(CONTEXT.md 里早就有);深度表/推迟发刊/查询函数是本章用语。

📦 章末完整代码

这一章你没有新文件signal.ts 加了一个导出 + 一个新测试文件。文件夹结构:

signal-book/
├── demo/
│   └── ch01.mjs
├── node_modules/
├── package.json
├── tsconfig.json
├── src/
│   ├── money.ts
│   └── signal.ts      ← 加了:getBatchDepth(导出)
└── 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
    ├── recursion.spec.ts
    ├── cleanup.spec.ts
    ├── scope.spec.ts
    ├── trigger.spec.ts
    └── batch-lifecycle.spec.ts   ← 今天的新测试

src/signal.ts其他部分和第 17 章那份一样,今天只在 startBatch 旁边加了一个导出(第 1 步那份):

ts
// 深度表:现在攒了几层?
export function getBatchDepth(): number {
	return batchDepth;
}

tests/batch-lifecycle.spec.ts 的完整内容,就是上面"第 2 步"抄的那份——抄全了就不用再看。

跑通了? 只要 npm test 打出 39 passed,攒批就正式入职了——传播不拦,发刊推迟,一生不乱

🎮 动手试试

怎么玩

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

1. 预言输出(猜一猜)

小铃在攒批里改了一个信号两次。猜猜 log 最后长什么样?

ts
const log: string[] = [];
const a = signal(1);

effect(() => {
	a();
	log.push('run');
	return () => log.push('cleanup');
});

startBatch();
a(2);
a(3);
endBatch();
👉 点开看答案

log['run', 'cleanup', 'run']

  • 注册:run
  • 批里改了两次,订报人只排了一次队(待核实章去重);
  • endBatch 发刊:只醒一次——先清理(cleanup)再跑(run)。

改两次、醒一次——攒批把两次变化攒成了一笔,订报人不用白跑两遍。

2. 帮小铃找 bug(抓错)

小铃写 endBatch 时,把减深度忘了:

ts
export function endBatch(): void {
	// 咦?batchDepth-- 呢?
	if (batchDepth === 0) {
		flush();
	}
}

结果:batch.spec.ts 里"嵌套攒批"的测试红了——最内层的 endBatch 就发刊了。为什么?

👉 点开看答案

因为深度一直不减batchDepth 永远是 1——第一次 endBatch 就满足 batchDepth === 0……不对,等等——深度不减的话 batchDepth 是 2(startBatch 两次),=== 0 永远不成立,发刊永远不发生,测试会卡住/超时。

其实不管是哪种错法,道理都一样:endBatch 的活是"先减一,再检查是不是到零了":

ts
export function endBatch(): void {
	batchDepth--;
	if (batchDepth === 0) {
		flush();    // 最外层结束,才发刊
	}
}

减一和检查,缺一不可。

3. 填空(补全)

getBatchDepth 少了一行。补全它:

ts
export function getBatchDepth(): number {
	return /* 这里该写什么? */;
}
👉 点开看答案

batchDepth

ts
	return batchDepth;

深度表报的就是 batchDepth 这个计数器的当前值——看一眼,不动它。

4. 小挑战(深度表观察)

写一段代码,验证深度表的节奏:外层 startBatch → 内层 startBatch → 打印深度 → 内层 endBatch → 打印 → 外层 endBatch → 打印。你预期看到什么?

👉 点开看答案

预期节奏:

ts
startBatch();
console.log(getBatchDepth());   // 1
startBatch();
console.log(getBatchDepth());   // 2(套娃)
endBatch();
console.log(getBatchDepth());   // 1(还有外层)
endBatch();
console.log(getBatchDepth());   // 0(全部结束,发刊)

深度表就是攒批的"套娃计数器":进一层加一、退一层减一,归零才发刊。写进测试验证一下吧:

ts
expect(getBatchDepth()).toBe(0);
startBatch();
expect(getBatchDepth()).toBe(1);
startBatch();
expect(getBatchDepth()).toBe(2);
endBatch();
expect(getBatchDepth()).toBe(1);
endBatch();
expect(getBatchDepth()).toBe(0);

🏁 本章小结

  1. 攒批的正式形态:传播不拦、发刊推迟——第 5 章的待办本子早已退休,现在只靠一个深度计数器;
  2. 深度表getBatchDepth)公开了"攒了几层",和 getActiveSub 一样是只读不动的查询函数;
  3. 攒批 × 一生:批里的清理、重跑、嵌套全攒到发刊那一刻,顺序一个不破——39 个测试全绿。

攒批入职了。老墨在下一张图纸上,画了一双捂住耳朵的手:

订报人有时候,要"捂住耳朵"——读信号,但不登记订阅。

下一章,消息城要教订报人捂住耳朵(untracked)。