Appearance
第 7 章 连接线:Link
📖 图纸上的线
第二天一大早,小铃就冲进了老墨的办公室。昨天那句"明天,你就知道了"让她一晚上没睡好。
老墨已经在桌上摊开了一卷图纸。图纸上画着消息城的缩影:公告牌、中转站、订报人,全都变成了一个个圆角方块;方块之间,连着一条一条的线。
"这就是连接线方案。"老墨指着图纸,"我问你,昨天那两个怪病,病根是什么?"
"Set 只记'谁在听',记不住顺序,也记不住状态。"小铃背得滚瓜烂熟。
"对。所以我们要换一种记录方式——把每一笔订阅,都变成一根看得见、摸得着的线。"老墨在图纸上画了一根线,"这一根线,就是一条连接线(Link):一头连着'谁在听'(订阅者),一头连着'听的是谁'(消息源)。消息城里的每一笔订阅,都有一根这样的线。"
小铃盯着图纸:"可是……它怎么记住顺序?"
"问得好。"老墨笑了,"每根线都认识它的前后邻居——上一根线是谁,下一根线是谁。所有线手拉手排成队,顺序不就记住了?而且想从队伍中间抽出任何一根线,它自己就知道前后邻居是谁,'啪'一下就能摘掉,不用惊动整队人。"
小铃的眼睛亮了。她忽然想起第 6 章的怪病:"那……'状态'呢?图纸上不是说,线上还写着第几期?"
"那是给'复用旧线'准备的。"老墨推了推眼镜,"等第 14 章,我们嫌'每次跑都重新连线'太慢的时候,才需要期号来认旧线。今天,先把线本身造出来——今天这章,我们只换'怎么记订阅',别的什么都不变。"
"什么都不变?"小铃有点不相信,"那换了干什么?"
"你想想朴素版的那个毛病:自动反应换了订阅对象,旧信号还赖着不走(订阅污染)。有了线,我们就能退订了——先清旧,再订新。这可是朴素版想做做不到的事。"老墨把图纸往前一推,"来吧,开工。记住一句话:"
消息城里的每一笔订阅,都是一根线。
🎯 本章要解决什么
一句话问题:怎么把"听众本子(Set)"换成"连接线(双向链表)"来记订阅?
读完这一章,你会知道:
- 消息城里的一切都是节点,每个节点有两条链:依赖链和听众链;
- 一根连接线(Link)长什么样,怎么连线(link)、怎么摘线(unlink);
- "先清旧订阅、再重新订阅"的规矩,治好了朴素版的订阅污染。
这章很长,也很重要
这是全书第一次真正动数据结构——心脏手术的第一刀。慢慢来,抄代码的时候一个字都别抄错。
💡 概念讲解
节点:消息城里的一切
从今天起,公告牌(信号)、中转站(计算值)、订报人(自动反应),在代码里都统称为节点(node)。
每个节点都长着一样的两条链:
mermaid
graph LR
subgraph 节点的两条链
deps["依赖链 deps<br/>我依赖谁(我的消息来源们)"]
subs["听众链 subs<br/>谁在听我(我的订阅者们)"]
end- 依赖链(deps):这根节点依赖哪些节点——小铃的"蛋糕价"依赖"面粉价"和"糖价";
- 听众链(subs):哪些节点在听这根节点——"蛋糕价"的听众是那个自动反应。
一条链,就是很多根连接线手拉手排成的队伍。
连接线:一笔订阅
一根连接线,记录一次订阅。它身上有 6 样东西:
| 字段 | 意思 | 比喻 |
|---|---|---|
dep | 被依赖的节点(消息源) | 这单订的是谁家的报纸 |
sub | 依赖的节点(订阅者) | 订报的是谁 |
prevDep | 依赖链上的前一根线 | 排队时前面那位 |
nextDep | 依赖链上的后一根线 | 排队时后面那位 |
prevSub | 听众链上的前一根线 | 在另一队里的前面那位 |
nextSub | 听众链上的后一根线 | 在另一队里的后面那位 |
一根线,同时排在两条队里:在订阅者的"依赖链"队伍里排着,也在消息源的"听众链"队伍里排着。这就是它为什么有前后两对邻居指针。
mermaid
graph LR
dep["dep 消息源 🗞️<br/>(被依赖的)"] -->|prevSub / nextSub| link["一根连接线 Link 🧵<br/>记录:谁订了谁"]
link -->|prevDep / nextDep| sub["sub 订阅者 🧒<br/>(依赖的)"]连线(link)和摘线(unlink)
连线:订阅者读到消息源的那一刻,牵起一根新线,同时接进两条队的队尾。
mermaid
graph LR
sub["sub 的依赖链"] --> d1["线①"] --> d2["线②"] --> d3["线③ 新的"]
dep["dep 的听众链"] --> s1["线①"] --> s2["线②"] --> s3["线③ 新的"]摘线:不想订阅了,把线从两条队里都抽出来。因为每根线都认识前后邻居,摘线只需要改邻居的指针,自己就退队了。
先清旧、再订新
从今天起,自动反应和计算值每次跑,都先把自己的依赖链清空,再重新订阅一遍。
跑之前:清空旧订阅(退掉所有旧报纸)
跑的时候:读到谁,就牵新线(订新的)这治好了朴素版的订阅污染:以前自动反应换了订阅对象,旧信号还赖在听众本子里不走,一改就打扰它。现在——旧线退了,新线牵上,一清二楚。
耳朵改名
还记得"耳朵"吗?它现在改名叫 activeSub 了。为什么?
因为以前只有自动反应会用耳朵(所以叫 activeEffect)。现在计算值跑规则的时候也要用——它也要让读到的信号知道"是它在听"。自动反应、计算值都是"订阅者(subscriber)",所以耳朵改叫 activeSub(正在跑的订阅者)。
名字变了,规矩没变:跑之前举起来,跑完还回去。
✍️ 动手写代码
今天要重写 src/signal.ts——这是心脏手术第一刀。别怕,我们一层一层来,每一层都有测试盯着。
不想一步步抄?
每一步都有讲解。如果抄到一半乱了,直接跳到"章末完整代码",那里有整份文件,对照着抄完就行。
第 1 步:画图纸(接口)
打开 src/signal.ts,把里面的内容全部删掉,从零开始写。先写两张"图纸":
ts
// src/signal.ts —— 链表版信号库(第 7 章)
// 节点:消息城里的一切(公告牌、中转站、订报人)都是节点
interface ReactiveNode {
deps?: Link; // 我的依赖链(链头)
depsTail?: Link; // 我的依赖链(链尾)
subs?: Link; // 我的听众链(链头)
subsTail?: Link; // 我的听众链(链尾)
}
// 连接线:一笔订阅
interface Link {
dep: ReactiveNode; // 被依赖的(消息源)
sub: ReactiveNode; // 依赖的(订阅者)
prevDep?: Link; // 依赖链上的前一根线
nextDep?: Link; // 依赖链上的后一根线
prevSub?: Link; // 听众链上的前一根线
nextSub?: Link; // 听众链上的后一根线
}注意:链有头(deps / subs)和尾(depsTail / subsTail)。新线一律从队尾接入,所以记住尾巴就够用了。
然后给三种节点各画一张图纸:
ts
// 信号节点:公告牌
interface SignalNode extends ReactiveNode {
value: unknown;
}
// 自动反应节点:订报人
interface EffectNode extends ReactiveNode {
fn: () => void;
run: () => void;
}
// 计算值节点:中转站
interface ComputedNode extends ReactiveNode {
getter: () => unknown;
value: unknown;
hasValue: boolean;
run: () => void;
}
// 会被通知的节点(听众名单上只会有这两种)
type Subscriber = EffectNode | ComputedNode;extends 的意思是"继承":SignalNode 是 ReactiveNode 的孩子,自动拥有爸爸妈妈的两条链,再添上自己的宝贝(value、fn……)。
run 是"跑一遍"的本事:自动反应和计算值都有(信号没有——信号不会"跑",它只会被读)。
然后是两个全局状态(和第 5 章一样,只是耳朵改了名):
ts
// 耳朵:现在正在跑的是谁?
let activeSub: ReactiveNode | undefined;
// 攒批状态
let batchDepth = 0;
const pending = new Set<Subscriber>();第 2 步:连线(link)、摘线(unlink)、清空(purgeAllDeps)
图纸画完了,先写三个最基础的动作。在全局状态下面追加:
ts
// —— 连线和摘线 ——
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;
}
}读懂 link 的两个动作:
- 检查:沿着
sub的依赖链走一遍,如果这根线已经连过了,直接返回——别牵重复的线(这条检查是朴素版 Set 天然就有的本事,链表的我们要自己写); - 接入:新线两头接——进
sub的依赖链队尾,同时进dep的听众链队尾。
unlink 是"摘线":把线从两条队里都抽出来。靠的就是前后邻居改指针——前面那位的"后面"改成我的"后面",后面那位的"前面"改成我的"前面",我就从队伍里消失了。
purgeAllDeps 是"清空订阅":从链尾一根一根往前摘,全部摘光。
第 3 步:信号(signal)
追加 signal——和第 5 章长得几乎一样,只是把"听众本子 Set"换成了"听众链","记名字"换成了"连线":
ts
// —— 信号 ——
export function signal<T>(initialValue: T) {
const node: SignalNode = {
value: initialValue,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
};
const read = (): T => {
if (activeSub !== undefined) {
link(node, activeSub); // 记一笔:这位在听我
}
return node.value as T;
};
const write = (next: T): void => {
node.value = next;
if (batchDepth > 0) {
// 攒批中:先记待办
for (let l = node.subs; l !== undefined; l = l.nextSub) {
pending.add(l.sub as Subscriber);
}
return;
}
// 先把听众名单抄下来,再挨个喊——免得跑的过程中名单变了
const snapshot: Subscriber[] = [];
for (let l = node.subs; l !== undefined; l = l.nextSub) {
snapshot.push(l.sub as Subscriber);
}
for (const sub of snapshot) {
sub.run();
}
};
const signalFn = function (next?: T): T {
if (arguments.length > 0) {
write(next as T);
return node.value as T;
}
return read();
};
return signalFn;
}两个新花样要讲清楚:
node.value as T:node.value在图纸上是unknown(不知道是什么类型),但我们知道它其实是T。as就是跟 TypeScript 保证:"我确定它是这个类型,你放心。"(TS 小课堂会细讲)snapshot(抄名单):通知之前,先把听众链上的人抄进一个数组,再挨个喊。为什么?因为喊的过程中,订报人可能会"退旧订新"——听众链会变。如果边走边喊,队伍一变就可能漏喊或喊重。先抄名单,再挨个喊,最保险。
第 4 步:自动反应(effect)
追加 effect——它多了一个 run,是"跑一遍"的四步曲:
ts
// —— 自动反应 ——
export function effect(fn: () => void): void {
const node: EffectNode = {
fn,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
run: () => {},
};
const run = (): void => {
purgeAllDeps(node); // ① 先清掉旧订阅
const prev = activeSub;
activeSub = node; // ② 举起耳朵
try {
node.fn(); // ③ 跑一遍——读到的信号都会连上我
} finally {
activeSub = prev; // ④ 把耳朵还回去
}
};
node.run = run;
run(); // 注册就立刻跑一遍(先送一期报纸)
}四步曲的第一拍是全新的:先清掉旧订阅。这就是"先清旧、再订新"的规矩——跑的时候读到谁,就牵新线;没读到的,旧线已经全退了。
第 5 步:计算值(computed)
追加 computed——它的 recompute 也是四步曲,只是多了一步"转发":
ts
// —— 计算值 ——
export function computed<T>(getter: () => T): () => T {
const node: ComputedNode = {
getter: getter as () => unknown,
value: undefined,
hasValue: false,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
run: () => {},
};
const recompute = (): void => {
const isFirst = !node.hasValue; // 第一次算吗?
purgeAllDeps(node); // ① 清掉旧订阅
const prev = activeSub;
activeSub = node; // ② 举起耳朵
try {
node.value = node.getter(); // ③ 按规则算!读到的信号都会连上我
node.hasValue = true;
} finally {
activeSub = prev; // ④ 把耳朵还回去
}
// ⑤ 算完了,转发给在听我的人(第一次不算"更新")
if (!isFirst) {
const snapshot: Subscriber[] = [];
for (let l = node.subs; l !== undefined; l = l.nextSub) {
snapshot.push(l.sub as Subscriber);
}
for (const sub of snapshot) {
sub.run();
}
}
};
node.run = recompute;
const read = (): T => {
if (activeSub !== undefined) {
link(node, activeSub); // 记一笔:这位在听我
}
if (!node.hasValue) {
recompute(); // 第一次被读:先算一遍
}
return node.value as T;
};
return read;
}和第 4 章相比,recompute 多了"① 清掉旧订阅",其余全是老朋友:第一次算不算"更新"(isFirst)、转发前抄名单。
第 6 步:攒批(batch)
startBatch 一字不改。endBatch 只有一处变化:待办本子里存的是节点了,所以要喊 sub.run() 而不是 sub():
ts
// —— 攒批 ——
export function startBatch(): void {
batchDepth++;
}
export function endBatch(): void {
batchDepth--;
if (batchDepth > 0) {
return;
}
while (pending.size > 0) {
const queue = [...pending];
pending.clear();
for (const sub of queue) {
sub.run();
}
}
}第 7 步:写新测试(新本事:换订阅)
在 tests 文件夹里新建 subscription.spec.ts,测一测"先清旧、再订新"这个新本事——朴素版可做不到:
ts
import { describe, expect, test } from 'vitest';
import { signal, effect, computed } from '../src/signal';
describe('新本事:换订阅(清旧订新)', () => {
test('自动反应换了订阅对象,旧信号不再打扰它', () => {
const logs: number[] = [];
const flag = signal(true);
const a = signal(1);
const b = signal(10);
effect(() => {
if (flag()) {
logs.push(a());
} else {
logs.push(b());
}
});
expect(logs).toEqual([1]);
flag(false); // 换!以后只读 b 了
expect(logs).toEqual([1, 10]);
a(2); // a 已经"退订",不该再打扰
expect(logs).toEqual([1, 10]); // ← 没有多!
});
test('计算值换了依赖,旧依赖不再触发重算', () => {
let runs = 0;
const flag = signal(true);
const a = signal(1);
const b = signal(10);
const c = computed(() => {
runs++;
return flag() ? a() : b();
});
effect(() => {
c();
});
expect(runs).toBe(1);
flag(false); // c 换依赖:以后只读 b
expect(runs).toBe(2);
a(2); // a 已退订,不该触发 c 重算
expect(runs).toBe(2);
});
});第一个测试的故事:自动反应本来只听 a,flag 一翻,它改听 b 了——这时候再改 a,不该打扰它。第二个测试是同样的故事,主角换成计算值。
第 8 步:叫检查员!
bash
npm test🚀 现在跑一下,你应该看到:
✓ 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 6 passed (6) Tests 19 passed (19)数一下我们的测试总数:15(第 5 章末)→ 17(第 6 章加 2 张诊断照片)→ 19(今天加 2 个新测试)。看到
19 passed,就对了!
(如果你看到别的数字,多半是漏抄了哪一步。对照"章末完整代码"检查 src/signal.ts。)
🧰 TS 小课堂:interface 和 as
今天有两样新语法,都是这章的顶梁柱。
1. interface(图纸)
interface 是 TypeScript 的"图纸":它规定一个对象必须长什么样。
ts
interface Link {
dep: ReactiveNode;
sub: ReactiveNode;
prevDep?: Link;
}读到这张图纸,TypeScript 就会盯着所有"自称是 Link"的对象:缺一个字段?报错。字段类型不对?报错。 图纸上的 ? 问号表示"这个字段可以有,也可以没有"。
interface 还能继承(extends):孩子自动拥有爸爸妈妈的全部字段,再添自己的:
ts
interface SignalNode extends ReactiveNode {
value: unknown; // 爸爸的两条链 + 自己的消息
}2. as(我保证它是这个类型)
node.value 的图纸类型是 unknown(不知道),但我们心里清楚它其实是 T。用 as 告诉 TypeScript:
ts
return node.value as T; // "我保证:它实际是 T,你别多问"as 是类型断言——作者向 TypeScript 的保证书。用的时候要真的确定才行,撒谎的话,运行时可能出问题。
📚 消息城词典
| 词 | 意思 | 记住它 |
|---|---|---|
| 节点 (node) | 公告牌、中转站、订报人……消息城里的一切 | 都有两条链:依赖链 + 听众链 |
| 连接线 (Link) | 一笔订阅 = 一根线 | 一头是消息源,一头是订阅者,认识前后邻居 |
| 依赖链 (deps) | 节点"我依赖谁"的队伍 | 我的消息来源们,手拉手排队 |
| 听众链 (subs) | 节点"谁在听我"的队伍 | 我的订阅者们,手拉手排队 |
| 退订 | 把旧线摘掉(unlink) | 换订阅对象时,先清旧、再订新 |
🪶 拆比喻时刻:这其实叫"双向链表"
好了,把报纸的比喻暂时放一边——我们来认识它真正的名字。
"连接线手拉手排队",在计算机科学里有一个响亮的名字:链表(linked list)。而我们的连接线两头都能走(既能顺着 next 往后走,也能顺着 prev 往回走),所以它叫双向链表(doubly linked list)。
mermaid
graph LR
a["线A"] --> b["线B"] --> c["线C"]
a -.->|prev| b
b -.->|prev| c你可能听说过"链表"这个词——它和"数组"是存储数据的两大阵营:
| 数组(Array) | 链表(Linked List) | |
|---|---|---|
| 排队的队伍 | 挨个站好,一个挨一个 | 手拉手,每人都记得前后邻居 |
| 从中间抽一个人 | 后面所有人要往前挪 | 改两个指针,立刻走人 |
| 想知道队伍多长 | 数一下 length 就知道 | 得从头走到尾数一遍 |
消息城选择链表,是因为订阅关系经常要加、要退——链表最擅长这个。而"想知道有多长"这种事,消息城根本不在乎。
从这一章起,你写的每一段代码,都是在和链表打交道。以后在别的书里再看到"双向链表",你会想:"哦,就是连接线嘛。"
📦 章末完整代码
这一章你的 signal-book 文件夹长这样:
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 ← 今天的新测试src/signal.ts 完整内容(链表版,第 1-6 步的总和):
ts
// src/signal.ts —— 链表版信号库(第 7 章)
// 节点:消息城里的一切(公告牌、中转站、订报人)都是节点
interface ReactiveNode {
deps?: Link; // 我的依赖链(链头)
depsTail?: Link; // 我的依赖链(链尾)
subs?: Link; // 我的听众链(链头)
subsTail?: Link; // 我的听众链(链尾)
}
// 连接线:一笔订阅
interface Link {
dep: ReactiveNode; // 被依赖的(消息源)
sub: ReactiveNode; // 依赖的(订阅者)
prevDep?: Link; // 依赖链上的前一根线
nextDep?: Link; // 依赖链上的后一根线
prevSub?: Link; // 听众链上的前一根线
nextSub?: Link; // 听众链上的后一根线
}
// 信号节点:公告牌
interface SignalNode extends ReactiveNode {
value: unknown;
}
// 自动反应节点:订报人
interface EffectNode extends ReactiveNode {
fn: () => void;
run: () => void;
}
// 计算值节点:中转站
interface ComputedNode extends ReactiveNode {
getter: () => unknown;
value: unknown;
hasValue: boolean;
run: () => void;
}
// 会被通知的节点(听众名单上只会有这两种)
type Subscriber = EffectNode | ComputedNode;
// 耳朵:现在正在跑的是谁?
let activeSub: ReactiveNode | undefined;
// 攒批状态
let batchDepth = 0;
const pending = new Set<Subscriber>();
// —— 连线和摘线 ——
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;
}
}
// —— 信号 ——
export function signal<T>(initialValue: T) {
const node: SignalNode = {
value: initialValue,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
};
const read = (): T => {
if (activeSub !== undefined) {
link(node, activeSub); // 记一笔:这位在听我
}
return node.value as T;
};
const write = (next: T): void => {
node.value = next;
if (batchDepth > 0) {
// 攒批中:先记待办
for (let l = node.subs; l !== undefined; l = l.nextSub) {
pending.add(l.sub as Subscriber);
}
return;
}
// 先把听众名单抄下来,再挨个喊——免得跑的过程中名单变了
const snapshot: Subscriber[] = [];
for (let l = node.subs; l !== undefined; l = l.nextSub) {
snapshot.push(l.sub as Subscriber);
}
for (const sub of snapshot) {
sub.run();
}
};
const signalFn = function (next?: T): T {
if (arguments.length > 0) {
write(next as T);
return node.value as T;
}
return read();
};
return signalFn;
}
// —— 自动反应 ——
export function effect(fn: () => void): void {
const node: EffectNode = {
fn,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
run: () => {},
};
const run = (): void => {
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,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
run: () => {},
};
const recompute = (): void => {
const isFirst = !node.hasValue; // 第一次算吗?
purgeAllDeps(node); // ① 清掉旧订阅
const prev = activeSub;
activeSub = node; // ② 举起耳朵
try {
node.value = node.getter(); // ③ 按规则算!读到的信号都会连上我
node.hasValue = true;
} finally {
activeSub = prev; // ④ 把耳朵还回去
}
// ⑤ 算完了,转发给在听我的人(第一次不算"更新")
if (!isFirst) {
const snapshot: Subscriber[] = [];
for (let l = node.subs; l !== undefined; l = l.nextSub) {
snapshot.push(l.sub as Subscriber);
}
for (const sub of snapshot) {
sub.run();
}
}
};
node.run = recompute;
const read = (): T => {
if (activeSub !== undefined) {
link(node, activeSub); // 记一笔:这位在听我
}
if (!node.hasValue) {
recompute(); // 第一次被读:先算一遍
}
return node.value as T;
};
return read;
}
// —— 攒批 ——
export function startBatch(): void {
batchDepth++;
}
export function endBatch(): void {
batchDepth--;
if (batchDepth > 0) {
return;
}
while (pending.size > 0) {
const queue = [...pending];
pending.clear();
for (const sub of queue) {
sub.run();
}
}
}tests/subscription.spec.ts 的完整内容,就是上面"第 7 步"抄的那份——抄全了就不用再看。
✅ 跑通了? 只要
npm test打出19 passed,心脏手术的第一刀就缝好了:订阅方式从 Set 换成了链表,行为一点没变,还多了一样本事(换订阅)。
🎮 动手试试
怎么玩
先自己写答案,再点开对照。忍得住才点开哦!
1. 预言输出(猜一猜)
小铃写了一段"换订阅"的代码,猜猜 logs 最后长什么样?
ts
const logs: number[] = [];
const flag = signal(true);
const a = signal(1);
const b = signal(10);
effect(() => {
logs.push(flag() ? a() : b());
});
flag(false);
a(2);
b(3);👉 点开看答案
logs 是 [1, 10, 3]。
- 注册时:flag 是 true → 读
a→ 推入 1; flag(false):自动反应换订阅——退掉a,改订b→ 推入 10;a(2):a已经被退订,不打扰(这就是新本事!);b(3):b正在被听 → 推入 3。
要是朴素版,a(2) 会再多推一条——订阅污染。链表版治好了它。
2. 帮小铃找 bug(抓错)
小铃觉得 link 里的"检查重复"太啰嗦:"反正跑之前会清空订阅,为什么还要检查?"她把它删了:
ts
function link(dep: ReactiveNode, sub: ReactiveNode): void {
// 检查重复的那段被她删了!
const newLink: Link = { /* …… */ };
// ……接进两条链……
}结果:一个自动反应在 fn 里读了两次 a(比如 a() + a()),a 一变,它跑了两遍。为什么?
👉 点开看答案
因为删了检查,每次读 a 都会牵一根新线:a() 牵一根、a() + a() 里的第二次 a() 又牵一根——a 的听众链上出现了两根连向同一个自动反应的线。
a 一变,写消息时沿着听众链走,两根线各喊一遍 → 自动反应跑两遍。
检查重复就是为了防止这种情况:同一个订阅者,同一个消息源,只允许有一根线。这段"啰嗦",是必须的。
3. 填空(补全)
unlink 从"听众链"上摘线时,要处理"如果被摘的是链尾"的情况。补全这一行:
ts
// 从 dep 的听众链上摘下来
if (prevSub !== undefined) {
prevSub.nextSub = nextSub;
} else {
dep.subs = nextSub;
}
if (nextSub !== undefined) {
nextSub.prevSub = prevSub;
} else {
/* 这里该写什么? */
}👉 点开看答案
写 dep.subsTail = prevSub;。
被摘的线没有"后面"了(nextSub 不存在),说明它是队尾——那它的"前面"(prevSub)就成了新的队尾,所以要更新 dep.subsTail。
对称地看:unlink 里 nextDep 不存在时写 sub.depsTail = prevDep,nextSub 不存在时写 dep.subsTail = prevSub——两对邻居,两个队尾,一样的道理。
4. 小挑战(画图)
用箭头画出下面这段代码里,"自动反应换订阅"前后的链:
ts
const flag = signal(true);
const a = signal(1);
const b = signal(10);
effect(() => {
if (flag()) {
a();
} else {
b();
}
});要画出:flag、a、b 三条听众链上各有什么(flag 的听众是?a 的听众是?b 的听众是?),以及"换订阅"之后有什么变化。
👉 点开看答案
换订阅前(注册跑完时):
flag 的听众链:→ [自动反应]
a 的听众链:→ [自动反应]
b 的听众链:→ (空)换订阅后(flag(false) 跑完时):
flag 的听众链:→ [自动反应]
a 的听众链:→ (空) ← 退订了!
b 的听众链:→ [自动反应] ← 新订的自动反应(订报人)的依赖链也从 flag → a 换成了 flag → b。这就是"先清旧、再订新":a 那根线被摘掉,b 那根线牵上来。画对了吗?
🏁 本章小结
- 消息城里的一切都是节点,每个节点有两条链:依赖链(我依赖谁)和听众链(谁在听我);
- 每一笔订阅是一根连接线(Link),认识前后邻居;连线(link)进队尾,摘线(unlink)改邻居指针——这就是双向链表;
- 自动反应和计算值每次跑都"先清旧、再订新",治好了朴素版的订阅污染——但行为上,两个怪病还在(诊断照片依然如实记录着)。
结构换了,怪病还没治。下一章,老墨要发明一样更厉害的东西:传播(propagate)——不再"先抄名单再挨个喊",而是边走边做记号,顺便把"重复通知"这个怪病一起治了。