Skip to content

第 15 章 自动反应的一生:清理、嵌套、LIFO

📖 订报人也有"一生"

"老墨,"小铃这天走进办公室,表情有点凝重,"我发现咱们的订报人,只会'跑'。"

"哦?跑不好吗?"

"它跑完就完了。可它干活的时候,可能会留下东西——比如打开了一个文件、挂了一个计时器、占了一块地方。消息一变它又跑一遍,旧的没收拾,新的又来了……时间一长,消息城会不会被它留下的东西塞满?"

老墨欣慰地笑了:"你问到了一个特别重要的问题——订报人的一生。"

"一生?"

"对。我们的订报人,之前只有'工作'这一个阶段。今天,我们给它补上完整的一生:出生(注册就立刻跑一遍)、工作(消息一变就重跑)、退休(重跑前先把上一期留下的东西收拾好)、辞职(随时可以离开,走之前把该交代的都交代完)。"

"退休……辞职……"小铃的眼睛亮了,"那它怎么收拾上一期留下的东西?"

"靠清理函数。订报人的规则可以'顺手带回一张纸条'——纸条上写着'如果我重跑/辞职,先做这些事'。这张纸条,就是它的退休金清单。"

"还有,老墨,"小铃追问,"订报人能不能在订报人里再订报人?"

"能。消息城允许嵌套——订报人的规则里可以再造订报人。但这里有一条铁规矩,你得记牢:"

父订报人重跑或辞职时,要先送走孩子——后出生的孩子先走。

"后出生的先走……"小铃歪着头,"像一摞盘子,最后放上去的最先拿走?"

"没错,这叫 LIFO(后进先出)。今天这一章,我们给订报人补上它的一生。"

🎯 本章要解决什么

一句话问题:怎么让订报人有完整的一生——清理函数、嵌套订报、辞职、LIFO 顺序?

读完这一章,你会知道:

  • 清理函数(cleanup):重跑前 / 辞职时,先收拾上一期留下的东西;
  • 嵌套订报:订报人里造订报人,父先送走孩子;
  • 辞职(dispose):effect 返回的"辞职按钮";
  • LIFO:后创建的先清理。

💡 概念讲解

退休金清单(清理函数)

订报人的规则(fn)可以返回一个函数——这个返回值就是清理函数

ts
effect(() => {
	a();
	// ……干活……
	return () => {
		// ……收拾上一期留下的东西……
	};
});

清理函数什么时候执行?两个时刻:

  1. 重跑之前:新一期开始前,先跑一遍旧清理(收拾上一期的);
  2. 辞职之时:订报人离开消息城之前,最后跑一遍清理(交代后事)。
mermaid
graph LR
    run1["第一期的活 📝"] --> redo["消息变了,要重跑"]
    redo --> clean["先跑旧清理函数 🧹"]
    clean --> run2["再跑第二期的活"]
    run2 --> quit["辞职按钮按下"]
    quit --> clean2["最后跑一遍清理 🧹"]
    clean2 --> bye["离开消息城 👋"]

嵌套订报:父与子

订报人的规则里,可以再 effect(...)——在父订报人里出生的子订报人

消息城的规矩:子订报人出生时,把自己登记成父订报人的"孩子"(一根特殊的连接线)。于是:

  • 父重跑:先送走旧孩子(旧孩子收拾完离开),再跑,再迎接新孩子;
  • 父辞职:孩子们先走,父最后走。
mermaid
graph TD
    parent["父订报人 👨"] --> child1["子订报人 ① 👶"]
    parent --> child2["子订报人 ② 👶"]
    parent --> child3["子订报人 ③ 👶"]

LIFO:后出生的先走

父辞职时,多个孩子按什么顺序离开?后创建的先走

③ 先走 → ② 再走 → ① 最后走 → 父才走

一摞盘子,最后放上去的最先拿走。为什么?因为后创建的订报人,通常依赖先创建的——让它们先走,才不会"依赖没了还在用"。

辞职(dispose)

effect(...) 现在会返回一个辞职按钮——按下它,订报人走完一生:

ts
const stop = effect(() => { /* …… */ });
stop();   // 辞职!收拾完东西离开

✍️ 动手写代码

这是消息城最复杂的一次改造:给订报人补上"一生"。别急,我们一层一层来。

不想一步步抄?

每一步都有讲解。抄到一半乱了,直接跳"章末完整代码",那里有整份文件。

第 1 步:加退休金清单(cleanup 字段)

打开 src/signal.ts。给订报人的图纸加一个字段,规则也允许"带回一张纸条":

ts
// 自动反应节点:订报人(会干活,还会收拾)
interface EffectNode extends ReactiveNode {
	fn: () => void | (() => void);   // 规则(可以返回清理函数)
	cleanup: (() => void) | void;    // 退休金清单:重跑前/辞职时执行
	run: () => void;
}

在六面旗里加一面新旗(HasChildEffect = 64),并在枚举注释里说明:

ts
export const enum ReactiveFlags {
	// ……原有的六面旗……
	HasChildEffect = 64,  // 我有孩子:我的依赖里有子订报人
}

第 2 步:改 effect——出生(跑第一遍)+ 辞职按钮

effect 整个换成下面的版本。它现在做的事多了:第一遍直接跑(出生)、登记到父订报人名下(嵌套)、返回辞职按钮

ts
export function effect(fn: () => void | (() => void)): () => void {
	const node: EffectNode = {
		fn,
		cleanup: undefined,
		flags: ReactiveFlags.Watching | ReactiveFlags.RecursedCheck,
		deps: undefined,
		depsTail: undefined,
		subs: undefined,
		subsTail: undefined,
		run: () => {},
	};

	node.run = () => { run(node); };   // 重跑按钮(先装好,出生时可能要立刻用)

	// 如果现在有"正在跑的订报人"(父订报人):把自己登记成它的孩子
	const parent = activeSub;
	if (parent !== undefined) {
		link(node, parent, 0);
		parent.flags |= ReactiveFlags.HasChildEffect;
	}

	// 出生:第一遍直接跑(读到的信号都会连上我)
	++cycle;
	++runDepth;
	const prevSub = activeSub;
	activeSub = node;
	try {
		node.cleanup = node.fn();   // fn 的返回值,就是退休金清单
	} finally {
		--runDepth;
		activeSub = prevSub;
		node.flags &= ~ReactiveFlags.RecursedCheck;
		purgeDeps(node);            // 期末清线
	}

	// 辞职按钮:按下它,走完一生
	return () => {
		dispose(node);
	};
}

注意两件事:

  • 第一遍是"直接跑",不走 run——因为出生时还没有"上一期"要清理;
  • fn 的返回值存进 cleanup——这就是退休金清单。

第 3 步:加三个"一生"工具

在文件里(run 附近)加三个函数——跑清理从后往前送走所有孩子辞职

ts
// 跑清理:执行退休金清单(跑完把清单撕掉)
function runCleanup(e: EffectNode): void {
	const cleanup = e.cleanup!;
	e.cleanup = undefined;
	const prevSub = activeSub;
	activeSub = undefined;
	try {
		cleanup();
	} finally {
		activeSub = prevSub;
	}
}

// 从后往前送走一个节点的所有依赖(后创建的先走,LIFO)
function disposeAllDepsInReverse(sub: ReactiveNode): void {
	let link = sub.depsTail;
	while (link !== undefined) {
		const prev = link.prevDep;
		unlink(link);
		link = prev;
	}
}

// 辞职:结束订报人的一生(送走孩子 → 从父那里摘掉自己 → 跑最后一遍清理)
function dispose(e: EffectNode): void {
	e.flags = ReactiveFlags.None;      // 辞职:所有旗子拔掉
	disposeAllDepsInReverse(e);        // 先送走孩子们(后创建的先走)
	const sub = e.subs;
	if (sub !== undefined) {
		unlink(sub);                   // 从父订报人那里摘掉自己
	}
	if (e.cleanup) {
		runCleanup(e);                 // 最后跑一遍退休金清单
	}
}

disposeAllDepsInReverse链尾往前走——而孩子是后创建的、排在依赖链靠后的位置,所以后出生的先被送走,LIFO 的"盘子"就这样摞起来了。

第 4 步:加"没人订了"(unwatched)+ 改摘线

还有一个新机制:没人订了,就该歇业。加一个 unwatched(歇业)函数,并在 unlink 里调用它——当一根线的"听众"被摘光时,通知被摘光的节点:

ts
// 没人订了:节点该"歇业"了
function unwatched(node: SignalNode | ComputedNode | EffectNode): void {
	if ('getter' in node) {
		// 中转站:没人订阅了——标记过期,并把里面的子订报人送走
		if (node.depsTail !== undefined) {
			node.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty;
			disposeAllDepsInReverse(node);
		}
	} else if ('fn' in node) {
		// 订报人:没人订阅了——辞职
		dispose(node);
	}
	// 信号:什么也不用做
}

unlink 里,"从 dep 的听众链上摘下来"那一段要加一行——听众被摘光时叫它歇业

ts
	// 从 dep 的听众链上摘下来
	if (prevSub !== undefined) {
		prevSub.nextSub = nextSub;
	} else if ((dep.subs = nextSub) === undefined) {
		unwatched(dep as SignalNode | EffectNode | ComputedNode);   // 没人听它了:叫它歇业
	}

(dep.subs = nextSub) === undefined 是一句"边改边查":先把 dep.subs 更新成 nextSub,再检查它是不是空了。)

第 5 步:改发通知(notify)——内层先跑

notify 要升级:订报人排队时,把它的父订报人也带上,而且内层排前面(先跑):

ts
// 发通知:把订报人排进队(父订报人也跟着排,内层先跑)
function notify(effect: EffectNode): void {
	let insertIndex = queued.length;
	let firstInsertedIndex = insertIndex;

	do {
		queued[insertIndex++] = effect;
		effect.flags &= ~ReactiveFlags.Watching;   // 摘掉"盯着"章:别重复排队
		effect = effect.subs?.sub as EffectNode;   // 往上走:找父订报人
		if (effect === undefined || !(effect.flags & ReactiveFlags.Watching)) {
			break;
		}
	} while (true);

	queued.length = insertIndex;

	// 反转:内层排前面,先跑
	while (firstInsertedIndex < --insertIndex) {
		const left = queued[firstInsertedIndex];
		queued[firstInsertedIndex++] = queued[insertIndex];
		queued[insertIndex] = left;
	}
}

新本事是 effect = effect.subs?.sub——沿着"谁在听我"的链往上走,找到父订报人,一起排队。最后那段"反转",把排队的顺序倒过来:子订报人排到父前面,先跑。

第 6 步:改 run(重跑的一生)

run 换成完整版——它现在要先问"真的需要重跑吗"(脏不脏门),需要的话:先送走旧孩子 → 跑旧清理 → 跑新一期

ts
// 重跑:订报人的"工作"阶段(先问脏不脏,脏了才跑)
function run(e: EffectNode): void {
	const flags = e.flags;
	if (
		flags & ReactiveFlags.Dirty
		|| (
			flags & ReactiveFlags.Pending
			&& checkDirty(e)
		)
	) {
		if (flags & ReactiveFlags.HasChildEffect) {
			// 有孩子:先把上一期的子订报人送走(从链尾往前)
			let l = e.depsTail;
			while (l !== undefined) {
				const prev = l.prevDep;
				const dep = l.dep;
				if (!('getter' in dep) && !('currentValue' in dep)) {
					unlink(l);   // 摘掉子订报人 → 没人听它了 → 它自己辞职
				}
				l = prev;
			}
		}
		if (e.cleanup) {
			runCleanup(e);       // 先跑旧退休金清单
			if (!e.flags) {
				return;          // 清理的时候被辞职了:不跑了
			}
		}
		e.depsTail = undefined;
		e.flags = ReactiveFlags.Watching | ReactiveFlags.RecursedCheck;
		++cycle;
		++runDepth;
		const prevSub = activeSub;
		activeSub = e;
		try {
			e.cleanup = e.fn();  // 跑新一期(返回值 = 新的退休金清单)
		} finally {
			--runDepth;
			activeSub = prevSub;
			e.flags &= ~ReactiveFlags.RecursedCheck;
			purgeDeps(e);        // 期末清线
		}
	} else if (e.deps !== undefined) {
		e.flags = ReactiveFlags.Watching | (flags & ReactiveFlags.HasChildEffect);
	}
}

四个关键点:

  1. 脏不脏门Dirty 或(Pending 且核实说真的变了)才重跑——不然就回去继续盯着(最后那行 else 分支);
  2. 先送孩子HasChildEffect 旗在,就从链尾往前把子订报人摘掉——摘掉后没人听它,unwatched 让它自己辞职、跑清理;
  3. 先清后跑:旧清理跑完,才跑新一期;
  4. run 里的 node.fn() 直接跑没了——现在全靠这个完整版。

第 7 步:改核实(checkDirty)的签名

订报人也要"量一量脏不脏"(第 6 步的脏不脏门),所以把 checkDirty 的签名从"只收中转站"改成"收任何节点"——它本来也只用到 .deps

ts
// 核实:被问到的时候,沿着依赖链挨个问"你们真的变了吗?"
function checkDirty(node: ReactiveNode): boolean {

(函数体一个字不用改——它只读 node.deps。)

第 8 步:写新测试(一生)

tests 文件夹里新建 cleanup.spec.ts

ts
import { describe, expect, test } from 'vitest';
import { signal, effect } 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']);

		a(2);
		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']);

		log.length = 0;
		a(1);
		expect(log).toEqual([
			'inner:cleanup',
			'outer:cleanup',
			'outer:run',
			'inner:run',
		]);
	});

	test('辞职(dispose):内层先清理、外层后清理', () => {
		const log: string[] = [];
		const dispose = effect(() => {
			log.push('outer:run');
			effect(() => {
				log.push('inner:run');
				return () => log.push('inner:cleanup');
			});
			return () => log.push('outer:cleanup');
		});
		log.length = 0;
		dispose();
		expect(log).toEqual(['inner:cleanup', 'outer:cleanup']);
	});

	test('兄弟订报人:后创建的先清理(LIFO)', () => {
		const log: string[] = [];
		const dispose = effect(() => {
			effect(() => () => log.push('inner1:cleanup'));
			effect(() => () => log.push('inner2:cleanup'));
			effect(() => () => log.push('inner3:cleanup'));
			return () => log.push('outer:cleanup');
		});
		dispose();
		expect(log).toEqual([
			'inner3:cleanup',
			'inner2:cleanup',
			'inner1:cleanup',
			'outer:cleanup',
		]);
	});

	test('三层嵌套:最深的先清理', () => {
		const log: string[] = [];
		const dispose = effect(() => {
			effect(() => {
				effect(() => () => log.push('grandchild:cleanup'));
				return () => log.push('child:cleanup');
			});
			return () => log.push('outer:cleanup');
		});
		dispose();
		expect(log).toEqual(['grandchild:cleanup', 'child:cleanup', 'outer:cleanup']);
	});
});

五个测试,把"一生"从头到尾演了一遍:重跑前清理、嵌套清理顺序、辞职顺序、兄弟 LIFO、三层嵌套。

第 9 步:叫检查员!

bash
npm test

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

✓ 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  10 passed (10)
     Tests  30 passed (30)

30 个,全绿。 订报人终于有了一生——会出生、会工作、会退休、会辞职。🎉

🧰 TS 小课堂:可选链 ?.

今天的新语法藏在 notify 里:

ts
effect = effect.subs?.sub as EffectNode;

读法:"取 effect.subs.sub——但如果 effect.subs 是空的(undefined),就别往后走了,直接得到 undefined。"

它是"先检查、再取值"的短写法:

ts
// 这行:
const x = obj?.a;
// 等于这段:
const x = obj !== undefined ? obj.a : undefined;

?.可选链"空了就别往后走"。链子可以一直拖下去(a?.b?.c),每一环都自带检查。

notify 里为什么需要它?因为订报人不一定有父订报人——顶层订报人的 subs 是空的。没有 ?.effect.subs.sub 会直接报错(读空值的属性);有了 ?.,空就空,安心拿到 undefined,循环自然结束。

📚 消息城词典

意思记住它
清理函数 (cleanup)重跑前/辞职时执行的"退休金清单"规则 fn 的返回值
嵌套订报订报人里造订报人子是父的孩子
辞职 (dispose)结束订报人的一生effect 返回的按钮
LIFO后进先出:后创建的先清理一摞盘子,后放的先拿
歇业 (unwatched)没人订了,该休息了停刊的老朋友

清理函数辞职是正式消息城术语,会同步进附录·消息城词典

📦 章末完整代码

这一章你的 signal-book 文件夹长这样(signal.ts 大改造 + 一个新测试文件):

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
    ├── naive-problems.spec.ts
    ├── subscription.spec.ts
    ├── checkdirty.spec.ts
    ├── checkup.spec.ts
    ├── recursion.spec.ts
    └── cleanup.spec.ts   ← 今天的新测试

src/signal.ts其他部分和第 14 章那份一样,今天改了八处:

  1. EffectNode 加了 cleanup 字段,fn 允许返回清理函数;ReactiveFlagsHasChildEffect = 64(第 1 步);
  2. effect 换成"出生 + 辞职按钮"版(第 2 步那份);
  3. 加了 runCleanupdisposeAllDepsInReversedispose(第 3 步那份);
  4. 加了 unwatchedunlink 的听众摘光分支调用它(第 4 步那份);
  5. notify 升级成"向上排队 + 反转"(第 5 步那份);
  6. run 换成完整版(脏不脏门 + 先送孩子 + 先清后跑)(第 6 步那份);
  7. checkDirty 签名从 ComputedNode 改成 ReactiveNode(第 7 步那份);
  8. effect 里的旧 run 闭包逻辑整体搬进了模块级 run

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

跑通了? 只要 npm test 打出 30 passed,订报人的一生就完整了——会出生、会工作、会退休、会辞职

🎮 动手试试

怎么玩

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

1. 预言输出(猜一猜)

小铃给订报人配了退休金清单,然后改了消息、又按了辞职按钮。猜猜 log 最后长什么样?

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

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

a(2);
stop();
👉 点开看答案

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

  • 出生:跑一遍 → run
  • a(2):重跑——先跑旧清理cleanup),再跑新一期(run);
  • stop():辞职——最后跑一遍清理cleanup)。

一生四次动作:run → (重跑) cleanup → run → (辞职) cleanup

2. 帮小铃找 bug(抓错)

小铃写 dispose 时,把"先送走孩子"那行忘了:

ts
function dispose(e: EffectNode): void {
	e.flags = ReactiveFlags.None;
	// 咦?送走孩子呢?
	const sub = e.subs;
	if (sub !== undefined) {
		unlink(sub);
	}
	if (e.cleanup) {
		runCleanup(e);
	}
}

结果:cleanup.spec.ts 里"辞职:内层先清理、外层后清理"的测试红了——log 里只有 'outer:cleanup',没有 'inner:cleanup'。为什么?

👉 点开看答案

因为孩子还赖在依赖链上,没被送走

disposeAllDepsInReverse 的活是"从链尾往前,把依赖(包括孩子)一根根摘掉"——摘掉孩子时没人听它了,unwatched 触发,孩子自己辞职、跑自己的清理。少了这一行,孩子根本没被触发,它的退休金清单永远没跑

"先送走孩子,再跑自己的清理"——这个顺序是 dispose 的灵魂,一行都不能少。

3. 填空(补全)

dispose 里"从父订报人那里摘掉自己"少了一行。补全它:

ts
	const sub = e.subs;
	if (sub !== undefined) {
		/* 这里该写什么? */
	}
👉 点开看答案

写:

ts
		unlink(sub);

e.subs 里那根线,就是"父订报人听我的"连接线——unlink 把它摘掉,父的依赖链上就不再挂着这个辞职的订报人了。

4. 小挑战(倒计时订报人)

用清理函数造一个"开/关"订报人:每次跑都"开"一个新编号的定时器(用 log 模拟),清理函数负责"关"掉上一个。把 a 改两次,验证:永远先关旧的、再开新的

👉 点开看答案

一种写法:

ts
const log: string[] = [];
const a = signal(0);
let id = 0;

effect(() => {
	a();
	const myId = ++id;
	log.push(`开 ${myId}`);
	return () => log.push(`关 ${myId}`);
});

expect(log).toEqual(['开 1']);

a(1);
a(2);

expect(log).toEqual(['开 1', '关 1', '开 2', '关 2', '开 3']);

log 里的节奏是"开 → 关 → 开 → 关 → 开":每次重跑,旧清理(关旧的)一定先于新一期(开新的)。这就是清理函数的意义——旧的不收拾干净,新的绝不开工

🏁 本章小结

  1. 清理函数(cleanup)是订报人的退休金清单:重跑前先收拾旧账,辞职时最后交代——规则 fn 的返回值就是它;
  2. 嵌套订报让订报人有了父与子:父重跑/辞职时先送走孩子——disposeAllDepsInReverse 从链尾往前,后创建的先走(LIFO)
  3. 辞职按钮(dispose)结束订报人的一生;歇业(unwatched)让"没人订了"的节点自动休息——30 个测试全绿

一个订报人的一生完整了。老墨在第六张图纸上写下的标题,让小铃想起了"订阅小组":

订阅小组:一个小组,一起出生,一起辞职。

下一章,消息城要开张订阅小组(effectScope)。