文章

运行时性能优化深度解析:长任务拆分与帧率保障实战

运行时性能核心是永远别把主线程占满 50ms 以上,通过拆分长任务、利用空闲时间、保证每 16ms 渲染一帧让 JS 执行对交互零感知。 面试常考 requestIdleCallback、requestAnimationFrame 与时间切片,以及一帧 16.67ms 预算的拆解。

运行时性能优化深度解析:长任务拆分与帧率保障实战

一句话概括

页面加载完不代表用户觉得流畅——运行时性能的核心是”永远不要把主线程占满 50ms 以上”,通过拆分长任务、利用空闲时间、保证每 16ms 渲染一帧,让 JS 执行对用户交互”零感知”。

核心知识点

1. 浏览器一帧的生命周期(16.67ms 的预算)

1
2
3
4
5
6
每 16.67ms 一帧(60fps):
┌─ 输入事件处理 ──── 留给 JS 的只有约 10ms
├─ requestAnimationFrame 回调
├─ 样式计算 / 布局
├─ 绘制 / 合成
└─ 空闲时间 → requestIdleCallback

如果 JS 执行超过 50ms(长任务),浏览器无法在下一帧及时渲染,用户感觉”卡顿”。INP(Interaction to Next Paint)衡量的就是”用户点击到画面更新”的延迟——目标 < 200ms。

2. 长任务拆分的三种方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 场景:处理 10000 条数据的排序和过滤
// ❌ 一次处理全部 → 长任务 300ms+ → 页面卡死
const results = bigArray.filter(fn).sort(compare).map(transform);

// ✅ 方法1:用 setTimeout 分段执行
function processInChunks(items, processFn, chunkSize = 100, done) {
  let i = 0;
  function next() {
    const slice = items.slice(i, i + chunkSize);
    slice.forEach(processFn);
    i += chunkSize;
    if (i < items.length) setTimeout(next, 0); // 把控制权交还给浏览器
    else done?.();
  }
  setTimeout(next, 0);
}

// ✅ 方法2:用 requestIdleCallback 在空闲时执行(低优先级任务)
function idleProcess(items, processFn, done) {
  let i = 0;
  function next(deadline) {
    while (i < items.length && deadline.timeRemaining() > 1) {
      processFn(items[i++]);
    }
    if (i < items.length) requestIdleCallback(next);
    else done?.();
  }
  requestIdleCallback(next);
}

// ✅ 方法3:用 scheduler.yield()(2025+ 新 API,明确让出主线程)
async function processWithYield(items, processFn) {
  for (let i = 0; i < items.length; i++) {
    processFn(items[i]);
    if (i % 50 === 0) await scheduler.yield(); // 每 50 条让出一次
  }
}

核心思想:把一个大任务切成多个小任务,每个小任务 < 50ms,中间留出间隙让浏览器处理用户交互和渲染。

3. requestAnimationFrame vs requestIdleCallback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// rAF:在下一帧渲染前执行(高优先级,每 16ms 最多一次)
function animate() {
  element.style.left = `${x}px`;
  x += 1;
  requestAnimationFrame(animate); // 保证跟屏幕刷新同步
}

// rIC:在当前帧的空闲时间执行(低优先级,不保证被执行)
requestIdleCallback((deadline) => {
  // deadline.timeRemaining() 返回本帧剩余时间(ms)
  // deadline.didTimeout 表示是否因为超时而被强制执行
  if (deadline.timeRemaining() > 5 || deadline.didTimeout) {
    preloadNextPageData(); // 预加载、日志上报等不重要的工作
  }
}, { timeout: 2000 }); // 最长等 2 秒,到期强执行
 rAFrIC
触发时机每帧渲染前帧末尾空闲时
频率60fps = 16ms 一次不固定(可能多帧才一次)
用途动画、DOM 更新预加载、上报、缓存清理
保证执行是否(页面忙可能一直不被调)

4. React 中的运行时优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1. useDeferredValue:延迟更新非关键 UI
function SearchPage({ query }) {
  const deferredQuery = useDeferredValue(query); // query 变化时此值延迟更新
  return (
    <>
      <input value={query} />                    {/* 立即更新输入框 */}
      <SlowSearchResults query={deferredQuery} /> {/* 延迟更新结果列表 */}
    </>
  );
}

// 2. useTransition:标记低优先级更新
const [isPending, startTransition] = useTransition();
function handleTabClick(tab) {
  startTransition(() => setActiveTab(tab)); // 标签切换不阻塞用户继续点击
}

// 3. useMemo/useCallback 减少不必要的重计算
const sorted = useMemo(() => items.sort(compare), [items]);
const handler = useCallback(() => doThing(id), [id]);

// 4. 虚拟列表(见下篇):万级列表只渲染视口内的几十条

5. Web Worker:把重计算搬出主线程

1
2
3
4
5
6
7
8
9
10
11
12
13
// main.js
const worker = new Worker('/heavy-worker.js');
worker.postMessage(largeArray);
worker.onmessage = (e) => {
  console.log('计算完成,主线程不受影响:', e.data);
  // 主线程在这期间可以继续处理用户交互
};

// heavy-worker.js
self.onmessage = (e) => {
  const result = e.data.map(heavyComputation); // 不管多慢都不影响 UI
  self.postMessage(result);
};

Worker 不能操作 DOM,但可以做数据格式化、加密解密、图片处理、搜索索引构建等纯计算工作。

其实你每天都在用

  • 页面下拉刷新:你快速下滑微博/Twitter,新内容没有卡顿出现——因为列表用了虚拟滚动,只渲染视口内的 DOM
  • 输入框实时搜索:你搜索时输入框跟手,但结果列表有 0.2s 延迟——React 的 useDeferredValue 或防抖在工作
  • 页面切后台变省电:浏览器在 visibilitychange 到 hidden 时降低 rAF 频率、暂停 rIC——这是浏览器级的节能机制
  • Chrome DevTools Performance 面板的红色长条:每一个 > 50ms 的红色块就是一个长任务——INP 差的罪魁祸首

常见误解(FAQ)

❌ 误区:用 setTimeout(fn, 0) 就能让浏览器不卡

setTimeout 有 4ms 的最小延迟(嵌套 5 层以上),且不感知渲染帧。正确做法是用 requestAnimationFrame(跟帧绑定)或 requestIdleCallback(空闲时执行)。

❌ 误区:加上 will-change 就能开启 GPU 加速

will-change 是提前通知浏览器”这个元素会变化”,浏览器可以预先创建 GPU 层。但滥用会导致 GPU 内存暴涨——只在动画开始前加,结束后移除。更简单的方案优先用 transform 和 opacity(浏览器自动走合成线程,不触发重排)。

❌ 误区:Worker 线程完全不影响主线程

Worker 线程不阻塞主线程,但 postMessage 传递大数据时(非 Transferable)会有结构化克隆的开销——在主线程拷贝数据。大数组用 Transferable(postMessage(data, [data.buffer]))实现零拷贝转移所有权。

❌ 误区:React.memo 套上就不会重渲染了

React.memo 做的是浅比较 props——如果父组件每次传入新的匿名函数或对象(onClick={() => {}} / style={ { color: red } }),memo 白做。需要配合 useCallback 和 useMemo 稳定引用。

一句话总结

运行时性能优化的黄金法则:绝不让主线程连续占用超过 50ms——每个”长任务”都是用户感知到的卡顿。

本文由作者按照 CC BY 4.0 进行授权