浏览器时间切片与协作式调度
从长任务、帧预算、requestAnimationFrame 与任务分片角度理解浏览器主线程调度。
[!info] related notes
- 前置概念: js事件循环, 浏览器渲染流程
- 性能指标: Interaction to Next Paint
- 相关能力: Web Worker, 前端工程化中的性能优化
- 相关题单: 拼多多前端春招准备
浏览器时间切片与协作式调度
一句话定义
时间切片的核心不是“让任务变快”,而是把长任务拆成多段执行,主动把主线程让出来,让浏览器有机会响应输入、更新动画和完成渲染。
1. 为什么会需要时间切片
浏览器主线程要同时做几件事:
- 执行 JavaScript
- 处理用户输入
- 计算样式和布局
- 绘制页面
如果一段同步 JavaScript 长时间不结束,浏览器就拿不回主线程控制权,结果就是:
- 点击后没反应
- 动画掉帧
- 输入卡顿
- INP、TBT 变差
这就是“长任务阻塞主线程”。
2. 先抓住一个核心直觉
60fps 表示一帧大约只有 16.67ms。
但这 16.67ms 不是都给 JavaScript 的。浏览器还要留时间给:
- 样式计算
- 布局
- 绘制
- 合成
所以当你看到“每次最多执行 15ms”这类题时,本质是在考:
- 你知不知道主线程不能一直被同步代码霸占
- 你会不会主动在任务之间
yield
3. 常见调度工具怎么选
| 工具 | 适合什么 | 优点 | 边界 |
|---|---|---|---|
requestAnimationFrame | 动画、视觉相关更新 | 回调发生在下一次绘制前,和刷新节奏对齐 | 不适合纯后台重计算;隐藏标签页会降频或暂停 |
setTimeout(fn, 0) | 最通用的让出执行权 | 兼容性最好,易理解 | 定时器有最小延迟,不够精细 |
MessageChannel | 轻量任务分片 | 比 setTimeout 更适合高频切片调度 | 仍然在主线程,不能解决 CPU 太重的问题 |
requestIdleCallback | 低优先级、可延后任务 | 适合空闲时慢慢做 | 空闲不足时可能长期得不到执行 |
Web Worker | 纯计算型重任务 | 真正移出主线程 | 不能直接操作 DOM,需要消息通信 |
4. 为什么高帧动画优先用 requestAnimationFrame
这类题最稳的回答不是“它更快”,而是:
- 它和浏览器绘制节奏对齐
- 回调发生在下一次 paint 之前
- 浏览器可以在后台标签页自动降频,避免无意义消耗
- 比起
setTimeout,更不容易出现漂移和无效重绘
所以做高帧动画、平滑位移、逐帧视觉更新时,requestAnimationFrame 是默认首选。
如果是纯计算分片,不涉及每一帧都要改 UI,则 MessageChannel / setTimeout 更常见。
5. 时间切片题到底在考什么
题目通常会描述成:
- 有大量同步函数
- 每次最多执行
15ms - 然后让浏览器去做动画或渲染
- 某一个函数报错就 reject
它考的是四件事:
- 顺序执行而不是并发乱跑
- 用
performance.now()统计当前切片耗时 - 到预算上限后主动调度下一轮
- 用
Promise暴露完成和错误
6. 一个可用的实现
下面这版默认用 MessageChannel 调度,必要时也可以切到 setTimeout 或 requestAnimationFrame:
function timeSlice(functionArray, option = {}) {
const {
budget = 15,
args = [],
yieldStrategy = 'messageChannel',
onProgress,
} = option;
if (!Array.isArray(functionArray)) {
return Promise.reject(new TypeError('functionArray must be an array'));
}
if (!functionArray.every((fn) => typeof fn === 'function')) {
return Promise.reject(new TypeError('every item in functionArray must be a function'));
}
const tasks = functionArray.slice();
const results = new Array(tasks.length);
let index = 0;
let channel = null;
return new Promise((resolve, reject) => {
const cleanup = () => {
if (channel) {
channel.port1.onmessage = null;
channel = null;
}
};
const scheduleNext = () => {
if (yieldStrategy === 'raf' && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(runSlice);
return;
}
if (yieldStrategy === 'timeout' || typeof MessageChannel === 'undefined') {
setTimeout(runSlice, 0);
return;
}
if (!channel) {
channel = new MessageChannel();
channel.port1.onmessage = runSlice;
}
channel.port2.postMessage(undefined);
};
const runSlice = () => {
const sliceStart = performance.now();
try {
while (index < tasks.length && performance.now() - sliceStart < budget) {
const task = tasks[index];
results[index] = task(...args);
onProgress?.({
index,
total: tasks.length,
result: results[index],
});
index += 1;
}
if (index >= tasks.length) {
cleanup();
resolve(results);
return;
}
scheduleNext();
} catch (error) {
cleanup();
reject(error);
}
};
scheduleNext();
});
}
7. 这版实现的关键点
7.1 为什么用 performance.now()
因为它更适合高精度统计执行耗时,比 Date.now() 更稳。
7.2 为什么不是每执行一个函数都 setTimeout
因为那样切得太碎,调度开销会很高。
合理做法是:
- 当前切片内尽量多执行几个任务
- 一旦接近预算上限,再主动让出主线程
7.3 为什么返回 Promise
因为调用方需要知道:
- 全部任务什么时候完成
- 中间是否有错误
- 每个任务的结果如何汇总
7.4 为什么一出错就 reject
因为题目要求“一旦某个函数报错就返回错误 Promise”,这意味着失败策略是 fail-fast,而不是吞错继续跑。
8. 面试里还可以顺手补的升级点
8.1 支持取消
可以返回一个带 cancel() 的控制器,或者把 AbortSignal 传进来。
8.2 支持异步任务
如果任务本身返回 Promise,可以用串行 await 包一层;但那时“时间预算”主要只约束同步部分,网络等待本身不占主线程。
8.3 计算太重时用 Worker
如果单个任务本身就要跑几十毫秒,光切片不够,因为每一小块仍然会卡。
这时更稳的做法是:
- 把纯计算移到
Web Worker - 主线程只做结果合并和界面更新
9. 这类题最稳的回答模板
这题本质是在做协作式调度。浏览器主线程既要跑 JS,也要处理输入和渲染,所以不能把大量同步任务一次性跑完。我会把任务数组按时间预算分片执行,用
performance.now()统计当前切片耗时,超出预算后通过MessageChannel、setTimeout或requestAnimationFrame把控制权让出去。整个过程用 Promise 包起来,全部执行完就 resolve,只要某个任务抛错就立即 reject。
10. 最短记忆方式
requestAnimationFrame:适合动画MessageChannel/setTimeout:适合主线程分片Web Worker:适合真正重计算- 时间切片解决的是“别一直霸占主线程”,不是“把重活 magically 做没”