框架性能优化

一、技术难点:你优化的,是一个不完全由你掌控的运行时

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 决定是否短路子组件。它失效的原因有四类,每一类都在生产代码里真实存在:

  1. 内联对象/箭头函数 :onClick={() => pick(id)} 每次渲染都是新引用,浅比较必然失败;
  2. children 每次重建 :JSX 语法糖产出的元素对象每次都是新的,<Child>{<span/>}</Child> 会让 memo 失效;
  3. Context 穿透 :memo 只挡 props,不挡 context。Provider 的 value 变了,子树里所有消费者照样重渲染,memo 包几层都没用;
  4. 比较成本 > 渲染成本 :给一个"只渲染一个 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。

难点六:列表虚拟化的三个反直觉难点

虚拟化听起来很简单:只渲染可见行。但一旦行高不固定,三个问题同时出现:

  1. 定位不能靠除法:"第几个行在 2000px 处"要靠前缀和查表。如果用朴素数组维护前缀和,每测量一行就 O(n) 重算------10 万行列表滚动时这就是灾难;
  2. 滚动锚定 :虚拟化会异步测量真实行高。如果视口上方的行被修正(40px 变成 96px),它下面所有内容会整体位移 56px,用户看到列表"跳了一下";
  3. 虚拟化本身有代价: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)。

相关推荐
回眸&啤酒鸭10 小时前
【回眸】OpenSwarm 多智能体协作系统实战指南
大数据·前端·人工智能
用户693717500138410 小时前
2026,程序员的时代拐点到了
android·前端·后端
大龄秃头程序员10 小时前
一次 iBeacon + BLE 无感解锁方案的实现记录
前端
小兔子10 小时前
Python 的 GIL 与 free-threading:3.13 之后「去 GIL」走到哪一步了
前端
IT_陈寒10 小时前
SpringBoot自动配置差点让我加班到凌晨
前端·人工智能·后端
guslegend11 小时前
脚手架原理与本地调试:从 bin 软链接到 npm link
前端·npm·node.js·脚手架·前端工程化
海码事务所12 小时前
Google Play 新个人开发者账号上架指南:12 人连续 14 天封闭测试怎么做?
前端
田威AI12 小时前
图片内文字翻译的规格:输入输出、保真、自动化、时间与费用
前端·计算机视觉
不可能片场12 小时前
命令行中文变问号 我用环境变量救了场
前端·electron