Appearance
第 14 章 期号 version:连接线复用
📖 第几期,派上用场了
"老墨!"小铃举着一页代码冲进办公室,"我发现咱们的邮差在干傻活!"
"哦?"
"每次订报人跑一遍,它都先把所有旧线全拆了,再一根一根重新牵!"小铃指着 purgeAllDeps 和 link,"第 7 章拆比喻的时候你说过,连接线最擅长'加线退线'——可咱们现在明明线还好好连着,为什么要全拆了重牵?多浪费啊!"
老墨笑了:"你观察得很准。这正是第四张图纸要解决的问题。还记得第 7 章我说过什么吗?"
"说过好多呢……"小铃歪着头。
"我说过——线上还写着第几期。当时我说,那是'给复用旧线准备的'。"
"期号……"小铃的眼睛亮了,"所以今天,期号要派上用场了?"
"对。给每一期(每次跑)编一个号,连接线上贴着自己是哪一期的。下一期跑的时候,邮差先认线:这根线还用得着吗?——用着,就不拆不牵,贴上新年份继续用;用不上的旧线,等到期末再清掉。"
"这样——"小铃慢慢说,"就不用每次把线全拆了重牵了!"
"没错。这一章,我们给连接线贴上期号,让它学会复用。"
🎯 本章要解决什么
一句话问题:怎么让连接线复用——订报人这期读的和上期一样,就不重新牵线?
读完这一章,你会知道:
- 期号(
version)和每期一号(cycle)是怎么回事; link的三条快路径:认出"这线还用得着"就复用;- 期末清线(
purgeDeps):只清"这期没用上"的旧线,不搞大拆大建。
💡 概念讲解
现在的问题:每期都大拆大建
现在的订报人每跑一遍(新的一期),都要:
purgeAllDeps——把依赖链上的线全拆了;- 跑的时候
link——一根一根重新牵,还先沿链扫描一遍防止重复。
其实大多数时候,订报人这期读的依赖和上期一模一样——全拆重牵,等于把好好的房子推倒重建。
期号:给每一期编号
消息城加一个期号计数器 cycle:每跑一期(订报人跑一遍 / 中转站重算一次),期号加一。
mermaid
graph LR
run1["第一期 📰 cycle = 1"] --> run2["第二期 📰 cycle = 2"] --> run3["第三期 📰 cycle = 3"]每根连接线上,都贴着它是哪一期连的(version)。
link 的三条快路径
连线的时候,不再傻乎乎先扫描一遍——先试三条快路径,认得出旧线就复用:
| 快路径 | 认什么 | 怎么办 |
|---|---|---|
| ① | 刚连的就是它(依赖链尾巴上就是这根线) | 不用再连,直接走人 |
| ② | 旧线还用得着(尾巴后面那根就是它) | 贴上新的期号,复用! |
| ③ | 依赖那边已经连着同一期了(它的听众链尾巴就是这根线、还是本期) | 不用再连 |
三条都认不出来?那才是真的新线——牵一根新的。
mermaid
graph LR
link["link(dep, sub, 期号)"] --> check1{"尾巴就是它?"}
check1 -- "是" --> done1["不用连,走人"]
check1 -- "否" --> check2{"尾巴后面是它?"}
check2 -- "是" --> reuse["贴期号,复用!"]
check2 -- "否" --> check3{"依赖那边连着同一期?"}
check3 -- "是" --> done3["不用连,走人"]
check3 -- "否" --> create["牵一根新线"]期末清线:只清没用上的
那"这期不读的旧线"呢?不拆了——期末清:跑完之后,从依赖链尾巴往后看,凡是这期没读到的旧线,一根根摘掉。
mermaid
graph LR
deps["这期读到的依赖(在尾巴附近)✅"] --> stale["这期没读的旧线 🗑️"]
stale --> purge["期末清掉"]这比"全拆了重牵"省多了:读到的线原样复用,没读到的才摘。
为什么读的顺序稳定,就能全复用
订报人每期都按同样的顺序读依赖(大多数订报人就是这样)——期号机制下,第一根线贴一期、第二根贴二期……全在原位复用,一根新线都不用牵。顺序老变的订报人才会牵新线(旧的期末被清掉)——该省的省,该花的花。
✍️ 动手写代码
今天改的是"连线"这套功夫:贴期号、认旧线、期末清线。一步步来。
第 1 步:给连接线贴期号 + 加期号计数器
打开 src/signal.ts。先给 Link 图纸加一个字段:
ts
// 连接线:一笔订阅(线上贴着期号)
interface Link {
version: number; // 期号:这是哪一期连的
dep: ReactiveNode; // 被依赖的(消息源)
sub: ReactiveNode; // 依赖的(订阅者)
prevDep?: Link; // 依赖链上的前一根线
nextDep?: Link; // 依赖链上的后一根线
prevSub?: Link; // 听众链上的前一根线
nextSub?: Link; // 听众链上的后一根线
}在全局状态那里加期号计数器:
ts
// 期号:每跑一期加一,连接线靠它认出"这是哪一期的"
let cycle = 0;第 2 步:换 link(三条快路径)
把 link 换成下面的版本——参数多了 version(期号),开头多了三条快路径:
ts
// 连线:能复用旧线就复用(认"期号"),省得重新牵
function link(dep: ReactiveNode, sub: ReactiveNode, version: number): void {
const prevDep = sub.depsTail;
if (prevDep !== undefined && prevDep.dep === dep) {
return; // ① 刚连的就是它:不用再连
}
const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps;
if (nextDep !== undefined && nextDep.dep === dep) {
nextDep.version = version; // ② 旧线还用得着:贴上期号,复用!
sub.depsTail = nextDep;
return;
}
const prevSub = dep.subsTail;
if (prevSub !== undefined && prevSub.version === version && prevSub.sub === sub) {
return; // ③ 依赖那边已经连着同一期了:不用再连
}
const newLink: Link = {
version,
dep,
sub,
prevDep,
nextDep,
prevSub,
nextSub: undefined,
};
// 接进 sub 的依赖链
if (prevDep !== undefined) {
prevDep.nextDep = newLink;
} else {
sub.deps = newLink;
}
if (nextDep !== undefined) {
nextDep.prevDep = newLink;
}
sub.depsTail = newLink;
// 接进 dep 的听众链
if (prevSub !== undefined) {
prevSub.nextSub = newLink;
} else {
dep.subs = newLink;
}
dep.subsTail = newLink;
}对照三条快路径看:① 看依赖链尾巴;② 看尾巴后面那根(说明这根线这期还没被用过,但就在附近)——找到就贴期号复用;③ 看依赖那边的听众链尾巴是不是连着同一期。
第 7 章的扫描没了
以前 link 开头要沿依赖链扫一遍检查重复(O(n))。现在换成三条快路径——大多数时候第一步就认出来了,不用扫描。
第 3 步:加期末清线(purgeDeps)
把 purgeAllDeps 换成 purgeDeps——它只清"尾巴后面"的旧线,不清整条链:
ts
// 期末清线:把这期没用上的旧线(尾巴后面的)摘掉
function purgeDeps(sub: ReactiveNode): void {
let dep = sub.depsTail !== undefined ? sub.depsTail.nextDep : sub.deps;
while (dep !== undefined) {
const next = dep.nextDep;
unlink(dep);
dep = next;
}
}(purgeAllDeps 整段删掉——它"全拆"的活,被"期末只清旧线"取代了。)
第 4 步:改自动反应(effect)
run 里三处小改:新的一期(++cycle)、旧尾巴作废(depsTail = undefined)、跑完期末清线(purgeDeps)。原来开头的"清空订阅"整段删掉:
ts
const run = (): void => {
node.flags = ReactiveFlags.Watching | ReactiveFlags.RecursedCheck; // 戴上"正在跑"的章
++runDepth; // 记账:现在有节点在跑
++cycle; // 新的一期!
node.depsTail = undefined; // 旧尾巴作废,重新开始记
const prev = activeSub;
activeSub = node; // 举起耳朵
try {
node.fn(); // 跑一遍——读到的信号都会连上我(能复用就复用)
} finally {
--runDepth; // 跑完,把账销掉
activeSub = prev; // 把耳朵还回去
node.flags &= ~ReactiveFlags.RecursedCheck; // 摘掉"正在跑"的章
purgeDeps(node); // 期末清线:这期没用上的旧线摘掉
}
};第 5 步:改重算(update)
中转站的 update 也一样——新的一期、旧尾巴作废、期末清线:
ts
if ('getter' in node) {
// —— 计算值:重算 ——
const oldValue = node.value;
++cycle; // 新的一期!
node.depsTail = undefined; // 旧尾巴作废,重新开始记
node.flags = ReactiveFlags.Mutable | ReactiveFlags.RecursedCheck; // 戴上"正在跑"的章
const prev = activeSub;
activeSub = node; // 举起耳朵
try {
node.value = node.getter(); // 按规则算!读到的信号都会连上我
node.hasValue = true;
} finally {
activeSub = prev; // 把耳朵还回去
node.flags &= ~ReactiveFlags.RecursedCheck; // 摘掉"正在跑"的章
purgeDeps(node); // 期末清线:这期没用上的旧线摘掉
}
const changed = oldValue !== node.value;
if (changed && node.subs !== undefined) {
shallowPropagate(node.subs); // 真变了:给听众升级"过期"章
}
return changed;
}第 6 步:改两处"读"(传期号)
signal 的 read 和 computed 的 read 里,link 调用多传一个期号:
ts
// signal 的 read 里:
if (activeSub !== undefined) {
link(node, activeSub, cycle); // 记一笔:这位在听我(本期)
}
// computed 的 read 里:
if (activeSub !== undefined) {
link(node, activeSub, cycle); // 记一笔:这位在听我(本期)
}第 7 步:叫检查员!(回归)
bash
npm test🚀 现在跑一下,你应该看到:
✓ 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 9 passed (9) Tests 25 passed (25)25 个,一个都不红。 尤其是"换订阅"那两条测试——它们验证的正是"期末清线":换了订阅对象,旧线被清掉,旧信号再也不来打扰。🎉
🧰 TS 小课堂:三目运算符
今天的新语法藏在 link 的第一行里:
ts
const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps;读法:"如果 prevDep 不是空的,就用 prevDep.nextDep;否则用 sub.deps。"
它是 if / else 的短写法——一句话把"选哪个"说清楚:
ts
// 这行:
const x = 条件 ? 甲 : 乙;
// 等于这段:
let x;
if (条件) {
x = 甲;
} else {
x = 乙;
}? 前面是条件,? 后面是"条件成立时",: 后面是"不成立时"。三样东西拼成一句话,所以叫"三目"(三个部分)。
什么时候用它?
"二选一"的小选择,用三目很清爽;逻辑一复杂,还是老老实实写 if / else 更清楚。
📚 消息城词典
| 词 | 意思 | 记住它 |
|---|---|---|
| 期号 (version) | 连接线上贴的"这是哪一期连的" | 第 7 章就埋的伏笔,今天兑现 |
| 每期一号 (cycle) | 每次跑(新的一期)加一 | 期号计数器 |
| 复用 | 认得出旧线还用得着,不拆不牵 | 快路径 ② 的活 |
| 期末清线 (purgeDeps) | 跑完只清"这期没用上"的旧线 | 不搞大拆大建 |
📦 章末完整代码
这一章你没有新文件,signal.ts 改了几处。文件夹结构一个字没变:
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
├── checkup.spec.ts
└── recursion.spec.tssrc/signal.ts 的其他部分和第 13 章那份一样,今天改了六处(就是第 1-6 步):
Link加了version字段,全局加了cycle计数器;link换成三条快路径的版本(第 2 步那份),所有link(...)调用多传cycle;purgeAllDeps换成purgeDeps(第 3 步那份);effect的run:++cycle、depsTail = undefined、期末purgeDeps(第 4 步那份);update的计算值分支:同样三处小改(第 5 步那份);signal和computed的read:link(node, activeSub, cycle)。
✅ 跑通了? 只要
npm test打出25 passed,连接线就学会复用啦——该省的省,该花的花。
🎮 动手试试
怎么玩
先自己写答案,再点开对照。忍得住才点开哦!
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);👉 点开看答案
logs 是 [1, 10]。
- 注册时:读
flag和a→ 推入 1; flag(false):订报人重跑(新的一期)——flag和b被连上,a的旧线在期末被清掉;a(2):a的听众链已经空了,不打扰 → 没有第三条。
这正是"期末清线"的本事:换订阅只清旧线,不惊动旧信号。
2. 帮小铃找 bug(抓错)
小铃改完期号,把"期末清线"给忘了——run 的 finally 里没有 purgeDeps:
ts
} finally {
--runDepth;
activeSub = prev;
node.flags &= ~ReactiveFlags.RecursedCheck;
// 咦?期末清线呢?
}结果:subscription.spec.ts 的测试红了——订报人换了订阅对象,旧信号一变,它又被叫醒了。为什么?
👉 点开看答案
因为旧线没清,赖在链上了。
期号只负责"认出旧线还能复用"(快路径②),可清掉"这期没用上的旧线"是期末清线的活。没清线,订报人换了订阅对象,a 的听众链里还挂着它——a(2) 一写,传播照旧叫醒它——订阅污染回来了。
期号和期末清线是一对搭档:期号省着牵,期末清着摘——缺一个都不行。
3. 填空(补全)
快路径 ② 缺了两行。补全它,让旧线被"贴上期号、复用到尾巴":
ts
if (nextDep !== undefined && nextDep.dep === dep) {
nextDep.version = /* 这里该写什么? */;
sub.depsTail = /* 这里该写什么? */;
return;
}👉 点开看答案
写:
ts
nextDep.version = version; // 贴上这一期的期号
sub.depsTail = nextDep; // 它成了新的尾巴(本期最后连的)两根线一起动:贴上新年份(认出"这期用过了"),挪到尾巴(期末清线从尾巴往后清,它就不会被误清)。
4. 小挑战(画图)
小铃的订报人第一期读了 a、b、c 三个信号(依赖链:a → b → c)。第二期它只读 b 和 c(a 不读了)。
画一画:第二期跑完之后,依赖链长什么样?(提示:b、c 能不能复用?a 去哪了?)
👉 点开看答案
第二期跑完,依赖链是 b → c(a 被期末清掉)。
过程:
- 读
b:link沿快路径找——a不是b,牵一根新线b'(放在链头前面)→b' → a → c; - 读
c:快路径②认出旧的c线就在附近——复用,贴上期号挪到尾巴 →b' → c; - 期末清线:从尾巴(
c)往后看——a的旧线这期没被读过,摘掉。
结果:b 牵了新线(因为顺序变了,旧的 b 在链中间不好认),c 复用了旧线,a 被清掉。该省的省(c),该花的花(b),该清的清(a)。
🏁 本章小结
- 连接线贴上了期号(version),消息城有了每期一号(cycle)——第 7 章埋的伏笔兑现;
link的三条快路径认得出旧线:刚连的、尾巴后面的、依赖那边同期的——能复用就复用;- 期末清线(purgeDeps)只清"这期没用上"的旧线——不搞大拆大建,订阅污染也不会回来。
连接线学会精打细算了。老墨在第五张图纸上写下了一个温柔的话题:
自动反应的一生:它会老、会辞职,还会领"退休金"。
下一章,订报人终于有"一生"了——清理函数、嵌套订报、先后顺序。