Skip to content

第 22 章 性能彩蛋:和原版当面比一比

📖 毕业后的回访

毕业典礼刚结束,老墨就来找小铃了。

"毕业考满分。"老墨说,"但我想再问你一个问题——我们写的库,和原版比,快吗?"

"快?"小铃眨眨眼,"行为不是已经一模一样了吗?"

"行为一样,说的是干不干;快不快,说的是干得利不利索。"老墨在纸上画了两条赛道,"把原版请来,和我们的库跑同一场'大促销'——一边 10 万次写消息,看谁先跑完。"

小铃写了个对比脚本,结果出来了——原版快 1.2~1.5 倍

"咦?明明干的活一样……"小铃有点不服气。

"干活一样,花钱不一样。"老墨笑了,"咱们的库,热路径上有两笔'冤枉钱'——一笔是回程票本子,一笔是喊一嗓子。今天,把这笔钱省下来。"

🎯 本章要解决什么

一句话问题:行为已经和原版一致了,怎么把速度差距(1.2~1.5 倍)也抹平?

读完这一章,你会知道:

  • 热路径上的两笔"冤枉钱":每调用新开数组 + 每次更新都喊一嗓子
  • 优化一:回程票从"本子"换成珠子链(用到才串,零分配起步);
  • 优化二:浅传播移出 update + "听众不止一个才喊一嗓子"的剪枝。

💡 概念讲解

冤枉钱①:回程票本子

propagatecheckDirty 每次调用,都先新开一本回程票本子

ts
const tickets: Link[] = [];   // 每次都买一本新本子!

哪怕这次传播一层都不钻(最常见的情况),本子也白买了——每次写消息都要买一本,10 万次就是 10 万本。

原版怎么省? 它不买本子——需要记路的时候,才串一颗珠子(一个小对象);不钻层,一颗珠子都不串。零分配起步

mermaid
graph LR
    subgraph 我们:本子(数组)
        buy["每次调用都买新本子 📒"] --> use["用不用得上都买了"]
    end
    subgraph 原版:珠子链
        bead["要用才串珠子 📿"] --> zero["不钻层 = 零颗珠子"]
    end

冤枉钱②:每次更新都"喊一嗓子"

update(重算/消费)每次"真变了",都会立刻给所有听众喊一嗓子(浅传播,把"待核实"升级成"过期")。

原版怎么省? 它的 update 只报"变没变"——浅传播交给调用方决定,而且有个剪枝:听众只有一个的时候,不喊(那唯一的听众就是正在被核实的那个,它自己马上会被处理)。

两笔钱省下来,差距多大?

场景优化前优化后
① 基础写读1.4×1.4×
② 深链·读顶层(惰性)1.4×1.3×
③ 深链·读底层(全算)1.3×1.1×
④ 净零攒批(不白算)1.5×1.0×(完全打平)
⑤ 一百个订报人1.4×1.4×
⑥ 换订阅(牵线摘线)1.2×1.2×

("参考库快 X 倍";数字是同一台机器上的实测中位数。)

4 个场景打平或接近,2 个场景还差一点——剩下的差距,是教学版刻意保留的"看得懂"结构的代价(比如我们写代码追求每行都有注释、结构对称,而原版为了速度把代码挤成了精炼的形态)。行为一致,始终是真的;速度,我们追到了"非常接近"。

✍️ 动手写代码

两笔冤枉钱,两个补丁。改完,250 条测试(对我们 _dev 来说)继续全绿——行为一个字不变

不想一步步抄?

改的是 src/system.tssrc/index.ts 的局部,照抄"第 1~3 步"的代码块即可。抄到一半乱了,直接跳"章末完整代码"。

第 1 步:加"珠子链"图纸(system.ts)

打开 src/system.ts,在 Link 图纸下面加一个小接口——回程票珠子

ts
// 回程票珠子:轻量小栈,用到才串(不钻层就不花钱)
interface Stack<T> {
	value: T;
	prev: Stack<T> | undefined;
}

第 2 步:propagate 换珠子链(system.ts)

propagate 开头的"买本子"换成"准备珠子链",结尾的"掏本子"换成"摘珠子":

ts
	// 传播:边盖章边下传(认"正在跑的自己")
	function propagate(link: Link, innerWrite: boolean): void {
		let stack: Stack<Link | undefined> | undefined;   // 回程票珠子链(用到才串)
		let l: Link | undefined = link;                   // 现在站在哪根线上

		while (l !== undefined) {
			const next: Link | undefined = l.nextSub;   // 同层的下一站(先记住)
			const sub: ReactiveNode = l.sub;
			let flags = sub.flags;

			if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed | ReactiveFlags.Dirty | ReactiveFlags.Pending))) {
				sub.flags = flags | ReactiveFlags.Pending;
				if (innerWrite) {
					sub.flags |= ReactiveFlags.Recursed;
				}
			} else if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed))) {
				flags = ReactiveFlags.None;
			} else if (!(flags & ReactiveFlags.RecursedCheck)) {
				sub.flags = (flags & ~ReactiveFlags.Recursed) | ReactiveFlags.Pending;
			} else if (!(flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending)) && isValidLink(l, sub)) {
				sub.flags = flags | ReactiveFlags.Recursed | ReactiveFlags.Pending;
				flags &= ReactiveFlags.Mutable;
			} else {
				flags = ReactiveFlags.None;
			}

			if (flags & ReactiveFlags.Watching) {
				notify(sub);               // 问整车:排队
			}
			if (flags & ReactiveFlags.Mutable) {
				const subSubs: Link | undefined = sub.subs;
				if (subSubs !== undefined) {
					if (next !== undefined) {
						stack = { value: next, prev: stack };   // 这层还有路:串一颗珠子
					}
					l = subSubs;
					continue;
				}
			}

			if (next !== undefined) {
				l = next;                    // 同层下一站
			} else if (stack !== undefined) {
				l = stack.value;             // 掏一颗珠子(回程票)
				stack = stack.prev;
			} else {
				l = undefined;
			}
		}
	}

改动就三行:开头 let stack = undefined(不再买本子)、下钻时串珠子、没路时摘珠子

第 3 步:checkDirty 换珠子链 + 喊嗓子的剪枝(system.ts)

checkDirty 同样换珠子链,并在两处 update 调用点加上剪枝——听众不止一个,才喊一嗓子

ts
	// 核实:沿着依赖链挨个问"你们真的变了吗?"
	function checkDirty(node: ReactiveNode): boolean {
		let stack: Stack<Link> | undefined;   // 回程票珠子链(用到才串)
		let l: Link | undefined = node.deps;  // 现在核实的依赖线
		let dirty = false;                    // 下层问出"真变了"了吗?

		while (true) {
			if (l === undefined) {
				// 这一层问完了:往回走
				if (stack === undefined) {
					return dirty && !!node.flags;   // 没真变 → false;有真变 → true(被辞职的节点 flags 归零 → false)
				}
				const link = stack.value;    // 掏一颗珠子(回程票)
				stack = stack.prev;
				if (dirty) {
					// 下层真的变了:核实这个依赖(重算它)
					const subs = link.dep.subs;
					if (update(link.dep)) {
						if (subs !== undefined && subs.nextSub !== undefined) {
							shallowPropagate(subs);   // 听众不止一个,才喊一嗓子
						}
						continue;            // 它重算后真的变了:带着"脏"继续往上
					}
					dirty = false;           // 重算了但值没变:白激动,继续问同层
				} else {
					// 下层没变:摘掉它的"待核实"章
					link.dep.flags &= ~ReactiveFlags.Pending;
				}
				l = link.nextDep;            // 回到上一层,继续问下一根依赖
				continue;
			}

			const dep: ReactiveNode = l.dep;
			if (dep.flags & ReactiveFlags.Dirty) {
				// 依赖盖着"过期"章:让它把新旧消息对一对
				const subs = dep.subs;
				if (update(dep)) {
					if (subs !== undefined && subs.nextSub !== undefined) {
						shallowPropagate(subs);   // 听众不止一个,才喊一嗓子
					}
					dirty = true;            // 真的变了!带着"脏"往回走
					l = undefined;
				} else {
					l = l.nextDep;           // 没真变:继续问下一根
				}
			} else if (dep.flags & ReactiveFlags.Pending) {
				// 依赖盖着"待核实"章:下钻,先问它的依赖们
				stack = { value: l, prev: stack };   // 串一颗珠子
				l = dep.deps;
			} else {
				l = l.nextDep;               // 干净的依赖:继续问下一根
			}
		}
	}

新增的东西:subs.nextSub !== undefined 这个剪枝——唯一的听众正在被核实,不用喊它

第 4 步:update 移出浅传播 + 读路径补浅传播(index.ts)

src/index.tsupdate 现在"管得太宽"——重算完还自己喊一嗓子。改成只报"变没变",把喊嗓子交给调用方(checkDirty 已经带着剪枝喊了)。两处"喊嗓子"删掉:

ts
		return oldValue !== node.value;   // 真变了?浅传播由调用方决定(带"单听众跳过"剪枝)
	}
	// —— 信号:消费新消息,比较新旧 ——
	if ('currentValue' in node) {
		node.flags = ReactiveFlags.Mutable;      // 摘掉"过期"章
		const changed = node.currentValue !== node.pendingValue;
		node.currentValue = node.pendingValue;   // 旧消息格 = 新消息格(消费掉)
		return changed;
	}

但"读"路径(computedread)也要补上喊嗓子——它调 update 之后,自己喊:

ts
		if (node.flags & ReactiveFlags.Dirty) {
			if (update(node) && node.subs !== undefined) {
				shallowPropagate(node.subs);   // 真变了:给听众升级"过期"章
			}
		} else if (node.flags & ReactiveFlags.Pending) {
			// 待核实:先打电话问依赖们真的变了吗
			if (checkDirty(node)) {
				if (update(node) && node.subs !== undefined) {
					shallowPropagate(node.subs);   // 真变了:给听众升级"过期"章
				}
			} else {
				node.flags = ReactiveFlags.Mutable;   // 没真变:摘掉"待核实"章
			}
		}

第 5 步:叫检查员!(行为不变)+ 当面比一比

bash
npm test

🚀 你应该看到:250 条测试(对我们 _dev 来说)全部照绿——两笔冤枉钱省了,行为一个字没变。

然后建 tests/bench-reference.spec.ts——把原版请进来当面比。它和毕业表演的 bench.spec.ts 长得几乎一样,只是第三位选手换成了原版:

ts
import { test } from 'vitest';
import * as ours from '../src/index';
import * as ref from '../../参考/alien-signals/src/index';

六个场景、每个先热身再跑 3 次取中位数——跑完打印对比。你(应该)会看到:

① 基础写读:我们的库 104.7 ms | 参考库 75.1 ms | 参考库快 1.4 倍
② 深链·读顶层:我们的库 10.6 ms | 参考库 8.2 ms | 参考库快 1.3 倍
③ 深链·读底层:我们的库 13.3 ms | 参考库 11.9 ms | 参考库快 1.1 倍
④ 净零攒批:我们的库 23.2 ms | 参考库 23.2 ms | 参考库快 1.0 倍   ← 完全打平!
⑤ 一百个订报人:我们的库 94.6 ms | 参考库 67.2 ms | 参考库快 1.4 倍
⑥ 换订阅:我们的库 9.1 ms | 参考库 7.5 ms | 参考库快 1.2 倍

(不同机器数字会不同,但格局应该差不多:几个场景打平,几个场景差一点点。)

🧰 TS 小课堂:珠子链——一个"用到才串"的栈

今天的主角 Stack<T>,是个老朋友换了新发型

ts
interface Stack<T> {
	value: T;
	prev: Stack<T> | undefined;
}

它和数组一样是"后进先出"的栈——串进去的珠子,最后串的先摘。但它和数组有个关键区别:

数组(本子)珠子链(Stack)
初始化const tickets = []——每次都新开let stack = undefined——不花一分钱
存东西tickets.push(x)stack = { value: x, prev: stack }——串一颗珠子
取东西tickets.pop()stack = stack.prev——摘一颗珠子,前一颗浮上来

每个珠子只有两个字段value(记的路)和 prev(指向前一颗珠子)。它是一根单向链表——一颗珠子用手指着前一颗,串成一串。

mermaid
graph LR
    bead3["珠子③ value=C prev=②"] --> bead2["珠子② value=B prev=①"] --> bead1["珠子① value=A prev=无"]

为啥比数组省?数组的"本子"是一块连续的格子——哪怕一格不用也占着、要分配;珠子的链是"用到才串"——不钻层,一颗珠子都没有,stack 就是 undefined,啥也不分配。

泛型复习:Stack<T>T 是"珠子装什么"——Stack<Link | undefined> 装连接线,Stack<Link> 也装连接线(第 3 章的泛型,第 20 章之后我们已经离不开它了)。

📚 消息城词典

意思记住它
回程票珠子 (Stack)轻量小栈:用到才串,摘了就走数组本子的"省着买"版
浅传播剪枝听众不止一个,才喊一嗓子唯一的听众正在被核实
性能体检和原版当面比一比速度行为一致,还要快

回程票珠子/浅传播剪枝/性能体检是本章用语。

📦 章末完整代码

这章你没有新文件bench-reference.spec.ts 是第 5 步抄的那份),src/system.tssrc/index.ts 各改了局部。文件夹结构:

signal-book/
├── demo/
│   ├── ch01.mjs
│   └── naive-signal.ts
├── node_modules/
├── package.json
├── tsconfig.json
├── src/
│   ├── money.ts
│   ├── system.ts            ← 加了 Stack 珠子链 + propagate/checkDirty 改造
│   └── index.ts             ← update 移出浅传播 + read 补浅传播
└── tests/
    ├── ……(第 21 章的全部测试)
    ├── reference/           ← 参考库自己的测试(不动)
    ├── bench.spec.ts        ← 毕业表演(朴素版)
    └── bench-reference.spec.ts   ← 今天的新脚本(和原版比)

src/system.ts 的改动 = 第 1~3 步的三块(Stack 接口 + 新 propagate + 新 checkDirty,完整代码在上面贴全了)。src/index.ts 的改动 = 第 4 步的两块(update 删两处喊嗓子、computedread 补两处喊嗓子)。其余一字未动。

跑通了? 只要 npm test 还是全绿、bench-reference 打出的格局和上面差不多,两笔冤枉钱就省下来了——行为一致,速度追平大半

🎮 动手试试

怎么玩

先自己写答案,再点开对照。忍得住才点开哦!

1. 预言输出(猜一猜)

优化后的对比表里,④"净零攒批"完全打平(1.0×),⑤"一百个订报人"还差 1.4 倍。为什么偏偏是这两个?

👉 点开看答案

因为两笔冤枉钱分布的位置不一样

  • ④ 的活几乎全在传播和核实(盖章、钻层、问脏不脏)——这正是我们这章优化的地方(珠子链 + 剪枝),所以打平;
  • ⑤ 的活大部分在订报人自己跑(fn 调用、读信号、摘线清线)——这部分我们和原版结构基本一样,但每跑一次都有一点教学版结构的常数开销,100 个订报人 × 5000 轮就把差距放大了。

差距在哪儿优化哪儿——这就是"性能体检"的意义:先量出来,再对症下药。

2. 帮小铃找 bug(抓错)

小铃打第 4 步的补丁时,只删了 update 里的"喊嗓子",忘了在 computedread 里补上。结果:直接读一个过期的计算值,下游订报人不醒了。为什么?

👉 点开看答案

因为"喊嗓子"现在只由调用方负责——update 只报"变没变",不再自己喊。

readupdate 的调用方之一:它读完、发现真变了,就必须自己喊shallowPropagate(node.subs)),下游才会收到"过期"章。漏了,就是"计算值自己知道变了,但听众都不知道"——变了个寂寞

checkDirty 里已经带剪枝地喊了;read 里不带剪枝地喊。两个调用方,一个都不能少。

3. 填空(补全)

propagate 里"掏珠子"少了一行。补全它,让前一颗珠子浮上来:

ts
			} else if (stack !== undefined) {
				l = stack.value;             // 掏一颗珠子(回程票)
				/* 这里该写什么? */;
			}
👉 点开看答案

stack = stack.prev

ts
				l = stack.value;             // 掏一颗珠子(回程票)
				stack = stack.prev;          // 摘掉它,前一颗浮上来

珠子链是"后进先出":掏走顶上的珠子,prev 指的那颗就成了新的顶上。摘一颗、浮一颗——和数组的 pop() 一个道理,只是不花钱买本子。

4. 小挑战(把活干得更多)

把性能体检里 50 层的链改成 200 层,再跑 ③ 和 ④。猜猜倍数会怎么变?

提示:活越多,每轮"必须干"的活占比越大,"常数开销"占比越小。

👉 点开看答案

应该更接近打平(或者打平)——比如 ③ 从 1.1× 变成 1.0× 上下。

道理:200 层的活(重算 200 次 / 核实 200 层)是"必须干"的,两边一样多;我们多出来的只是每层那一点点常数开销——层数越多,那点开销被摊得越薄。

性能优化的本质,就是让"常数开销"越小、摊得越薄——珠子链和剪枝,都是在往这个方向走。

🏁 本章小结

  1. 两笔冤枉钱:每次调用新开"回程票本子"、每次更新都"喊一嗓子"——热路径上最贵的两笔常数开销;
  2. 两个补丁:回程票换珠子链(用到才串,零分配起步)+ 浅传播剪枝(听众不止一个才喊)——行为一字不变,250 条测试照绿;
  3. 实测:4/6 场景打平或接近(④ 完全打平),剩 2 个场景差 1.3~1.4 倍——剩下的差距,是教学版"看得懂"结构的代价,诚实地说:行为一致是真的,速度我们追到了"非常接近"。

🎓 真正的毕业

消息城的故事,到这里,就真的讲完了。

从第 1 章"为什么 count 变了 money 不自己跟着变",到这一章"和原版当面比一比"——你手里的这个信号库:

  • 行为和真实库完全一致(它自己的 200 条测试,一条都挑不出毛病);
  • 速度追到了"非常接近"(4 个场景打平,剩下的是可读性的代价);
  • 每一行代码你都看得懂——这,才是比"快 1.4 倍"更值钱的东西。

原版的性能,靠的是把代码挤成机器喜欢的形状;我们的性能,靠的是把每一笔开销都算清楚——这两种"快",前者是别人给的,后者是你自己挣的。

谢谢你陪消息城走到这里。这次,是真的毕业了。🎓