一、技术难点:你优化的,是一个不完全由你掌控的运行时
Day22 我们解决了"组件库的更新粒度"问题,方法是细粒度订阅。但如果只用一句话总结那篇文章,它是这样的:把状态放到真正使用它的地方。这一篇要把这句话背后的成本模型彻底讲清楚------因为框架性能优化之所以难,不是缺工具,而是缺一本能对得上的账。
难点一:三本账混在一起算,优化必然打偏
绝大多数"性能优化"失败在第一步:搞错了在优化哪笔账。
| 账本 | 度量对象 | 怎么量 | 常见误判 |
|---|---|---|---|
| 框架工作量 | 组件函数执行次数、vnode 比较次数、effect 重跑次数 | Profiler、渲染计数器、DevTools Highlight updates | 把"组件重渲染"等同于"页面重绘" |
| 浏览器工作量 | DOM 属性写次数、样式重算、布局、绘制 | Performance 面板、Rendering 工具 | 以为减少重渲染就一定减少绘制 |
| 用户感知 | 长任务、INP、掉帧 | PerformanceObserver、字段数据(RUM) | 用开发机的数据推断低端机 |
这三本账不总是同向变化 。Day22 的选择器订阅把"高亮移动"的组件执行次数从 102 次压到 2 次,但 DOM 写次数始终是 2 次------因为真正需要改的只有两个 class。所以那次优化的收益上限是"框架渲染开销",与绘制开销无关。
反过来更反直觉:减少框架工作量有时会增加浏览器工作量。后面的粒度阶梯实测里有一组数据会证明这一点。
难点二:虚拟 DOM 的成本模型是 O(树),不是 O(变化量)
自上而下协调(top-down reconciliation)意味着:一次更新的渲染半径,由"状态放在哪"决定,而不是由"谁在用这个状态"决定。
状态放在根组件,切换一个高亮行,就是 App + List + 100 Row = 102 次组件函数执行。这 102 次里,有 100 次是白跑的------它们只是为了"再比较一次,发现 props 没变"。
很多人有个错觉:"diff 很便宜"。这句话只说对了一半:便宜的是单个节点的比较,贵的是你要比较 n 个节点 。而且 diff 是事后 判断------它必须先执行子组件的渲染函数,才知道子树有没有变。框架无法在编译期知道"这次更新只会影响哪几个节点",除非你(或编译器)提前给它标注。这就是 memo、PatchFlags、signal 这些机制存在的根本原因:它们都是"提前告诉框架哪里会变"的手段。
难点三:memo 是双刃剑------它可能是负优化
React.memo 用浅比较 props 决定是否短路子组件。它失效的原因有四类,每一类都在生产代码里真实存在:
- 内联对象/箭头函数 :
onClick={() => pick(id)}每次渲染都是新引用,浅比较必然失败; - children 每次重建 :JSX 语法糖产出的元素对象每次都是新的,
<Child>{<span/>}</Child>会让memo失效; - Context 穿透 :
memo只挡 props,不挡 context。Provider 的 value 变了,子树里所有消费者照样重渲染,memo包几层都没用; - 比较成本 > 渲染成本 :给一个"只渲染一个 span"的叶子组件加
memo,省下的渲染时间小于浅比较的开销。
第 1 类最危险,因为它的代价不止于"没优化" 。本文第二节的实测会给出一个数字:memo 被内联函数击穿后,组件执行次数与不 memo 完全相同(102 次),但真实 DOM 写次数是不 memo 的 50 倍 。原因是新函数引用让 onClick 这个 prop 在 diff 里被判定为"变化了",框架只能逐节点重写属性------加了 memo 反而更慢。
难点四:一次 setState 就可能是一个 50ms 以上的同步长任务
浏览器把超过 50ms 的任务定义为长任务(long task):它占住主线程,用户输入只能排队。而 INP(Interaction to Next Paint)的"良好"阈值是 200ms。
同步渲染是不可中断的。假设一次列表渲染要遍历 120 个 fiber 节点,每个节点平均 0.4ms,那是 48ms------已经逼近长任务红线。这期间用户敲下的每一个按键都得等它跑完,输入框出现明显的"打字粘滞感"。
于是只有两条路:要么减少工作量,要么把不可中断变成可中断。React 选了后者(并发渲染 + 时间切片),Vue、Svelte 选了前者(编译期精确标注)。两条路互不排斥,这也是本文第二节要把它们放在同一张决策表里的原因。
难点五:可中断带来撕裂(tearing)
时间切片的代价不是"更慢",而是一致性 :一次渲染被拆到多帧执行,如果渲染进行到一半时外部数据源变了,同一屏上就可能出现两个组件读到同一个数据源的两个版本------左边显示 43,右边还是 42。这就是撕裂。
React 的解法不是禁止外部 store,而是要求你用 useSyncExternalStore 订阅:渲染期间读一次快照版本号,commit 之前再校验一次,发现版本变了就丢弃这次渲染结果重来。撕裂是"可中断"这一设计不可回避的副作用,而不是某个框架的 bug。
难点六:列表虚拟化的三个反直觉难点
虚拟化听起来很简单:只渲染可见行。但一旦行高不固定,三个问题同时出现:
- 定位不能靠除法:"第几个行在 2000px 处"要靠前缀和查表。如果用朴素数组维护前缀和,每测量一行就 O(n) 重算------10 万行列表滚动时这就是灾难;
- 滚动锚定 :虚拟化会异步测量真实行高。如果视口上方的行被修正(40px 变成 96px),它下面所有内容会整体位移 56px,用户看到列表"跳了一下";
- 虚拟化本身有代价:Ctrl+F 搜不到未挂载的行、打印不全、屏幕阅读器读到不完整内容、Ctrl+A 复制缺失。它是"用功能换性能"的交易,不是免费午餐。
难点七:能编译期消除的,留到运行时就是浪费
手写 memo / useMemo 本质是"人肉编译器",而且极不稳定------一个内联函数就能让整棵子树的记忆化全部失效。而"哪里会变"这件事,编译器比人清楚:Vue 在模板编译期把动态节点打上 PatchFlags 并用 Block Tree 收集动态子节点,静态子树整块跳过;Svelte 5 的 runes、Solid 的 signal 直接把订阅关系绑定到具体 DOM 节点;React Compiler 则把 memo/useMemo/useCallback 变成自动记忆化。
但编译器不是银弹,两条边界必须记住:它遇到违反规则的代码会 bailout(不优化) ,而且它无法修复"状态放错位置"这类结构问题------把状态从根组件下沉到叶子,收益永远大于让编译器去记忆化一棵本来就不该重渲染的树。
二、完整解法
2.1 决策顺序:先量,再减工作量,最后才动调度
顺序不能反,原因写在每一步里:
| 步骤 | 做什么 | 为什么必须在这个位置 |
|---|---|---|
| ① 量 | Profiler + 渲染计数器 + longtask/INP 埋点 | 不量就不知道瓶颈在"渲染次数"还是"单个组件太贵" |
| ② 减工作量 | 状态下沉、拆分组件、避免无谓的 state 提升 | 唯一能真正减少总开销的一步 |
| ③ 降粒度 | memo(仅在 props 稳定时)、选择器订阅、signal、编译期标注 | 减少"白跑"的比例,不减少必须做的工作 |
| ④ 切分 | 时间切片、transition、虚拟化 | 切片不减少总工作量,只把长任务摊到多帧 |
第 ④ 步放到最后,是因为它有个容易被忽略的性质:如果一次渲染本身就要 200ms,切片只会让它跨 40 帧完成,中间还会被反复打断、可能还得重做。切片的价值在于"让交互插队",而不是"让渲染变快"。工作量没降下来之前切片,你会得到一个"每帧都很滑但迟迟不更新"的界面------用户感知上反而更糟。
2.2 更新粒度阶梯:从 102 次到 0 次,代价各不相同
用一个模型把粒度问题量化。场景:100 行列表,高亮从第 42 行移到第 43 行。五种策略,统计组件函数执行次数 与真实 DOM 写次数(极简 vdom + DOM 打点,成本模型与 React 一致:函数组件每次更新都重新执行,只有 memo + props 浅比较相等才短路,只有 host 节点属性真的变了才写 DOM):
| 阶梯 | 策略 | 组件函数执行 | props 比较 | DOM 写 |
|---|---|---|---|---|
| 1 | 全量重渲染(状态在根组件) | 102 | 101 | 2 |
| 2 | memo + 内联 props(失效) | 102 | 201 | 100 |
| 3 | memo + 稳定 props | 4 | 103 | 2 |
| 4 | 状态下沉 + 精确订阅 | 2 | 2 | 2 |
| 5 | signal 直绑 DOM | 0 | 0 | 2 |
三行结论,每一行都是可以直接拿去评审代码的判据:
- 阶梯 1 与阶梯 2 的组件执行次数完全相同(102 次),但阶梯 2 的 DOM 写次数是阶梯 1 的 50 倍 。这就是"memo 被内联函数击穿是负优化"的完整证据:diff 在
onClick上看到的是"新引用",只能重写属性。给组件加memo之前,先检查它接收的 props 里有没有内联对象/函数; - 阶梯 3 / 4 / 5 的 DOM 写次数完全相同(都是 2 次) ,但组件执行次数是 4 / 2 / 0。粒度优化的天花板由此确定:它只能压缩框架开销,压不掉浏览器开销。真正需要改的 DOM 就那两处,谁也没法把它变成 1 次;
- 性价比拐点在阶梯 3~4 之间。阶梯 4(状态下沉 + 精确订阅)用"状态所有权重构"换取 102 → 2 的降幅,是收益最大且心智成本可控的一步;阶梯 5(signal 直绑 DOM)把组件函数也省掉了,但要求数据源本身可订阅(外部 store / signal),并且调试时"组件树里看不到更新来源",需要团队整体接受这套心智模型。
阶梯 5 的隐藏前提值得单独说:它把"更新"从"重跑组件函数"变成"直接写 DOM 属性"。这意味着组件函数不再是幂等的渲染描述,而是一次性执行的绑定代码------首次执行时建立的绑定关系必须被框架长期持有。这也是为什么 signal 方案在"组件里做条件渲染 + 动态数量"时最复杂:绑定关系需要随结构增删而重建。
2.3 调度内核:手写一个可运行的时间切片调度器
理解了粒度的天花板,再来看"调度"这一层能做什么。下面这个调度器是 React 并发渲染成本模型的忠实复刻------按过期时间排序、帧预算让出、过期强制同步 。时钟可注入,所以每次运行结果完全确定。下面的代码可以直接 node scheduler-core.mjs 跑出结果 (末尾附了驱动代码;完整脚本还包含优先级抢占、过期提升两个场景与不变量断言,断言失败即 exit 1):
js
const Sync = 1, Input = 2, Default = 4, Idle = 16;
const RANK = { [Sync]: 0, [Input]: 1, [Default]: 2, [Idle]: 3 };
const TIMEOUT = { [Sync]: 0, [Input]: 100, [Default]: 250, [Idle]: 2000 }; // 忍耐上限(ms)
let now = 0; // 可注入时钟:真实实现里是 performance.now()
const tick = (ms) => (now += ms);
const UNIT = 0.4; // 遍历一个 fiber 的成本(ms)
const BUDGET = 5; // 每帧留给渲染的预算(ms)
let uid = 0;
const pending = new Map();
const slices = []; // 记录每次切片的耗时,用来验证帧预算
const expires = (t) => t.at + TIMEOUT[t.lane]; // 过期时间 = 开始时间 + 忍耐上限
const moreUrgent = (self) => { // 是否该让出:有没有比我更接近被饿死的
for (const t of pending.values()) {
if (t.id === self.id) continue;
if (expires(t) < expires(self)) return true;
if (expires(t) === expires(self) && RANK[t.lane] < RANK[self.lane]) return true;
}
return false;
};
/** 谁最接近被饿死谁先跑 ------ 这就是 lane 优先级的实现方式 */
function pick() {
let best = null;
for (const t of pending.values()) {
if (!best) { best = t; continue; }
const a = expires(t), b = expires(best);
if (a < b || (a === b && RANK[t.lane] < RANK[best.lane])) best = t;
}
return best;
}
function workLoop() { // 对应 React 的 workLoopConcurrent
while (pending.size) {
const start = now;
const task = pick();
// 已过期 或 SyncLane:不再让出,一次跑完(这就是掉帧的来源)
const force = expires(task) <= now || task.lane === Sync;
for (;;) {
if (!force && moreUrgent(task)) break; // 被更紧急的任务打断,下次接着做
if (!force && now - start >= BUDGET) break; // 让出主线程,去画这一帧
tick(UNIT); task.done++;
if (task.done >= task.units) break;
}
slices.push(+(now - start).toFixed(2));
if (task.done >= task.units) pending.delete(task.id);
tick(0.1); // 帧之间浏览器还要做样式/布局/绘制
}
}
// ------ 试一下:把一次 120 个 fiber 的渲染切成帧内可完成的碎片 ------
pending.set(uid++, { id: 0, lane: Default, units: 120, done: 0, at: now });
workLoop();
console.log(`切片耗时(ms): ${slices.join(' / ')}`);
console.log(`单片最长占用 ${Math.max(...slices)}ms,总工作量 48.00ms ------ 响应性买到了,速度没变`);
三个机制细节,是这段代码真正的知识点:
(1)调度顺序不是"固定优先级队列",而是按过期时间排序。 lane 只是用来算 expirationTime = startTime + timeoutForLane。一个 2000ms 前提交的 Idle 任务,比 1ms 前提交的 Input 任务更"紧急"------因为前者已经快被饿死了。如果按固定优先级排队,低优先级更新在持续高压下会被无限推迟,这是不可接受的(用户会看到"点击了没反应,过一会儿突然全部生效")。
(2)让出的判定是"帧预算",不是"帧数"。 now - start >= BUDGET 用的是 5ms 这样的时间预算(一个 16.7ms 的帧里,还要留给样式、布局、绘制和浏览器自己的活)。真实实现里 React 用 MessageChannel 让出主线程,而不用 setTimeout------因为嵌套超过 5 层的 setTimeout 会被浏览器钳制到 4ms 起,切片粒度直接失控。
(3)过期任务强制同步执行。 一旦 now >= expirationTime,force = true,这次渲染不再检查预算也不再响应中断。这是"最终一致性"的保证:宁可掉一帧,不能让更新永久推迟。
实测输出(三个场景,node scheduler.mjs):
ini
场景 1 时间切片
切片: 13fiber/5.2ms 13fiber/5.2ms ... 13fiber/5.2ms 3fiber/1.2ms
单片最长阻塞: 5.2ms(总工作量 48.00ms,帧预算 5ms)
场景 2 优先级抢占
执行顺序: list-render -> keystroke-input -> list-render -> ...
输入在 t=3ms 到达,第 2 个切片(t=3.3ms)就插队执行
场景 3 过期提升(防饿死)
Idle 任务被选中 3 次,其中 2 次被顶掉、0 个 fiber 都没跑
最终在 t=2470.2ms 执行(已等待 2470.2ms > 忍耐上限 2000ms),强制同步=true
断言通过: fiber 无重复无遗漏 / 切片预算成立 / 阻塞时长从 48ms 降到 5.2ms
场景 1 是切片的核心收益:主线程最长连续占用从 48ms 降到 5.2ms(5ms 预算 + 一个工作单元 0.4ms 的粒度误差),输入事件的排队等待随之下降。注意总工作量没变,还是 48ms------切片买的是"响应性",不是"速度"。
场景 2 是切片真正的目的:交互延迟与树有多大解耦。输入在 t=3ms 到达时,正在进行中的低优先级渲染在下一个工作单元就让出,第 2 个切片就执行完了输入更新。
场景 3 是防饿死的证据:Idle 任务在被持续压制期间被选中 3 次,其中 2 次 0 个 fiber 都没跑就被顶掉;直到等待 2470.2ms(已超过 2000ms 忍耐上限)才被强制执行------此时 force = true,一次跑完 10 个 fiber,不再让出。优先级决定谁先跑,过期提升保证最终一定会跑。
2.4 批处理:边界是"微任务",不是"事件回调"
同一个 tick 内连续三次 setState,现代 React 只会渲染一次(自动批处理),而 React 17 在 setTimeout、Promise 回调、原生事件监听器里会渲染三次。原因在于批处理的边界是微任务:同一个微任务队列内累计的更新会被合并,队列排空时才 flush。实测:
rust
batch 内 3 次 setState -> 排空 1 次微任务,渲染 1 次(3 次更新合并为 1 次)
batch 外 3 次 setState -> 渲染 3 次(每次更新各自渲染一次)
实践含义有两条:不要把"多次 setState"当成性能问题去手动合并 (已经是自动的了);确实需要"立刻看到 DOM"时用 flushSync,要清楚它的代价是放弃这次合并、并强制同步渲染(可能制造长任务)。
2.5 React 侧的正确写法:外部 store + 选择器订阅
把粒度阶梯落到真实代码。关键是把状态从组件树里搬出来,放进外部 store,再让每个消费者只订阅自己关心的那一位信息:
tsx
type State = { activeId: number | null; items: Item[] };
function createStore(initial: State) {
let state = initial;
const listeners = new Set<() => void>();
return {
get: () => state,
set(updater: (s: State) => State) {
const next = updater(state);
if (Object.is(next, state)) return; // 同值不通知:这是护栏,不是优化
state = next;
listeners.forEach((l) => l());
},
subscribe(l: () => void) {
listeners.add(l);
return () => listeners.delete(l);
},
};
}
const store = createStore({ activeId: null, items: [] });
/** 每个 Item 只订阅"我是不是高亮"这一位布尔值 */
function useActive(id: number) {
return useSyncExternalStore(
store.subscribe,
() => store.get().activeId === id, // 返回原始值:快照必须稳定且可比
);
}
function Item({ id }: { id: number }) {
const active = useActive(id);
return (
<li
className={active ? 'active' : ''}
onClick={() => store.set((s) => ({ ...s, activeId: id }))}
/>
);
}
这段代码有三个必须说清楚的点:
(1)getSnapshot 必须返回稳定可比的值。 返回 boolean / number / string 最安全。如果它每次返回一个新对象(() => ({ active: ... })),React 会认为"快照每次都变了",直接进入无限重渲染。这是 useSyncExternalStore 使用中最常见的翻车点。
(2)它同时解决了撕裂。 React 会在渲染期间读快照、在 commit 前校验版本;发现版本变了就丢弃这次渲染重来。不要用 useEffect + useState 手工订阅外部 store------那条路径在并发渲染下无法保证一致性,正是撕裂的温床。
(3)Object.is(next, state) return 不是性能优化,是正确性护栏。 少了它,onChange → setState → onChange 这类循环会把浏览器卡死(Day22 详细讨论过这条护栏)。
2.6 把长更新降级:useDeferredValue 的双缓冲
有些更新天然可以"稍后",比如搜索框输入后重算一个大列表。这时不要用防抖(会丢按键、体感延迟固定),而是把列表的渲染降级到可中断的低优先级 lane:
tsx
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query); // 双缓冲:输入用 query,列表用 deferredQuery
const isStale = query !== deferredQuery;
return (
<>
{/* 受控输入必须同步回显:绝不能把它的 value 放进 transition */}
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Spinner hidden={!isStale} />
{/* 列表跟着一份"滞后的值"渲染,可被打断、可作废 */}
<List query={deferredQuery} />
</>
);
原理是双缓冲 :query 保持同步更新保证输入框即时回显,deferredQuery 在后台渲染完成后才追上来;两者不一致时 isStale 为真,可以显示"加载中"。可中断渲染在这里的价值才真正体现:列表渲染到一半被输入打断时,React 会丢弃这次未 commit 的结果直接用新值重来,因此不会出现"列表显示旧查询结果但输入框已经变了"的不一致。
两条硬约束:受控输入不能放进 transition (否则会出现字符丢失、光标回退);useMemo 不是"防重渲染"工具------它是渲染内的计算缓存,在并发渲染中框架有权丢弃缓存(所以绝不能把副作用或正确性依赖放在里面)。
2.7 虚拟列表内核:Fenwick 树 + 滚动锚定
回到难点六。动态行高下的区间定位必须查前缀和,而前缀和必须能增量更新。用 Fenwick 树(Binary Indexed Tree):查询 O(log n)、单点更新 O(log n)。核心代码同样可以直接运行(正确性用 O(n) 线性扫描做权威基线,5000 行 × 300 组滚动位置逐一比对):
js
function createVirtualizer({ count: n, estimate = 32, overscan = 3 }) {
const heights = new Float64Array(n).fill(estimate);
const tree = new Float64Array(n + 1);
for (let i = 1; i <= n; i++) { // O(n) 建树
tree[i] += heights[i - 1];
const p = i + (i & -i);
if (p <= n) tree[p] += tree[i];
}
const add = (i, delta) => { // 更新第 i 行的高度
for (let x = i + 1; x <= n; x += x & -x) tree[x] += delta;
};
const sum = (i) => { // 前 i 行的高度和
let s = 0;
for (let x = i; x > 0; x -= x & -x) s += tree[x];
return s;
};
/** 返回下标 idx,使得 sum(idx) <= offset < sum(idx+1):O(log n) 二分提升 */
const indexAt = (offset) => {
let idx = 0, bit = 1;
while (bit * 2 <= n) bit *= 2;
let rest = offset;
for (; bit > 0; bit >>= 1) {
const next = idx + bit;
if (next <= n && tree[next] <= rest) { idx = next; rest -= tree[next]; }
}
return Math.min(Math.max(idx, 0), n - 1);
};
let scrollTop = 0, viewport = 600;
return {
setScrollTop: (v) => (scrollTop = v),
setViewport: (v) => (viewport = v),
totalHeight: () => sum(n),
range() {
const bottom = scrollTop + viewport;
const startIdx = indexAt(scrollTop);
let endIdx = indexAt(bottom);
// 起点恰好落在视口底边上的行,可见像素为 0,必须排除
if (sum(endIdx) >= bottom - 1e-9) endIdx = Math.max(startIdx, endIdx - 1);
const start = Math.max(0, startIdx - overscan);
return { start, end: Math.min(n - 1, endIdx + overscan), offset: sum(start) };
},
/** 测量真实行高;若该行在视口上方,返回需要补偿的 scrollTop 增量 */
measure(i, h) {
const above = sum(i) + heights[i] <= scrollTop;
const delta = h - heights[i];
heights[i] = h;
add(i, delta);
return { delta, scrollCompensation: above ? delta : 0 };
},
};
}
// ------ 试一下:10 万行随机估计高度下的区间定位 ------
const v = createVirtualizer({ count: 100000, estimate: 32, overscan: 5 });
v.setViewport(800);
v.setScrollTop(v.totalHeight() * 0.5);
const r = v.range();
console.log(`10 万行命中 [${r.start}, ${r.end}],只渲染 ${r.end - r.start + 1} 行;总高度 ${v.totalHeight()}px`);
// ------ 滚动锚定:视口上方的行被修正后,必须补偿 scrollTop 才不会跳动 ------
const a = createVirtualizer({ count: 1000, estimate: 40, overscan: 2 });
a.setViewport(600);
a.setScrollTop(4000);
const before = a.range().start;
const m = a.measure(20, 96); // 上方第 20 行 40px -> 96px
a.setScrollTop(4000 + m.scrollCompensation);
console.log(`上方行 +${m.delta}px -> 补偿 scrollTop ${m.scrollCompensation}px 后,可见起始行保持 ${a.range().start}(补偿前 ${before})`);
实测输出:
scss
10 万行 Fenwick range(): 节点访问 50 次,命中 [49995, 50029](渲染 35 行)
同样位置线性扫描循环 50001 次,倍差 ~1000x
单行高度修正(更新前缀和)节点访问 17 次
滚动锚定: 上方行 +56px,补偿 scrollTop 56px 后可见起始行保持 98,无跳动。
正确性断言通过: 5000 行随机高度 + 300 组滚动位置,与 O(n) 线性扫描逐一一致。
三个数字各有含义:50 次节点访问 vs 50001 次循环 ,这是"10 万行列表能不能滚得动"的分水岭------线性扫描在每次滚动事件里跑一遍,主线程直接爆掉;单行修正 17 次访问 ,意味着 1000 行同时测量也不会卡;滚动锚定补偿 56px 是"列表不跳动"的量化表达:视口上方的行变高了 56px,就必须把 scrollTop 也加 56px,否则用户看到内容整体下移。而视口下方的行被修正时补偿必须是 0,否则会凭空抖动。
React 侧绑定这三件事就够了:滚动事件用 { passive: true } 并在 requestAnimationFrame 里一帧读一次 scrollTop(不要在滚动回调里同步 setState);用 ResizeObserver 测量行高并把 scrollCompensation 应用回 scrollTop;用总高度撑开占位元素(height: totalHeight),让原生滚动条与 Ctrl+F 的锚点行为尽量正常。
2.8 编译期消除:把"要不要更新"变成编译期常量
运行时优化到顶之后,剩下的收益在编译期。三类机制,本质相同------把"事后 diff"换成"事前标注":
- Vue 的模板编译:静态节点被提升,动态节点被打上 PatchFlags(哪些属性是动态的),并用 Block Tree 把动态子节点收集成扁平数组,更新时只遍历这个数组,跳过整棵静态子树;
- Svelte 5 runes / Solid 的 signal:编译期把每个动态绑定编译成"订阅 + 直写该 DOM 节点",粒度到属性级,组件函数不再是更新的单位;
- React Compiler :自动记忆化,把
memo/useMemo/useCallback的活接管过去。它最大的价值恰恰是替人解决了难点三------编译器不会忘记依赖、也不会被内联函数击穿。
但有两个边界必须写进团队规范:编译器遇到违反规则的代码会直接 bailout(跳过优化) ,所以"能编译"和"被优化了"是两件事,需要看编译器输出而不是凭感觉;编译器优化不了结构问题------如果状态还放在根组件,编译器只能帮你把 102 次执行变成 102 次带缓存的执行,而不会变成 2 次。
浏览器侧还有一层"内容级"优化值得知道:content-visibility: auto 配合 contain-intrinsic-size 可以让屏外内容跳过渲染工作(布局和绘制都被跳过,元素本身仍在 DOM 与可访问性树中,因此 Ctrl+F 和屏幕阅读器照常工作------这正是它与虚拟化的关键区别;但不提供 contain-intrinsic-size 时高度未知,滚动条会随滚动不断变化)。它和虚拟化是互补而非替代 关系:虚拟化省的是内存与 DOM 节点数,content-visibility 省的是渲染工作;对"行数几百、结构复杂"的场景,后者往往比手写虚拟化更划算。
2.9 把性能变成会失败的断言
性能优化最容易的失败方式不是优化没效果,而是三个月后被别人改回去。所以最后一步必须是把上文的量化指标变成 CI 里会失败的断言:
ts
// ① 渲染次数预算:超过就失败
let renders = 0;
function ProbedList(props: Props) {
renders++;
if (renders > 3) throw new Error(`ProbedList 渲染了 ${renders} 次,预算 3 次`);
// ...
}
// ② 长任务埋点:字段数据里能直接看到是谁占住的主线程
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.duration > 50) {
report('longtask', { duration: e.duration, source: e.attribution?.[0]?.containerSrc });
}
}
}).observe({ type: 'longtask', buffered: true });
两个执行细节:渲染次数断言必须在生产构建下跑 (开发构建的 StrictMode 会双调用渲染函数,数字会翻倍,误报率极高);长任务埋点只上报不回传全量数据,用分位数(p75/p95)聚合,否则埋点本身就成了性能负担。
三、应用场景
- 十万行级的表格与长列表:虚拟化 + Fenwick 区间定位是唯一可行解。要不要虚拟化,用"屏外内容是否必须被搜索/打印/复制"来决策;如果不必须,虚拟化收益最大。
- 输入延迟敏感的场景(富文本、公式编辑器、画布工具):这里 INP 就是产品体验本身。做法是三层叠加------受控输入永远走同步 lane、昂贵计算放进 transition、真正的同步计算(如光标位置换算)控制在 5ms 内。
- 低端设备 / 车机 / 大屏 / 跨端 WebView:CPU 预算只有开发机的 1/5~1/10。粒度阶梯的价值在这里被放大:省下的每一次组件执行、每一次无用 DOM 写都是实打实的帧时间。
- 中后台巨型表单:最常见的病是"状态全放在页面根组件"。把状态下沉到字段级、配合选择器订阅,改一个输入框不再触发整页校验和整表重渲染。
- 组件库 / 框架作者:把粒度做成默认能力(细粒度订阅、编译期标注),而不是留给使用者的可选项------使用者既没时间也没上下文去逐组件调优。
四、总结
框架性能优化的本质,是在正确的账本上做减法,并且知道减到哪一步就该停手。
完整的决策链条是四步:先量三本账(框架工作量 / 浏览器工作量 / 用户感知),再减总工作量(状态下沉),然后降更新粒度(memo、选择器订阅、signal、编译期标注),最后才切分调度(时间切片、transition、虚拟化)。顺序不能反,因为切片不减少工作量------它只是让交互能插队。
三个最值得记住的判据:给组件加 memo 之前先检查 props 里有没有内联对象/函数 (被击穿的 memo 比不 memo 更慢,本文实测 DOM 写次数是 50 倍差距);粒度的天花板是框架开销,不是浏览器开销 (阶梯 3/4/5 的 DOM 写都是 2 次);优先级决定谁先跑,过期提升保证最终一定会跑(Idle 任务被压制 2470ms 后强制同步执行------宁可掉一帧,不能让更新永久推迟)。
架构上收敛成一句话:粒度决定"白跑多少",调度决定"什么时候跑",而这两者都建立在"状态放在哪"这个结构决策之上。结构错了,后面两步都是给错误买单。
下一篇 Day24 从"单一应用内的性能"走向"多个应用如何共存",看微前端实现里的隔离、通信与加载时序问题。
运行环境
Node.js ≥ 18(调度器内核与虚拟列表内核两段代码可独立运行;完整的零依赖示例脚本另含优先级抢占、过期提升、粒度阶梯三个场景与不变量断言,断言失败即 exit 1)。React 侧示例需 React ≥ 18(useSyncExternalStore / 自动批处理 / transition lane)。