Appearance
第 22 章 性能彩蛋:和原版当面比一比
📖 毕业后的回访
毕业典礼刚结束,老墨就来找小铃了。
"毕业考满分。"老墨说,"但我想再问你一个问题——我们写的库,和原版比,快吗?"
"快?"小铃眨眨眼,"行为不是已经一模一样了吗?"
"行为一样,说的是干不干;快不快,说的是干得利不利索。"老墨在纸上画了两条赛道,"把原版请来,和我们的库跑同一场'大促销'——一边 10 万次写消息,看谁先跑完。"
小铃写了个对比脚本,结果出来了——原版快 1.2~1.5 倍。
"咦?明明干的活一样……"小铃有点不服气。
"干活一样,花钱不一样。"老墨笑了,"咱们的库,热路径上有两笔'冤枉钱'——一笔是回程票本子,一笔是喊一嗓子。今天,把这笔钱省下来。"
🎯 本章要解决什么
一句话问题:行为已经和原版一致了,怎么把速度差距(1.2~1.5 倍)也抹平?
读完这一章,你会知道:
- 热路径上的两笔"冤枉钱":每调用新开数组 + 每次更新都喊一嗓子;
- 优化一:回程票从"本子"换成珠子链(用到才串,零分配起步);
- 优化二:浅传播移出 update + "听众不止一个才喊一嗓子"的剪枝。
💡 概念讲解
冤枉钱①:回程票本子
propagate 和 checkDirty 每次调用,都先新开一本回程票本子:
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.ts 和 src/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.ts 的 update 现在"管得太宽"——重算完还自己喊一嗓子。改成只报"变没变",把喊嗓子交给调用方(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;
}但"读"路径(computed 的 read)也要补上喊嗓子——它调 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.ts 和 src/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 删两处喊嗓子、computed 的 read 补两处喊嗓子)。其余一字未动。
✅ 跑通了? 只要
npm test还是全绿、bench-reference打出的格局和上面差不多,两笔冤枉钱就省下来了——行为一致,速度追平大半。
🎮 动手试试
怎么玩
先自己写答案,再点开对照。忍得住才点开哦!
1. 预言输出(猜一猜)
优化后的对比表里,④"净零攒批"完全打平(1.0×),⑤"一百个订报人"还差 1.4 倍。为什么偏偏是这两个?
👉 点开看答案
因为两笔冤枉钱分布的位置不一样:
- ④ 的活几乎全在传播和核实(盖章、钻层、问脏不脏)——这正是我们这章优化的地方(珠子链 + 剪枝),所以打平;
- ⑤ 的活大部分在订报人自己跑(fn 调用、读信号、摘线清线)——这部分我们和原版结构基本一样,但每跑一次都有一点教学版结构的常数开销,100 个订报人 × 5000 轮就把差距放大了。
差距在哪儿优化哪儿——这就是"性能体检"的意义:先量出来,再对症下药。
2. 帮小铃找 bug(抓错)
小铃打第 4 步的补丁时,只删了 update 里的"喊嗓子",忘了在 computed 的 read 里补上。结果:直接读一个过期的计算值,下游订报人不醒了。为什么?
👉 点开看答案
因为"喊嗓子"现在只由调用方负责——update 只报"变没变",不再自己喊。
read 是 update 的调用方之一:它读完、发现真变了,就必须自己喊(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 层)是"必须干"的,两边一样多;我们多出来的只是每层那一点点常数开销——层数越多,那点开销被摊得越薄。
性能优化的本质,就是让"常数开销"越小、摊得越薄——珠子链和剪枝,都是在往这个方向走。
🏁 本章小结
- 两笔冤枉钱:每次调用新开"回程票本子"、每次更新都"喊一嗓子"——热路径上最贵的两笔常数开销;
- 两个补丁:回程票换珠子链(用到才串,零分配起步)+ 浅传播剪枝(听众不止一个才喊)——行为一字不变,250 条测试照绿;
- 实测:4/6 场景打平或接近(④ 完全打平),剩 2 个场景差 1.3~1.4 倍——剩下的差距,是教学版"看得懂"结构的代价,诚实地说:行为一致是真的,速度我们追到了"非常接近"。
🎓 真正的毕业
消息城的故事,到这里,就真的讲完了。
从第 1 章"为什么 count 变了 money 不自己跟着变",到这一章"和原版当面比一比"——你手里的这个信号库:
- 行为和真实库完全一致(它自己的 200 条测试,一条都挑不出毛病);
- 速度追到了"非常接近"(4 个场景打平,剩下的是可读性的代价);
- 每一行代码你都看得懂——这,才是比"快 1.4 倍"更值钱的东西。
原版的性能,靠的是把代码挤成机器喜欢的形状;我们的性能,靠的是把每一笔开销都算清楚——这两种"快",前者是别人给的,后者是你自己挣的。
谢谢你陪消息城走到这里。这次,是真的毕业了。🎓