Skip to content

第 19 章 捂住耳朵:untracked 与递归自动反应

📖 看一眼,不想订阅

"老墨,"小铃这天碰上一个怪需求,"订报人有时候只想看一眼某个信号——比如在日志里查个数——可它不想因此订阅。它一读,就登记成了听众,消息一变就被叫醒,白跑一趟!"

"你形容得真准——看一眼,不想订阅。"老墨笑了,"消息城早有办法:捂住耳朵。"

"捂住耳朵?"

"对。读信号的时候,耳朵本来是'挂着'的——信号一读,就把正在跑的人记成听众。捂住耳朵读,就是把耳朵暂时摘下来:信号照样读,但不登记订阅——看完,耳朵再戴回去。"

老墨在纸上写:

ts
untracked(() => b());   // 捂住耳朵读 b:读到值,但不订阅

"这就是 untracked——'不受追踪'的读。它靠一个更小的工具干活:setActiveSub——临时换耳朵。"

"换耳朵?"

"对。setActiveSub(undefined) 就是'把耳朵摘下来',setActiveSub(prev) 就是'把耳朵戴回去'。untracked 只是把这个动作包成了一个小函数。真实库把 setActiveSub 也公开着——有些框架要用它自己造'捂住耳朵'的花样。"

"那'递归自动反应'呢?"

"那是另一件事——订报人套订报人,消息一变,里里外外的配合。第 15 章我们造好了这套机制,今天补上一条回归测试,确认它在各种折腾下都不坏。"

🎯 本章要解决什么

一句话问题:怎么"看一眼信号却不订阅"?嵌套订报人的配合怎么保证不坏?

读完这一章,你会知道:

  • untracked:捂住耳朵读——读到值,不登记订阅;
  • setActiveSub:换耳朵的低层工具(untracked 的地基);
  • 递归自动反应的一条回归测试:内层订报人怎么折腾,外层都照常响应。

💡 概念讲解

捂住耳朵(untracked)

订报人读信号,靠的是耳朵(activeSub)——信号看到耳朵,就把正在跑的人记成听众:

mermaid
graph LR
    read["订报人读 b()"] --> ear["耳朵挂着 👂<br/>activeSub = 订报人"] --> subscribe["b 记下订报人 📝"]
    read2["订报人 untracked 读 b()"] --> ear2["耳朵摘下 🙉<br/>activeSub = undefined"] --> nothing["b 不记任何人 ✅"]

untracked(() => b())——读的时候耳朵是摘下的:值照读,订阅不登记

换耳朵(setActiveSub)

untracked 的地基是一个更小的工具——换耳朵

ts
const prev = setActiveSub(undefined);   // 摘下耳朵(返回旧耳朵)
// ……读信号:不订阅……
setActiveSub(prev);                     // 戴上耳朵

setActiveSub 收一个"新耳朵",返回旧的——这样换完还能换回来。

递归自动反应(回归测试)

订报人套订报人,第 15 章造好了。今天补一条回归测试——它模拟一个刁钻场景:

内层订报人自己的消息变了(内层先醒、先跑)——然后外层的消息也变了,外层还要照常响应,而且旧的内层要按规矩被送走、新内层出生。

这条测试来自真实库的一个 bug(issue #115)——我们造好机制后,用它确认"里里外外都不坏"。

✍️ 动手写代码

今天的代码很少:两个小函数。重点是测试——它们把"捂住耳朵"和"递归自动反应"钉死。

第 1 步:写换耳朵 + 捂住耳朵

打开 src/signal.ts,在 getActiveSub 旁边加两个函数:

ts
// 换耳朵:收一个"新耳朵",返回旧的(untracked 的地基)
export function setActiveSub(sub: ReactiveNode | undefined): ReactiveNode | undefined {
	const prevSub = activeSub;
	activeSub = sub;
	return prevSub;
}

// 捂住耳朵:读信号,但不登记订阅
export function untracked<T>(fn: () => T): T {
	const prevSub = setActiveSub(undefined);   // 摘下耳朵
	try {
		return fn();                           // 读——不订阅
	} finally {
		setActiveSub(prevSub);                 // 戴上耳朵
	}
}

对照"换耳朵"看:setActiveSub 就是第 13 章 getActiveSub 的"写"版本——getActiveSub 看耳朵,setActiveSub 换耳朵。

第 2 步:写新测试(捂住耳朵 + 递归自动反应)

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

ts
import { describe, expect, test } from 'vitest';
import { signal, effect, untracked, setActiveSub } from '../src/signal';

describe('捂住耳朵', () => {
	test('untracked 里读的信号不登记订阅', () => {
		const logs: number[] = [];
		const a = signal(1);
		const b = signal(10);
		effect(() => {
			logs.push(a() + untracked(() => b()));   // b 捂着耳朵读
		});
		expect(logs).toEqual([11]);

		a(2);
		expect(logs).toEqual([11, 12]);   // a 变了,醒

		b(20);   // b 没被登记 → 不醒
		expect(logs).toEqual([11, 12]);
	});

	test('setActiveSub 能临时换耳朵', () => {
		const logs: number[] = [];
		const a = signal(1);
		effect(() => {
			const prev = setActiveSub(undefined);   // 摘下耳朵
			try {
				logs.push(a());
			} finally {
				setActiveSub(prev);   // 戴上耳朵
			}
		});
		expect(logs).toEqual([1]);

		a(2);
		expect(logs).toEqual([1]);   // 从没登记订阅,不醒
	});
});

describe('递归自动反应(回归)', () => {
	test('内层折腾后,外层照常响应自己的消息', () => {
		const a = signal(0);
		const b = signal(0);
		let outerRuns = 0;
		let innerRuns = 0;

		effect(() => {
			a();
			outerRuns++;
			effect(() => {
				b();
				innerRuns++;
			});
		});
		expect(outerRuns).toBe(1);
		expect(innerRuns).toBe(1);

		b(1);   // 只有内层的消息变了
		expect(outerRuns).toBe(1);   // 外层不白跑
		expect(innerRuns).toBeGreaterThanOrEqual(2);

		a(1);   // 外层的消息也变了
		expect(outerRuns).toBe(2);   // 外层照常响应
	});
});

三个测试:第一个和第二个是"捂住耳朵"的两种写法(untracked 包好的 / setActiveSub 自己换);第三个是递归自动反应的回归测试——内层怎么折腾,外层都照常响应

第 3 步:叫检查员!

bash
npm test

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

✓ tests/untracked.spec.ts (3 tests)
✓ 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  14 passed (14)
     Tests  42 passed (42)

42 个,全绿。 订报人学会了"看一眼,不想订阅"——消息城又添一门本事。🎉

🧰 TS 小课堂:泛型复习——装进去什么,还出来什么

第 3 章我们学过泛型 <T>——signal<T> 是"口袋,装什么全函数认"。今天 untracked 用了一个新花样

ts
export function untracked<T>(fn: () => T): T {
	// …… fn() 的结果原样还出来
}

T 出现了两次

  • 参数里:fn: () => T——传进来的函数要返回什么T 就是什么;
  • 返回值:: T——untracked 原样还出 fn 的结果。

"装进去什么类型,还出来什么类型"——untracked 不碰 fn 的结果,只是"捂着耳朵护送"它走一趟:

ts
untracked(() => 42);          // T = number → 返回 42
untracked(() => '小铃');       // T = string → 返回 '小铃'
untracked(() => [1, 2]);      // T = number[] → 返回 [1, 2]

对比一下:

函数T 的用法
signal<T>(initial: T)装进去(T 是信号存的值)
untracked<T>(fn: () => T): T装进去什么,还出来什么(T 是护送的值)

泛型不只是"口袋",还是"通行证"——它让 untracked 在不知道具体类型的情况下,也能安全地把值原样送回。

📚 消息城词典

意思记住它
捂住耳朵 (untracked)读信号但不登记订阅看一眼,不想订阅
换耳朵 (setActiveSub)收一个新耳朵、返回旧耳朵untracked 的地基
摘下/戴上换耳朵的两个动作摘下来读,读完好戴回去

捂住耳朵/换耳朵是正式消息城术语,会同步进附录·消息城词典

📦 章末完整代码

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

signal-book/
├── demo/
│   └── ch01.mjs
├── node_modules/
├── package.json
├── tsconfig.json
├── src/
│   ├── money.ts
│   └── signal.ts      ← 加了:setActiveSub + untracked
└── 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
    └── untracked.spec.ts   ← 今天的新测试

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

ts
// 换耳朵:收一个"新耳朵",返回旧的(untracked 的地基)
export function setActiveSub(sub: ReactiveNode | undefined): ReactiveNode | undefined {
	const prevSub = activeSub;
	activeSub = sub;
	return prevSub;
}

// 捂住耳朵:读信号,但不登记订阅
export function untracked<T>(fn: () => T): T {
	const prevSub = setActiveSub(undefined);   // 摘下耳朵
	try {
		return fn();                           // 读——不订阅
	} finally {
		setActiveSub(prevSub);                 // 戴上耳朵
	}
}

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

跑通了? 只要 npm test 打出 42 passed,订报人就学会捂住耳朵了——看一眼,不想订阅

🎮 动手试试

怎么玩

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

1. 预言输出(猜一猜)

小铃的订报人捂着耳朵读 a,正常读 b。猜猜 logs 最后长什么样?

ts
const logs: number[] = [];
const a = signal(1);
const b = signal(10);

effect(() => {
	logs.push(untracked(() => a()) + b());
});

a(2);
b(20);
👉 点开看答案

logs[11, 21]

  • 注册:untracked(a()) = 1(不订阅)+ b() = 10 → 11;
  • a(2)a 从没被登记,不醒
  • b(20)b 是正常读的,醒 → untracked(a()) = 1 + 20 = 21。

捂住耳朵的 a 变了不醒,正常读的 b 变了醒——看一眼和订阅,分得清清楚楚。

2. 帮小铃找 bug(抓错)

小铃写 untracked 时,把"戴上耳朵"忘了——没有 finally

ts
export function untracked<T>(fn: () => T): T {
	const prevSub = setActiveSub(undefined);   // 摘下耳朵
	const result = fn();                       // 读——不订阅
	setActiveSub(prevSub);                     // 戴上耳朵(但出了错就没这句了)
	return result;
}

平时看着没事,可一旦 fn出了错,测试就乱了——后面的读全都不订阅了。为什么?

👉 点开看答案

因为没有 finallyfn 一抛错,setActiveSub(prevSub) 根本不会执行——耳朵永远摘着。

耳朵一摘,接下来这个订报人所有的读都不登记订阅——它变成"聋子"了,消息一变再也不醒,后面的测试全乱。

finally 的作用就是**"不管发生什么,最后一定要戴回耳朵"**:

ts
	try {
		return fn();
	} finally {
		setActiveSub(prevSub);   // 一定执行!
	}

3. 填空(补全)

setActiveSub 少了一行。补全它:

ts
export function setActiveSub(sub: ReactiveNode | undefined): ReactiveNode | undefined {
	const prevSub = activeSub;
	activeSub = /* 这里该写什么? */;
	return prevSub;
}
👉 点开看答案

sub

ts
	activeSub = sub;

把耳朵换成新耳朵sub),旧的(prevSub)留着还回去——这就是"换耳朵":换下来、还给旧主,用完再换回来。

4. 小挑战(只读不订)

untracked 造一个"参考配置"场景:订报人正常读 count(真依赖),但捂着耳朵读 config(参考值,只是看一眼)。验证:

  1. count → 订报人醒;
  2. config → 订报人不醒,但下次醒的时候读到的 config 是新值。
👉 点开看答案

一种写法:

ts
const logs: string[] = [];
const count = signal(1);
const config = signal('v1');

effect(() => {
	logs.push(`count=${count()} config=${untracked(() => config())}`);
});
expect(logs).toEqual(['count=1 config=v1']);

count(2);            // 真依赖变了 → 醒
expect(logs).toEqual(['count=1 config=v1', 'count=2 config=v1']);

config('v2');        // 参考值变了 → 不醒
expect(logs.length).toBe(2);

count(3);            // 下次醒,读到新的 config
expect(logs[2]).toBe('count=3 config=v2');

config 变了订报人不醒(没订阅),但下次醒时 untracked 读到的是新值——"看一眼"看的是最新,只是不当听众。

🏁 本章小结

  1. 捂住耳朵untracked)让订报人"看一眼,不想订阅"——读信号不登记,靠 setActiveSub 摘/戴耳朵;
  2. 换耳朵setActiveSub)是低层工具:收新耳朵、还旧耳朵——真实库也公开着它;
  3. 递归自动反应的回归测试确认:内层订报人怎么折腾,外层都照常响应——42 个测试全绿

消息城的功能,齐了。老墨摊开最后一张图纸,上面画着一辆和一台引擎

引擎和整车,是分开的。

下一章,把信号库拆成"引擎"(system)和"整车"(index)——引擎可以插到任何整车上去用。