Appearance
第 16 章 订阅小组:effectScope
📖 一个订报人太孤单
"老墨,"小铃这天抱着一摞代码进来,"消息城遇到个新需求——蛋糕店要搞'促销季',一开就是一个月,期间有一堆订报人在盯着各种价格。可促销季一结束,这些订报人全都该走——总不能一个一个按辞职按钮吧?"
老墨笑了:"你是想要一个总按钮——按一下,这一批订报人一起辞职?"
"对对对!"
"消息城早就有这个本事了。"老墨在纸上画了一个大圆圈,圈里装着一排小订报人,"它叫订阅小组(effectScope)——一个'小组',把一批订报人打包。组里出生的订报人,自动登记成小组的孩子;按下小组的解散按钮,组里所有订报人一起辞职——孩子们的清理函数照跑,一个都不漏。"
"而且,"老墨又画了一个圈中圈,"小组里还能再开小组——套娃。解散外层小组,里里外外一起解散。"
小铃眼睛发亮:"那这个小组,到底是什么?"
"它不是一个订报人——它只是一个容器。真正的订报人照常在组里出生,只是它们的'爸爸'从'单个订报人'变成了'这个小组'。上一章我们学了:爸爸辞职,先送走孩子。现在爸爸可以是一个小组——送走的,就是整批孩子。"
🎯 本章要解决什么
一句话问题:怎么把一批订报人打包成订阅小组,按一个按钮全部解散?
读完这一章,你会知道:
effectScope是什么——一个容器,不是订报人;- 组里出生的订报人怎么自动登记成"小组的孩子";
- 解散按钮:按一下,整组一起辞职(清理函数照跑,LIFO 不破)。
💡 概念讲解
小组是个容器
mermaid
graph TD
scope["订阅小组 📦(容器)"] --> e1["订报人 ① 👶"]
scope --> e2["订报人 ② 👶"]
scope --> sub["小组 ② 📦(套娃)"]
sub --> e3["订报人 ③ 👶"]小组自己不干活——它没有规则、没有清理函数。它只做两件事:
- 当"耳朵":组里的订报人出生时,
activeSub就是小组——于是订报人自动把自己登记成小组的孩子(第 15 章的"爸爸"机制,爸爸换成小组); - 当"总辞职按钮":解散小组,就是
disposeAllDepsInReverse——从链尾往前送走所有孩子(后创建的先走,LIFO),孩子的清理函数照跑。
解散小组
mermaid
graph LR
stop["按下解散按钮 🔘"] --> kids["从链尾往前送走孩子们 🧒🧒🧒"]
kids --> clean["每个孩子跑自己的清理函数 🧹"]
clean --> done["整组辞职完毕 👋"]关键:解散小组用的是第 15 章的"送孩子"机制——孩子被摘掉时没人听它了,unwatched 让它自己辞职、跑清理。所以一个按钮,整组收工。
为什么不用挨个按辞职按钮
订报人的辞职按钮要挨个按;小组的解散按钮按一个顶一群。而且套娃小组解散外层,内层小组会跟着一起解散(内层小组也是外层的孩子)——一锅端。
✍️ 动手写代码
今天加一个小组成员:effectScope。改动不大,核心是"让小组当耳朵"。
第 1 步:画小组的图纸
打开 src/signal.ts,加一个接口——小组没有 fn、没有 cleanup,只是一个空容器:
ts
// 订阅小组节点:一个"容器",把一批订报人打包
interface EffectScopeNode extends ReactiveNode {
}第 2 步:写 effectScope
在 effect 附近加 effectScope——它当"耳朵"跑一遍 fn(组里的订报人在这期间出生),然后返回解散按钮:
ts
// 订阅小组:当"耳朵"跑一遍 fn,返回解散按钮
export function effectScope(fn: () => void): () => void {
const node: EffectScopeNode = {
flags: ReactiveFlags.Mutable,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
};
// 如果有正在跑的父节点,把自己登记成它的孩子(套娃)
const parent = activeSub;
if (parent !== undefined) {
link(node, parent, 0);
parent.flags |= ReactiveFlags.HasChildEffect;
}
// 当"耳朵":组里的订报人出生时,会把自己登记成小组的孩子
const prevSub = activeSub;
activeSub = node;
try {
fn(); // 组里的订报人在这里出生
} finally {
activeSub = prevSub;
}
// 解散按钮:组里所有订报人一起辞职
return () => {
disposeScope(node);
};
}第 3 步:写解散(disposeScope)+ 改歇业(unwatched)
加 disposeScope——和第 15 章的 dispose 几乎一样,只是没有清理函数(小组自己不干活):
ts
// 解散小组:组里所有订报人一起辞职(后创建的先走)
function disposeScope(e: EffectScopeNode): void {
e.flags = ReactiveFlags.None;
disposeAllDepsInReverse(e); // 送走所有孩子(LIFO)
const sub = e.subs;
if (sub !== undefined) {
unlink(sub); // 从父节点那里摘掉自己
}
}然后给 unwatched(歇业)加一个分支——小组没人订了,也要解散。它和信号的区分方法是:小组没有 currentValue:
ts
// 没人订了:节点该"歇业"了
function unwatched(node: SignalNode | ComputedNode | EffectNode | EffectScopeNode): void {
if ('getter' in node) {
// 中转站:没人订阅了——标记过期,并把里面的子订报人送走
if (node.depsTail !== undefined) {
node.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty;
disposeAllDepsInReverse(node);
}
} else if ('fn' in node) {
// 订报人:没人订阅了——辞职
dispose(node);
} else if (!('currentValue' in node) && node.depsTail !== undefined) {
// 订阅小组:没人订阅了——解散
node.flags = ReactiveFlags.None;
disposeAllDepsInReverse(node);
}
// 信号:什么也不用做
}(注意:unlink 里调 unwatched 的那行,参数类型要带上 EffectScopeNode。)
第 4 步:写新测试(订阅小组)
在 tests 文件夹里新建 scope.spec.ts:
ts
import { describe, expect, test } from 'vitest';
import { signal, effect, effectScope } from '../src/signal';
describe('订阅小组', () => {
test('解散后,组里的订报人不再响应', () => {
const logs: number[] = [];
const count = signal(1);
const stopScope = effectScope(() => {
effect(() => {
logs.push(count());
});
});
expect(logs).toEqual([1]);
count(2);
expect(logs).toEqual([1, 2]);
stopScope(); // 解散!
count(3);
expect(logs).toEqual([1, 2]); // 不再响应
});
test('套娃:解散外层,里外一起解散', () => {
const logs: string[] = [];
const a = signal(1);
const b = signal(10);
const stopOuter = effectScope(() => {
effect(() => {
logs.push(`e1:${a()}`);
});
effectScope(() => {
effect(() => {
logs.push(`e2:${b()}`);
});
});
});
expect(logs).toEqual(['e1:1', 'e2:10']);
a(2);
expect(logs).toEqual(['e1:1', 'e2:10', 'e1:2']);
stopOuter(); // 解散外层 → 内层小组也一起解散
b(20);
expect(logs).toEqual(['e1:1', 'e2:10', 'e1:2']); // e2 也不响应了
});
test('解散时,组里订报人的清理函数会执行', () => {
const log: string[] = [];
const stopScope = effectScope(() => {
effect(() => {
log.push('run');
return () => log.push('cleanup');
});
});
expect(log).toEqual(['run']);
stopScope();
expect(log).toEqual(['run', 'cleanup']);
});
});三个测试:解散后不再响应、套娃一锅端、清理函数照跑。
第 5 步:叫检查员!
bash
npm test🚀 现在跑一下,你应该看到:
✓ 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 11 passed (11) Tests 33 passed (33)33 个,全绿。 蛋糕店的"促销季"可以开张了——结束的时候,按一个按钮,整批订报人一起下班。🎉
🧰 TS 小课堂:闭包(函数记住出生时的身边)
今天没有新语法,但有一个藏了一路的秘密——从第 3 章起,我们的信号库就靠它活着:闭包(closure)。
看 effectScope 的结尾:
ts
// 解散按钮:组里所有订报人一起辞职
return () => {
disposeScope(node);
};这个返回的"解散按钮"函数,记住了 node——即使 effectScope 早就跑完返回了,按钮里还是能用到小组的 node。
这就是闭包:函数会记住它出生时身边的变量。出生在 effectScope 里的按钮函数,把 node 记在了心里;不管以后把它带到哪、隔多久按,它都认得自己的小组。
ts
const stopScope = effectScope(() => { /* …… */ });
// 隔了十行、隔了十秒:
stopScope(); // 它还记得 group 是哪个小组其实,信号库从头到尾都是闭包搭的:
signal(1)返回的"公告牌"函数,记住了node(第 3 章);effect的run闭包,记住了订报人node(第 3 章);- 第 15 章的辞职按钮,记住了订报人(第 15 章);
- 今天的解散按钮,记住了小组(第 16 章)。
闭包让"函数带上自己的记忆"到处走——没有它,消息城寸步难行。
📚 消息城词典
| 词 | 意思 | 记住它 |
|---|---|---|
| 订阅小组 (effectScope) | 把一批订报人打包的容器 | 自己不干活,只当"爸爸" |
| 解散按钮 | 按一下,整组订报人一起辞职 | 一个按钮顶一群按钮 |
| 套娃 | 小组里再开小组 | 解散外层,里外一起散 |
| 闭包 (closure) | 函数记住出生时身边的变量 | 按钮记得自己的小组 |
订阅小组是正式 API 名;解散按钮/套娃是本章用语。
📦 章末完整代码
这一章你没有新文件,signal.ts 加了三小块 + 一个新测试文件。文件夹结构:
signal-book/
├── demo/
│ └── ch01.mjs
├── node_modules/
├── package.json
├── tsconfig.json
├── src/
│ ├── money.ts
│ └── signal.ts ← 加了:EffectScopeNode + effectScope + disposeScope + unwatched 分支
└── 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 ← 今天的新测试src/signal.ts 的其他部分和第 15 章那份一样,今天加了四处:
EffectScopeNode接口(第 1 步那份);effectScope函数(第 2 步那份);disposeScope函数(第 3 步那份);unwatched的签名加上EffectScopeNode,并加了"订阅小组:解散"分支(第 3 步那份)。
tests/scope.spec.ts 的完整内容,就是上面"第 4 步"抄的那份——抄全了就不用再看。
✅ 跑通了? 只要
npm test打出33 passed,订阅小组就开张了——一个按钮,整组下班。
🎮 动手试试
怎么玩
先自己写答案,再点开对照。忍得住才点开哦!
1. 预言输出(猜一猜)
小铃开了一个订阅小组,组里挂了一个订报人。猜猜 logs 最后长什么样?
ts
const logs: number[] = [];
const a = signal(1);
const stop = effectScope(() => {
effect(() => logs.push(a()));
});
a(2);
stop();
a(3);👉 点开看答案
logs 是 [1, 2]。
- 组里订报人出生:推入 1;
a(2):订报人响应,推入 2;stop():解散小组,订报人辞职;a(3):订报人已经不在了,不再响应 → 没有第三条。
一个解散按钮,顶一群辞职按钮。
2. 帮小铃找 bug(抓错)
小铃写 effectScope 时,忘了"当耳朵":
ts
// 咦?当耳朵的两行呢?
try {
fn(); // 组里的订报人在这里出生
} finally {
activeSub = prevSub;
}结果:scope.spec.ts 第一个测试红了——解散之后,组里的订报人还在响应。为什么?
👉 点开看答案
因为订报人出生时,activeSub 不是小组——它没有把自己登记成小组的孩子。
第 15 章的机制:订报人出生时,看 activeSub 是谁,就把自己登记成谁的孩子。小组不"当耳朵",订报人就登记成了上一个正在跑的人(或者谁都不是)。
结果:解散小组时,disposeAllDepsInReverse 沿着小组的依赖链走——链上一个孩子都没有——当然送不走任何订报人,它们还在响应。
补上那两行就好:
ts
const prevSub = activeSub;
activeSub = node; // 当耳朵:组里的订报人登记成我的孩子
try {
fn();
} finally {
activeSub = prevSub;
}3. 填空(补全)
effectScope 里"当耳朵"少了一行。补全它:
ts
const prevSub = activeSub;
activeSub = /* 这里该写什么? */;👉 点开看答案
写 node:
ts
activeSub = node;把耳朵挂到小组自己身上——这样组里出生的订报人,都会把自己登记成小组的孩子。
4. 小挑战(促销季管理)
用订阅小组管理一次"促销季":小组里挂两个订报人(一个看面粉价、一个看糖价),都带清理函数(打印 关 面粉 / 关 糖)。验证:
- 面粉、糖各变一次,两个订报人都响应;
- 解散小组:两个订报人一起辞职,清理函数都跑了(打印两条"关");
- 再改面粉价,谁都不响应。
👉 点开看答案
一种写法:
ts
const log: string[] = [];
const flour = signal(2);
const sugar = signal(3);
const stopPromo = effectScope(() => {
effect(() => {
log.push(`面粉 ${flour()}`);
return () => log.push('关 面粉');
});
effect(() => {
log.push(`糖 ${sugar()}`);
return () => log.push('关 糖');
});
});
flour(5); // log: 面粉 5
sugar(1); // log: 糖 1
expect(log).toEqual(['面粉 2', '糖 3', '面粉 5', '糖 1']);
stopPromo(); // 解散:后创建的先走 → 关 糖 → 关 面粉
expect(log).toEqual(['面粉 2', '糖 3', '面粉 5', '糖 1', '关 糖', '关 面粉']);
flour(9); // 谁都不响应
expect(log.length).toBe(6);注意清理顺序:糖 是后创建的,所以先关糖、再关面粉——LIFO 在小组里一样管用。
🏁 本章小结
- 订阅小组(effectScope)是容器不是订报人——它当"耳朵",让组里出生的订报人自动登记成它的孩子;
- 解散按钮(disposeScope)用第 15 章的"送孩子"机制:从链尾往前送,清理函数照跑、LIFO 不破——一个按钮顶一群按钮;
- 套娃:小组里再开小组,解散外层,里外一起解散——33 个测试全绿。
蛋糕店的促销季办得红红火火。可老墨又出了一道新题:
订报人订的是"会变的消息"——可有的消息,是外人直接改的,根本没走信号。怎么办?
下一章,消息城要开一门新业务:手动发刊(trigger)。