Appearance
第 9 章 核实:checkDirty
📖 白算了一整圈
传播上线后的第三天,消息城一切顺利。直到小铃盯着一块价格牌看了半天,发现了一个古怪的现象。
这块牌是蛋糕店的中转站:蛋糕价 = 面粉价 × 3。小铃做了个小实验:
ts
startBatch();
flour(5); // 面粉涨价到 5
flour(3); // 又跌回 3
endBatch();"面粉涨上去又跌回来,蛋糕价应该还是 9 元。"小铃自言自语,"可是订报人一问,中转站还是老老实实把规则重算了一遍——结果算出来还是 9 元,白忙活!"
她跑去跟老墨汇报:"中转站有时候会白算!明明结果根本没变,它还是把整个规则从头算一遍。"
老墨放下茶杯,笑了:"你观察得很准。知道为什么吗?"
"因为……它被盖了'待核实'章,就得重算?"
"对。'待核实'只说'可能有变',可没说'真的变了'。面粉涨上去又跌回来——净变化是零——中转站却不管,照样重算。"老墨在纸上画了一个电话,"所以,我们要教它一件事:被问到的时候,先打电话核实——问依赖们'你们真的变了吗?',真的变了才重算。"
"这个'先核实再算'的本事,"小铃眼睛一亮,"就是你说的 checkDirty!"
"没错。今天这一章,消息城的中转站要学会打电话。"
🎯 本章要解决什么
一句话问题:怎么让中转站先核实、再重算——依赖没真变,就不白算?
读完这一章,你会知道:
- 信号为什么要有两格消息(新消息 / 旧消息);
- 过期(Dirty)这个章,到底盖在谁身上、什么时候盖;
- 核实(checkDirty)怎么沿着依赖链一层一层往下打电话;
- 浅传播(shallowPropagate)这个小帮手是干什么的。
💡 概念讲解
信号的"两格信箱"
要判断"真的变了吗",信号得记住两样东西:
mermaid
graph LR
box["信号的两格信箱 📮"]
box --> cur["旧消息 currentValue<br/>上一次被'消费'掉的消息"]
box --> pen["新消息 pendingValue<br/>最新写进来的消息"]- 新消息(pendingValue):每次写(
flour(5)),新消息进这一格; - 旧消息(currentValue):上一次被消费(被核实过)的消息。
想判断"真的变了吗"?把两格对一对:一样 = 没真变;不一样 = 真变了。
面粉 3 → 5 → 3:新消息格是 3,旧消息格还是 3——一样,没真变。白算,可以免了。
过期(Dirty):盖在"有新消息没消费"的信号上
从这一章起,信号一被写(有了新消息),就盖上**过期(Dirty)**的章——意思是:"我这里有新消息还没被消费,来核实我!"
等到有人来核实(update 把它消费掉),才摘掉过期章。
核实(checkDirty):一层一层打电话
中转站被问到(被读到)时,不再直接重算——先沿着依赖链打电话:
- 打到信号:看它有没有过期章。有 → 让它把新旧消息对一对:真变了 → 重算;没真变 → 不重算;
- 打到中转站:它盖着"待核实"章?→ 再往它的依赖打(一层一层往下问)——问到底,真的变了才重算。
mermaid
graph LR
ask["订报人读到中转站 🧒"] --> check{"它盖着'待核实'章?"}
check -- "没盖" --> out["直接报出缓存的值"]
check -- "盖着" --> call["打电话核实 📞"]
call --> q1{"依赖是信号?"}
q1 -- "是" --> diff["新旧消息对一对"]
diff -- "真变了" --> recompute["重算 🧮"]
diff -- "没变" --> skip["不重算,摘掉章"]
q1 -- "是计算值" --> deeper["再往它的依赖打电话 🔁"]
deeper --> q1浅传播(shallowPropagate):给"待核实"升级
还有一个小帮手:当一个信号真的变了,光告诉当前这个中转站还不够——它所有盖着"待核实"章的听众,都应该升级成"过期"(因为它们的依赖真的变了,被问到就该直接重算,不用再打电话)。
ts
// 浅传播:把一层听众的"待核实"章升级成"过期"章这个小动作只传一层,所以叫"浅"传播。深的那层,靠核实里的递归去问。
✍️ 动手写代码
今天是"消息城装电话"的一天。要改的地方不少,跟着一步步来。
不想一步步抄?
每一步都有讲解。抄到一半乱了,直接跳"章末完整代码",那里有整份文件。
第 1 步:给信号换"两格信箱"
打开 src/signal.ts。先把 SignalNode 的图纸改掉——value 一格里放不下两样东西,拆成两格:
ts
// 信号节点:公告牌(有两格信箱:旧消息 + 新消息)
interface SignalNode extends ReactiveNode {
currentValue: unknown; // 旧消息:上一次被消费的
pendingValue: unknown; // 新消息:最新写进来的
}然后改 signal 函数:建节点时两格都放初始值;写只进新消息格(顺便盖"过期"章);读报出新消息格(不消费):
ts
export function signal<T>(initialValue: T) {
const node: SignalNode = {
currentValue: initialValue, // 旧消息格
pendingValue: initialValue, // 新消息格
flags: ReactiveFlags.Mutable,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
};
const read = (): T => {
if (activeSub !== undefined) {
link(node, activeSub); // 记一笔:这位在听我
}
return node.pendingValue as T; // 报出最新消息(不消费)
};
const write = (next: T): void => {
if (node.pendingValue === next) {
return; // 新消息没变?那就不发通知
}
node.pendingValue = next; // 新消息进格
node.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty; // 盖上"过期"章
if (node.subs !== undefined) {
propagate(node.subs); // 传播:盖章、排队
}
if (batchDepth === 0) {
flush(); // 没在攒批?立刻发刊
}
};
const signalFn = function (next?: T): T {
if (arguments.length > 0) {
write(next as T);
return node.pendingValue as T;
}
return read();
};
return signalFn;
}读不消费,是什么意思?
flour() 只把新消息报出来,不把旧消息格更新——"消费"(对一对新旧)是核实(update)的活。
这样设计有个大好处:不管订报人先读哪个信号、后读哪个,谁都没偷偷'消费'掉别人的核实证据——顺序乱不了。
第 2 步:让 update 变成"认人的调度员"
update 现在要认两种人:计算值(重算)和信号(对一对新旧消息)。而且无论哪种,真变了就浅传播:
ts
// 重算/消费:计算值重算一遍,信号把新旧消息对一对;返回"真变了吗"
function update(node: SignalNode | ComputedNode): boolean {
if ('getter' in node) {
// —— 计算值:重算 ——
const oldValue = node.value;
purgeAllDeps(node); // ① 清掉旧订阅
const prev = activeSub;
activeSub = node; // ② 举起耳朵
try {
node.value = node.getter(); // ③ 按规则算!
node.hasValue = true;
} finally {
activeSub = prev; // ④ 把耳朵还回去
}
node.flags = ReactiveFlags.Mutable; // ⑤ 算完恢复"干净"
const changed = oldValue !== node.value;
if (changed && node.subs !== undefined) {
shallowPropagate(node.subs); // 真变了:给听众升级"过期"章
}
return changed;
}
// —— 信号:消费新消息,比较新旧 ——
node.flags = ReactiveFlags.Mutable; // 摘掉"过期"章
const changed = node.currentValue !== node.pendingValue;
node.currentValue = node.pendingValue; // 旧消息格 = 新消息格(消费掉)
if (changed && node.subs !== undefined) {
shallowPropagate(node.subs); // 真变了:给听众升级"过期"章
}
return changed;
}注意信号分支的关键三行:
ts
const changed = node.currentValue !== node.pendingValue;
node.currentValue = node.pendingValue;
return changed;读法:"新旧对一对——不一样,说明真变了;对完,旧消息格跟上新消息格。" 面粉 3 → 5 → 3,最后核实的时候新旧都是 3——changed 是 false——不重算!这就是"白算"的解药。
第 3 步:写浅传播(shallowPropagate)
在 update 前面,加一个小帮手——它只做一件事:把听众的"待核实"章升级成"过期"章(不排队、不重算,只传一层):
ts
// 浅传播:给一层听众的"待核实"章升级成"过期"章
function shallowPropagate(link: Link): void {
let l: Link | undefined = link;
while (l !== undefined) {
const sub = l.sub;
if ((sub.flags & (ReactiveFlags.Pending | ReactiveFlags.Dirty)) === ReactiveFlags.Pending) {
sub.flags = sub.flags | ReactiveFlags.Dirty; // 待核实 → 过期
}
l = l.nextSub;
}
}=== ReactiveFlags.Pending 这个判断很讲究:"待核实"章在、而且"过期"章不在,才升级。已经过期的不用管,干净的也不用管。
第 4 步:写核实(checkDirty)——递归打电话
在 shallowPropagate 下面,加核实——这一章的主角。它沿着依赖链走,一层一层往下打电话:
ts
// 核实:被问到的时候,沿着依赖链挨个问"你们真的变了吗?"
function checkDirty(node: ComputedNode): boolean {
let l = node.deps;
while (l !== undefined) {
const dep = l.dep;
if (dep.flags & ReactiveFlags.Dirty) {
// 依赖盖着"过期"章:让它把新旧消息对一对
if (update(dep as SignalNode | ComputedNode)) {
return true; // 真的变了!
}
} else if (dep.flags & ReactiveFlags.Pending) {
// 依赖盖着"待核实"章:先问它的依赖们(递归!)
if (checkDirty(dep as ComputedNode)) {
if (update(dep as SignalNode | ComputedNode)) {
return true; // 它真的变了!
}
} else {
dep.flags &= ~ReactiveFlags.Pending; // 它没变,摘掉它的章
}
}
l = l.nextDep;
}
return false; // 谁都没真变——不用重算
}注意第 11 行:checkDirty(dep as ComputedNode)——核实会打电话给依赖,依赖又打电话给它的依赖……一层一层往下问,这就是递归(TS 小课堂会细讲)。
第 5 步:改计算值的"读"——先核实,再重算
computed 的 read 从"盖着章就算",升级成"先核实,再决定":
ts
const read = (): T => {
if (activeSub !== undefined) {
link(node, activeSub); // 记一笔:这位在听我
}
if (node.flags & ReactiveFlags.Dirty) {
update(node); // 过期了:直接重算
} else if (node.flags & ReactiveFlags.Pending) {
// 待核实:先打电话问依赖们真的变了吗
if (checkDirty(node)) {
update(node); // 真变了:重算
} else {
node.flags = ReactiveFlags.Mutable; // 没真变:摘掉"待核实"章
}
} else if (!node.hasValue) {
update(node); // 第一次被读:先算一遍
}
return node.value as T;
};三种情况,三种处理:
| 中转站的状态 | 处理 |
|---|---|
| 盖着过期章 | 依赖真的变了,直接重算 |
| 盖着待核实章 | 打电话核实——真变了才重算,没变就摘章 |
| 干干净净 | 报出缓存的值(或第一次读,先算一遍) |
第 6 步:写新测试(不白算)
在 tests 文件夹里新建 checkdirty.spec.ts,测一测新本事:
ts
import { describe, expect, test } from 'vitest';
import { signal, effect, computed, startBatch, endBatch } from '../src/signal';
describe('核实:不白算', () => {
test('改了又改回去,中转站一次都不白算', () => {
let runs = 0;
const flour = signal(3);
const cake = computed(() => {
runs++;
return flour() * 3;
});
effect(() => {
cake();
});
expect(runs).toBe(1); // 注册时算了一次
startBatch();
flour(5); // 涨价
flour(3); // 又跌回去——净变化是零!
endBatch();
expect(runs).toBe(1); // 一次都没白算!
expect(cake()).toBe(9);
});
test('核实会一层一层往下问(递归)', () => {
let cRuns = 0;
let c2Runs = 0;
const a = signal(2);
const c2 = computed(() => {
c2Runs++;
return a() * 10;
});
const c = computed(() => {
cRuns++;
return c2() + 1;
});
effect(() => {
c();
});
expect(cRuns).toBe(1);
expect(c2Runs).toBe(1);
startBatch();
a(3);
a(4); // 净变化:2 → 4,真的变了
endBatch();
expect(c2Runs).toBe(2); // c2 重算了一次
expect(cRuns).toBe(2); // c 也重算了一次
expect(c()).toBe(41); // 4 × 10 + 1
});
});第一个测试就是小铃发现的"白算":面粉涨上去又跌回来,中转站一次都没重算。第二个测试是"打电话打到最底层":a 真的变了(2→4),c 的核实会一路打到 c2 再打到 a,两层都重算。
第 7 步:叫检查员!
bash
npm test🚀 现在跑一下,你应该看到:
✓ 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 7 passed (7) Tests 21 passed (21)21 个测试全绿!🎉 中转站学会打电话了——谁也没白算。
🧰 TS 小课堂:递归(函数调用自己)
这一章没有新语法,但有一个新的思维:递归——函数调用自己。
看核实里的这一行:
ts
if (checkDirty(dep as ComputedNode)) { // checkDirty 在 checkDirty 里checkDirty 检查依赖,发现依赖也是计算值——于是它打电话给依赖的核实,而那个核实又会打电话给它的依赖的核实……像传话游戏,一层一层往下传,直到传到一个信号为止。
mermaid
graph TD
c["c 核实 c"] --> c2["c2 核实 c2"]
c2 --> a["a 信号:新旧对一对 📮"]递归有两个规矩,缺一不可:
- 要能"往下走":每次调用都要处理更小的范围——核实每次往下走一层依赖;
- 要有"尽头":走到底(依赖是信号,不是计算值)就不再往下调了——否则会无限套娃、把程序跑崩。
我们的核实走到信号就停(信号没有 checkDirty),尽头天然存在。
第 8 章的伏笔
第 8 章我们说过 update 的返回值"先收着"——现在派上用场了:核实就是靠它判断"依赖真变了吗"。
📚 消息城词典
| 词 | 意思 | 记住它 |
|---|---|---|
| 核实 (checkDirty) | 被问到先打电话问依赖"真的变了吗" | 一层一层往下问,真变了才重算 |
| 过期 (Dirty) | 有新消息没消费 / 依赖真变了 | 信号写完盖的章,核实才摘 |
| 浅传播 (shallowPropagate) | 把一层听众的"待核实"升级成"过期" | 只传一层,不排队不重算 |
| 新旧消息 | 信号的 currentValue / pendingValue | 两格对一对,一样=没真变 |
| 消费 | 把新消息格对进旧消息格 | update 的活,对完才知道变没变 |
📦 章末完整代码
这一章你的 signal-book 文件夹长这样(没有新文件,但 signal.ts 大改造,tests 加了一个文件):
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 ← 今天的新测试src/signal.ts 完整内容(第 9 章版):
ts
// src/signal.ts —— 核实版信号库(第 9 章)
// 小旗子(标志):告诉邮差"我现在是什么状态"
const enum ReactiveFlags {
None = 0, // 什么都没有(现在不用)
Mutable = 1, // 可变:我是会变化的消息(信号、计算值)
Watching = 2, // 盯着:我在听消息,消息一变我要跑(自动反应)
Dirty = 16, // 过期:有新消息没消费 / 依赖真变了
Pending = 32, // 待核实:这轮已经通知过我了,先别打扰
}
// 节点:消息城里的一切(公告牌、中转站、订报人)都是节点
interface ReactiveNode {
deps?: Link; // 我的依赖链(链头)
depsTail?: Link; // 我的依赖链(链尾)
subs?: Link; // 我的听众链(链头)
subsTail?: Link; // 我的听众链(链尾)
flags: ReactiveFlags; // 我的小旗子
}
// 连接线:一笔订阅
interface Link {
dep: ReactiveNode; // 被依赖的(消息源)
sub: ReactiveNode; // 依赖的(订阅者)
prevDep?: Link; // 依赖链上的前一根线
nextDep?: Link; // 依赖链上的后一根线
prevSub?: Link; // 听众链上的前一根线
nextSub?: Link; // 听众链上的后一根线
}
// 信号节点:公告牌(有两格信箱:旧消息 + 新消息)
interface SignalNode extends ReactiveNode {
currentValue: unknown; // 旧消息:上一次被消费的
pendingValue: unknown; // 新消息:最新写进来的
}
// 自动反应节点:订报人
interface EffectNode extends ReactiveNode {
fn: () => void;
run: () => void;
}
// 计算值节点:中转站
interface ComputedNode extends ReactiveNode {
getter: () => unknown;
value: unknown;
hasValue: boolean;
}
// 耳朵:现在正在跑的是谁?
let activeSub: ReactiveNode | undefined;
// 攒批深度:现在攒了几层?
let batchDepth = 0;
// 排队名单:被通知的自动反应,等着发刊时跑
const queued: (EffectNode | undefined)[] = [];
let notifyIndex = 0;
// —— 连线和摘线 ——
function link(dep: ReactiveNode, sub: ReactiveNode): void {
// 先沿着依赖链检查:这根线是不是已经连过了?
for (let l = sub.deps; l !== undefined; l = l.nextDep) {
if (l.dep === dep) {
return; // 已经连过了,别重复连
}
}
const newLink: Link = {
dep,
sub,
prevDep: sub.depsTail,
nextDep: undefined,
prevSub: dep.subsTail,
nextSub: undefined,
};
// 接进 sub 的依赖链(挂在链尾)
if (sub.depsTail !== undefined) {
sub.depsTail.nextDep = newLink;
} else {
sub.deps = newLink;
}
sub.depsTail = newLink;
// 接进 dep 的听众链(挂在链尾)
if (dep.subsTail !== undefined) {
dep.subsTail.nextSub = newLink;
} else {
dep.subs = newLink;
}
dep.subsTail = newLink;
}
function unlink(link: Link): void {
const { dep, sub, prevDep, nextDep, prevSub, nextSub } = link;
// 从 sub 的依赖链上摘下来
if (prevDep !== undefined) {
prevDep.nextDep = nextDep;
} else {
sub.deps = nextDep;
}
if (nextDep !== undefined) {
nextDep.prevDep = prevDep;
} else {
sub.depsTail = prevDep;
}
// 从 dep 的听众链上摘下来
if (prevSub !== undefined) {
prevSub.nextSub = nextSub;
} else {
dep.subs = nextSub;
}
if (nextSub !== undefined) {
nextSub.prevSub = prevSub;
} else {
dep.subsTail = prevSub;
}
}
// 清空一个节点的所有订阅(从链尾往前摘)
function purgeAllDeps(sub: ReactiveNode): void {
let l = sub.depsTail;
while (l !== undefined) {
const prev = l.prevDep;
unlink(l);
l = prev;
}
}
// —— 传播、核实 ——
// 传播:消息变了,沿着听众链一站一站走,边盖章边下传
function propagate(link: Link): void {
let l: Link | undefined = link;
while (l !== undefined) {
const next: Link | undefined = l.nextSub; // 先记住下一站——敲门时换门牌也不迷路
const sub = l.sub;
const flags = sub.flags;
// 已经"待核实"了?这轮已经通知过它,别再打扰
if (!(flags & (ReactiveFlags.Pending | ReactiveFlags.Dirty))) {
sub.flags = flags | ReactiveFlags.Pending; // 盖"待核实"章
if (flags & ReactiveFlags.Watching) {
notify(sub as EffectNode); // 自动反应:排队等跑
} else if (flags & ReactiveFlags.Mutable) {
// 计算值:不急着算!盖完章,继续往它的听众走
if (sub.subs !== undefined) {
propagate(sub.subs); // 继续往它的听众走
}
}
}
l = next; // 走下一站
}
}
// 浅传播:给一层听众的"待核实"章升级成"过期"章
function shallowPropagate(link: Link): void {
let l: Link | undefined = link;
while (l !== undefined) {
const sub = l.sub;
if ((sub.flags & (ReactiveFlags.Pending | ReactiveFlags.Dirty)) === ReactiveFlags.Pending) {
sub.flags = sub.flags | ReactiveFlags.Dirty; // 待核实 → 过期
}
l = l.nextSub;
}
}
// 重算/消费:计算值重算一遍,信号把新旧消息对一对;返回"真变了吗"
function update(node: SignalNode | ComputedNode): boolean {
if ('getter' in node) {
// —— 计算值:重算 ——
const oldValue = node.value;
purgeAllDeps(node); // ① 清掉旧订阅
const prev = activeSub;
activeSub = node; // ② 举起耳朵
try {
node.value = node.getter(); // ③ 按规则算!
node.hasValue = true;
} finally {
activeSub = prev; // ④ 把耳朵还回去
}
node.flags = ReactiveFlags.Mutable; // ⑤ 算完恢复"干净"
const changed = oldValue !== node.value;
if (changed && node.subs !== undefined) {
shallowPropagate(node.subs); // 真变了:给听众升级"过期"章
}
return changed;
}
// —— 信号:消费新消息,比较新旧 ——
node.flags = ReactiveFlags.Mutable; // 摘掉"过期"章
const changed = node.currentValue !== node.pendingValue;
node.currentValue = node.pendingValue; // 旧消息格 = 新消息格(消费掉)
if (changed && node.subs !== undefined) {
shallowPropagate(node.subs); // 真变了:给听众升级"过期"章
}
return changed;
}
// 核实:被问到的时候,沿着依赖链挨个问"你们真的变了吗?"
function checkDirty(node: ComputedNode): boolean {
let l = node.deps;
while (l !== undefined) {
const dep = l.dep;
if (dep.flags & ReactiveFlags.Dirty) {
// 依赖盖着"过期"章:让它把新旧消息对一对
if (update(dep as SignalNode | ComputedNode)) {
return true; // 真的变了!
}
} else if (dep.flags & ReactiveFlags.Pending) {
// 依赖盖着"待核实"章:先问它的依赖们(递归!)
if (checkDirty(dep as ComputedNode)) {
if (update(dep as SignalNode | ComputedNode)) {
return true; // 它真的变了!
}
} else {
dep.flags &= ~ReactiveFlags.Pending; // 它没变,摘掉它的章
}
}
l = l.nextDep;
}
return false; // 谁都没真变——不用重算
}
// 发通知:把自动反应记进排队名单
function notify(effect: EffectNode): void {
queued.push(effect);
}
// 发刊:把排队名单里的自动反应挨个跑一遍
function flush(): void {
while (notifyIndex < queued.length) {
const effect = queued[notifyIndex]!;
queued[notifyIndex++] = undefined;
effect.run();
}
notifyIndex = 0;
queued.length = 0;
}
// —— 信号 ——
export function signal<T>(initialValue: T) {
const node: SignalNode = {
currentValue: initialValue, // 旧消息格
pendingValue: initialValue, // 新消息格
flags: ReactiveFlags.Mutable,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
};
const read = (): T => {
if (activeSub !== undefined) {
link(node, activeSub); // 记一笔:这位在听我
}
return node.pendingValue as T; // 报出最新消息(不消费)
};
const write = (next: T): void => {
if (node.pendingValue === next) {
return; // 新消息没变?那就不发通知
}
node.pendingValue = next; // 新消息进格
node.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty; // 盖上"过期"章
if (node.subs !== undefined) {
propagate(node.subs); // 传播:盖章、排队
}
if (batchDepth === 0) {
flush(); // 没在攒批?立刻发刊
}
};
const signalFn = function (next?: T): T {
if (arguments.length > 0) {
write(next as T);
return node.pendingValue as T;
}
return read();
};
return signalFn;
}
// —— 自动反应 ——
export function effect(fn: () => void): void {
const node: EffectNode = {
fn,
flags: ReactiveFlags.Watching,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
run: () => {},
};
const run = (): void => {
node.flags = ReactiveFlags.Watching; // 重新戴上"盯着"章
purgeAllDeps(node); // ① 先清掉旧订阅
const prev = activeSub;
activeSub = node; // ② 举起耳朵
try {
node.fn(); // ③ 跑一遍——读到的信号都会连上我
} finally {
activeSub = prev; // ④ 把耳朵还回去
}
};
node.run = run;
run(); // 注册就立刻跑一遍(先送一期报纸)
}
// —— 计算值 ——
export function computed<T>(getter: () => T): () => T {
const node: ComputedNode = {
getter: getter as () => unknown,
value: undefined,
hasValue: false,
flags: ReactiveFlags.Mutable,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
};
const read = (): T => {
if (activeSub !== undefined) {
link(node, activeSub); // 记一笔:这位在听我
}
if (node.flags & ReactiveFlags.Dirty) {
update(node); // 过期了:直接重算
} else if (node.flags & ReactiveFlags.Pending) {
// 待核实:先打电话问依赖们真的变了吗
if (checkDirty(node)) {
update(node); // 真变了:重算
} else {
node.flags = ReactiveFlags.Mutable; // 没真变:摘掉"待核实"章
}
} else if (!node.hasValue) {
update(node); // 第一次被读:先算一遍
}
return node.value as T;
};
return read;
}
// —— 攒批 ——
export function startBatch(): void {
batchDepth++;
}
export function endBatch(): void {
batchDepth--;
if (batchDepth === 0) {
flush(); // 最外层结束,才发刊
}
}tests/checkdirty.spec.ts 的完整内容,就是上面"第 6 步"抄的那份——抄全了就不用再看。
✅ 跑通了? 只要
npm test打出21 passed,中转站就学会打电话了——谁也没白算。
🎮 动手试试
怎么玩
先自己写答案,再点开对照。忍得住才点开哦!
1. 预言输出(猜一猜)
小铃跑了一段代码,猜猜 runs 最后是几?
ts
let runs = 0;
const x = signal(1);
const y = computed(() => {
runs++;
return x() * 2;
});
effect(() => {
y();
});
startBatch();
x(2);
x(3);
x(1);
endBatch();👉 点开看答案
runs 是 1。
注册时算了一次(x=1,y=2)。攒批里 x 走了 1 → 2 → 3 → 1,净变化是零。发刊时订报人读到 y,核实打电话给 x:新旧消息对一对——都是 1,没真变——不重算。runs 从头到尾只有 1。
2. 帮小铃找 bug(抓错)
小铃写"核实"时,把"没变就摘章"的 else 分支忘了:
ts
} else if (node.flags & ReactiveFlags.Pending) {
if (checkDirty(node)) {
update(node);
}
// 少了 else 摘章!
}结果:第一次"没真变"之后,中转站再也不重算了。为什么?
👉 点开看答案
因为"待核实"章摘不掉了。
第一次核实发现没变,可章还盖着。下次信号真的变了、传播路过它——propagate 一看"已经待核实",跳过——它连核实的电话都接不到了,自然永远不重算。
所以"没真变"的那条路必须收尾:node.flags = ReactiveFlags.Mutable;——摘掉章,让它准备好迎接下一轮。
3. 填空(补全)
checkDirty 里,"依赖没变就摘掉它的章"少了一行。补全它:
ts
} else {
/* 这里该写什么? */
}👉 点开看答案
写:
ts
dep.flags &= ~ReactiveFlags.Pending;&= 是"与等于":把"待核实"那一面旗子按下去(~Pending 是把那一位变成 0)。摘掉章,下次传播才能再盖。
4. 小挑战(造三级链)
小铃搭了一条三层链:a → c1 → c2 → c3 → 订报人(每层都 +1 或 ×2 随便你)。数一数:如果 a 在攒批里走了 5 → 2(改回去),c1、c2、c3 各重算了几次?
写代码验证你的答案(给每层加计数器)。
👉 点开看答案
一次都没重算——三层计数都保持 1(只有注册那次)。
核实会一层一层往下打电话:c3 问 c2,c2 问 c1,c1 问 a——a 的新旧消息一对:还是 2,没真变。于是章从最底层一层一层摘上去,谁也没重算。这就是递归核实的威力:白算,从源头上就掐断了。
验证代码(参考):
ts
let runs1 = 0, runs2 = 0, runs3 = 0;
const a = signal(2);
const c1 = computed(() => { runs1++; return a() * 2; });
const c2 = computed(() => { runs2++; return c1() + 1; });
const c3 = computed(() => { runs3++; return c2() * 10; });
effect(() => { c3(); });
startBatch();
a(5);
a(2);
endBatch();
expect(runs1).toBe(1);
expect(runs2).toBe(1);
expect(runs3).toBe(1);
expect(c3()).toBe(50);🏁 本章小结
- 信号有了两格信箱(新消息/旧消息),"真变了吗"靠两格对一对来判断——**过期(Dirty)**章就盖在有新消息没消费的信号上;
- 核实(checkDirty)被问到先沿着依赖链递归打电话:真变了才重算,没变就一层层摘章——白算治好了;
- **浅传播(shallowPropagate)**把"待核实"升级成"过期",让真变的消息能通知到所有听众。
到这里,心脏手术的三大件——连接线、传播、核实——全部完工。下一章,是手术后的大体检:回归——跑一遍全部测试,再和当初的朴素版比一比,看看我们到底赢在哪。