Skip to content

第 14 章 期号 version:连接线复用

📖 第几期,派上用场了

"老墨!"小铃举着一页代码冲进办公室,"我发现咱们的邮差在干傻活!"

"哦?"

"每次订报人跑一遍,它都先把所有旧线全拆了,再一根一根重新牵!"小铃指着 purgeAllDepslink,"第 7 章拆比喻的时候你说过,连接线最擅长'加线退线'——可咱们现在明明线还好好连着,为什么要全拆了重牵?多浪费啊!"

老墨笑了:"你观察得很准。这正是第四张图纸要解决的问题。还记得第 7 章我说过什么吗?"

"说过好多呢……"小铃歪着头。

"我说过——线上还写着第几期。当时我说,那是'给复用旧线准备的'。"

"期号……"小铃的眼睛亮了,"所以今天,期号要派上用场了?"

"对。给每一期(每次跑)编一个号,连接线上贴着自己是哪一期的。下一期跑的时候,邮差先认线:这根线还用得着吗?——用着,就不拆不牵,贴上新年份继续用;用不上的旧线,等到期末再清掉。"

"这样——"小铃慢慢说,"就不用每次把线全拆了重牵了!"

"没错。这一章,我们给连接线贴上期号,让它学会复用。"

🎯 本章要解决什么

一句话问题:怎么让连接线复用——订报人这期读的和上期一样,就不重新牵线?

读完这一章,你会知道:

  • 期号(version)和每期一号cycle)是怎么回事;
  • link 的三条快路径:认出"这线还用得着"就复用;
  • 期末清线purgeDeps):只清"这期没用上"的旧线,不搞大拆大建。

💡 概念讲解

现在的问题:每期都大拆大建

现在的订报人每跑一遍(新的一期),都要:

  1. purgeAllDeps——把依赖链上的线全拆了
  2. 跑的时候 link——一根一根重新牵,还先沿链扫描一遍防止重复。

其实大多数时候,订报人这期读的依赖和上期一模一样——全拆重牵,等于把好好的房子推倒重建。

期号:给每一期编号

消息城加一个期号计数器 cycle:每跑一期(订报人跑一遍 / 中转站重算一次),期号加一。

mermaid
graph LR
    run1["第一期 📰 cycle = 1"] --> run2["第二期 📰 cycle = 2"] --> run3["第三期 📰 cycle = 3"]

每根连接线上,都贴着它是哪一期连的version)。

连线的时候,不再傻乎乎先扫描一遍——先试三条快路径,认得出旧线就复用:

快路径认什么怎么办
刚连的就是它(依赖链尾巴上就是这根线)不用再连,直接走人
旧线还用得着(尾巴后面那根就是它)贴上新的期号,复用
依赖那边已经连着同一期了(它的听众链尾巴就是这根线、还是本期)不用再连

三条都认不出来?那才是真的新线——牵一根新的。

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;

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 步:改两处"读"(传期号)

signalreadcomputedread 里,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.ts

src/signal.ts其他部分和第 13 章那份一样,今天改了六处(就是第 1-6 步):

  1. Link 加了 version 字段,全局加了 cycle 计数器;
  2. link 换成三条快路径的版本(第 2 步那份),所有 link(...) 调用多传 cycle
  3. purgeAllDeps 换成 purgeDeps(第 3 步那份);
  4. effectrun++cycledepsTail = undefined、期末 purgeDeps(第 4 步那份);
  5. update 的计算值分支:同样三处小改(第 5 步那份);
  6. signalcomputedreadlink(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]

  • 注册时:读 flaga → 推入 1;
  • flag(false):订报人重跑(新的一期)——flagb 被连上,a 的旧线在期末被清掉
  • a(2)a 的听众链已经空了,不打扰 → 没有第三条。

这正是"期末清线"的本事:换订阅只清旧线,不惊动旧信号。

2. 帮小铃找 bug(抓错)

小铃改完期号,把"期末清线"给忘了——runfinally 里没有 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. 小挑战(画图)

小铃的订报人第一期读了 abc 三个信号(依赖链:a → b → c)。第二期它只读 bca 不读了)。

画一画:第二期跑完之后,依赖链长什么样?(提示:bc 能不能复用?a 去哪了?)

👉 点开看答案

第二期跑完,依赖链是 b → ca 被期末清掉)。

过程:

  • blink 沿快路径找——a 不是 b,牵一根新线 b'(放在链头前面)→ b' → a → c
  • c:快路径②认出旧的 c 线就在附近——复用,贴上期号挪到尾巴 → b' → c
  • 期末清线:从尾巴(c)往后看——a 的旧线这期没被读过,摘掉

结果:b 牵了新线(因为顺序变了,旧的 b 在链中间不好认),c 复用了旧线,a 被清掉。该省的省(c),该花的花(b),该清的清(a)

🏁 本章小结

  1. 连接线贴上了期号(version),消息城有了每期一号(cycle)——第 7 章埋的伏笔兑现;
  2. link 的三条快路径认得出旧线:刚连的、尾巴后面的、依赖那边同期的——能复用就复用
  3. 期末清线(purgeDeps)只清"这期没用上"的旧线——不搞大拆大建,订阅污染也不会回来。

连接线学会精打细算了。老墨在第五张图纸上写下了一个温柔的话题:

自动反应的一生:它会老、会辞职,还会领"退休金"。

下一章,订报人终于有"一生"了——清理函数、嵌套订报、先后顺序