运行时性能优化深度解析:长任务拆分与帧率保障实战
运行时性能核心是永远别把主线程占满 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 秒,到期强执行
| rAF | rIC | |
|---|---|---|
| 触发时机 | 每帧渲染前 | 帧末尾空闲时 |
| 频率 | 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——每个”长任务”都是用户感知到的卡顿。