Skip to content

第 16 章 订阅小组:effectScope

📖 一个订报人太孤单

"老墨,"小铃这天抱着一摞代码进来,"消息城遇到个新需求——蛋糕店要搞'促销季',一开就是一个月,期间有一堆订报人在盯着各种价格。可促销季一结束,这些订报人全都该走——总不能一个一个按辞职按钮吧?"

老墨笑了:"你是想要一个总按钮——按一下,这一批订报人一起辞职?"

"对对对!"

"消息城早就有这个本事了。"老墨在纸上画了一个大圆圈,圈里装着一排小订报人,"它叫订阅小组(effectScope)——一个'小组',把一批订报人打包。组里出生的订报人,自动登记成小组的孩子;按下小组的解散按钮,组里所有订报人一起辞职——孩子们的清理函数照跑,一个都不漏。"

"而且,"老墨又画了一个圈中圈,"小组里还能再开小组——套娃。解散外层小组,里里外外一起解散。"

小铃眼睛发亮:"那这个小组,到底是什么?"

"它不是一个订报人——它只是一个容器。真正的订报人照常在组里出生,只是它们的'爸爸'从'单个订报人'变成了'这个小组'。上一章我们学了:爸爸辞职,先送走孩子。现在爸爸可以是一个小组——送走的,就是整批孩子。"

🎯 本章要解决什么

一句话问题:怎么把一批订报人打包成订阅小组,按一个按钮全部解散?

读完这一章,你会知道:

  • effectScope 是什么——一个容器,不是订报人;
  • 组里出生的订报人怎么自动登记成"小组的孩子";
  • 解散按钮:按一下,整组一起辞职(清理函数照跑,LIFO 不破)。

💡 概念讲解

小组是个容器

mermaid
graph TD
    scope["订阅小组 📦(容器)"] --> e1["订报人 ① 👶"]
    scope --> e2["订报人 ② 👶"]
    scope --> sub["小组 ② 📦(套娃)"]
    sub --> e3["订报人 ③ 👶"]

小组自己不干活——它没有规则、没有清理函数。它只做两件事:

  1. 当"耳朵":组里的订报人出生时,activeSub 就是小组——于是订报人自动把自己登记成小组的孩子(第 15 章的"爸爸"机制,爸爸换成小组);
  2. 当"总辞职按钮":解散小组,就是 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 章);
  • effectrun 闭包,记住了订报人 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 章那份一样,今天加了四处:

  1. EffectScopeNode 接口(第 1 步那份);
  2. effectScope 函数(第 2 步那份);
  3. disposeScope 函数(第 3 步那份);
  4. 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. 小挑战(促销季管理)

用订阅小组管理一次"促销季":小组里挂两个订报人(一个看面粉价、一个看糖价),都带清理函数(打印 关 面粉 / 关 糖)。验证:

  1. 面粉、糖各变一次,两个订报人都响应;
  2. 解散小组:两个订报人一起辞职,清理函数都跑了(打印两条"关");
  3. 再改面粉价,谁都不响应。
👉 点开看答案

一种写法:

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 在小组里一样管用。

🏁 本章小结

  1. 订阅小组(effectScope)是容器不是订报人——它当"耳朵",让组里出生的订报人自动登记成它的孩子;
  2. 解散按钮(disposeScope)用第 15 章的"送孩子"机制:从链尾往前送,清理函数照跑、LIFO 不破——一个按钮顶一群按钮;
  3. 套娃:小组里再开小组,解散外层,里外一起解散——33 个测试全绿

蛋糕店的促销季办得红红火火。可老墨又出了一道新题:

订报人订的是"会变的消息"——可有的消息,是外人直接改的,根本没走信号。怎么办?

下一章,消息城要开一门新业务:手动发刊(trigger)。