Appearance
第 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 里出了错,测试就乱了——后面的读全都不订阅了。为什么?
👉 点开看答案
因为没有 finally,fn 一抛错,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(参考值,只是看一眼)。验证:
- 改
count→ 订报人醒; - 改
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 读到的是新值——"看一眼"看的是最新,只是不当听众。
🏁 本章小结
- 捂住耳朵(
untracked)让订报人"看一眼,不想订阅"——读信号不登记,靠setActiveSub摘/戴耳朵; - 换耳朵(
setActiveSub)是低层工具:收新耳朵、还旧耳朵——真实库也公开着它; - 递归自动反应的回归测试确认:内层订报人怎么折腾,外层都照常响应——42 个测试全绿。
消息城的功能,齐了。老墨摊开最后一张图纸,上面画着一辆车和一台引擎:
引擎和整车,是分开的。
下一章,把信号库拆成"引擎"(system)和"整车"(index)——引擎可以插到任何整车上去用。