Appearance
第 15 章 自动反应的一生:清理、嵌套、LIFO
📖 订报人也有"一生"
"老墨,"小铃这天走进办公室,表情有点凝重,"我发现咱们的订报人,只会'跑'。"
"哦?跑不好吗?"
"它跑完就完了。可它干活的时候,可能会留下东西——比如打开了一个文件、挂了一个计时器、占了一块地方。消息一变它又跑一遍,旧的没收拾,新的又来了……时间一长,消息城会不会被它留下的东西塞满?"
老墨欣慰地笑了:"你问到了一个特别重要的问题——订报人的一生。"
"一生?"
"对。我们的订报人,之前只有'工作'这一个阶段。今天,我们给它补上完整的一生:出生(注册就立刻跑一遍)、工作(消息一变就重跑)、退休(重跑前先把上一期留下的东西收拾好)、辞职(随时可以离开,走之前把该交代的都交代完)。"
"退休……辞职……"小铃的眼睛亮了,"那它怎么收拾上一期留下的东西?"
"靠清理函数。订报人的规则可以'顺手带回一张纸条'——纸条上写着'如果我重跑/辞职,先做这些事'。这张纸条,就是它的退休金清单。"
"还有,老墨,"小铃追问,"订报人能不能在订报人里再订报人?"
"能。消息城允许嵌套——订报人的规则里可以再造订报人。但这里有一条铁规矩,你得记牢:"
父订报人重跑或辞职时,要先送走孩子——后出生的孩子先走。
"后出生的先走……"小铃歪着头,"像一摞盘子,最后放上去的最先拿走?"
"没错,这叫 LIFO(后进先出)。今天这一章,我们给订报人补上它的一生。"
🎯 本章要解决什么
一句话问题:怎么让订报人有完整的一生——清理函数、嵌套订报、辞职、LIFO 顺序?
读完这一章,你会知道:
- 清理函数(cleanup):重跑前 / 辞职时,先收拾上一期留下的东西;
- 嵌套订报:订报人里造订报人,父先送走孩子;
- 辞职(dispose):
effect返回的"辞职按钮"; - LIFO:后创建的先清理。
💡 概念讲解
退休金清单(清理函数)
订报人的规则(fn)可以返回一个函数——这个返回值就是清理函数:
ts
effect(() => {
a();
// ……干活……
return () => {
// ……收拾上一期留下的东西……
};
});清理函数什么时候执行?两个时刻:
- 重跑之前:新一期开始前,先跑一遍旧清理(收拾上一期的);
- 辞职之时:订报人离开消息城之前,最后跑一遍清理(交代后事)。
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);
}
}四个关键点:
- 脏不脏门:
Dirty或(Pending且核实说真的变了)才重跑——不然就回去继续盯着(最后那行else分支); - 先送孩子:
HasChildEffect旗在,就从链尾往前把子订报人摘掉——摘掉后没人听它,unwatched让它自己辞职、跑清理; - 先清后跑:旧清理跑完,才跑新一期;
- 旧
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 章那份一样,今天改了八处:
EffectNode加了cleanup字段,fn允许返回清理函数;ReactiveFlags加HasChildEffect = 64(第 1 步);effect换成"出生 + 辞职按钮"版(第 2 步那份);- 加了
runCleanup、disposeAllDepsInReverse、dispose(第 3 步那份); - 加了
unwatched,unlink的听众摘光分支调用它(第 4 步那份); notify升级成"向上排队 + 反转"(第 5 步那份);run换成完整版(脏不脏门 + 先送孩子 + 先清后跑)(第 6 步那份);checkDirty签名从ComputedNode改成ReactiveNode(第 7 步那份);- 原
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 里的节奏是"开 → 关 → 开 → 关 → 开":每次重跑,旧清理(关旧的)一定先于新一期(开新的)。这就是清理函数的意义——旧的不收拾干净,新的绝不开工。
🏁 本章小结
- 清理函数(cleanup)是订报人的退休金清单:重跑前先收拾旧账,辞职时最后交代——规则 fn 的返回值就是它;
- 嵌套订报让订报人有了父与子:父重跑/辞职时先送走孩子——
disposeAllDepsInReverse从链尾往前,后创建的先走(LIFO); - 辞职按钮(dispose)结束订报人的一生;歇业(unwatched)让"没人订了"的节点自动休息——30 个测试全绿。
一个订报人的一生完整了。老墨在第六张图纸上写下的标题,让小铃想起了"订阅小组":
订阅小组:一个小组,一起出生,一起辞职。
下一章,消息城要开张订阅小组(effectScope)。