浏览器任务调度:长任务拆分、requestIdleCallback 与优先级
本文目录
页面卡顿的第一元凶几乎总是长任务(long task):一段超过 50ms 的同步 JS 占住主线程,期间点击不响应、滚动掉帧、动画停住。解决它的手段不是"把代码写快点",而是调度——把工作切开、让出主线程、把优先级交还给用户输入。这篇文章把事件循环的渲染时机讲清楚,然后按优先级从高到低过一遍可用的调度手段,最后落到三个典型场景。
事件循环:task、microtask 与渲染的关系
一帧里浏览器要做的固定事情是:跑一个 task(可能多个)→ 清空全部 microtask → 有必要就渲染(rAF 回调 → style → layout → paint → 合成)。记住三条规则就够用:
| 规则 | 含义 | 推论 |
|---|---|---|
| task 之间可以插入渲染 | 完整跑完一个 task 后浏览器"有机会"渲染 | 把长任务切成多个 task = 给渲染留出机会 |
| microtask 清空后才会渲染 | Promise 回调串起来形成 microtask 链 | await 循环里就算写 await Promise.resolve() 也不会让出主线程 |
| rAF 在渲染前、渲染后是下一个 task | rAF 回调里的布局读写走"帧预算" | 动画相关逻辑放 rAF,别放 setTimeout |
第三条最常被违反:想用 await null 让 UI 刷新一下,写出来往往是无效让步——microtask 队列清空不触发渲染。真正的让步必须制造一个新的 task(setTimeout、MessageChannel、scheduler.yield)。
长任务与 TBT:先量化再优化
指标口径:任务超过 50ms 记为 long task;TBT(Total Blocking Time,总阻塞时间)= 每个长任务超出 50ms 部分之和,取自 FCP 到 TTI 之间。可以直接在浏览器里观测:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`长任务 ${entry.duration.toFixed(0)}ms,从 ${entry.name} 开始`);
}
});
observer.observe({ entryTypes: ['longtask'] });
定位到具体代码用 Chrome DevTools 的 Performance 面板:录制一段操作,看 Main 线程上的黄色长块,点开自底向上(Bottom-Up)面板找最重的函数。没有这两个数据就动手拆任务,拆的往往是错误代码。
拆分策略:分片处理与状态保存
拆长任务的核心是三件事:分片大小由帧预算决定、跨片状态要显式保存、迭代进度可恢复。
// 把一个同步循环改造成"每片最多 8ms"的分片处理
interface SliceContext<T> {
items: T[];
index: number; // 显式保存进度:下次从这继续
result: ReturnType<typeof handle>[];
}
function processInSlices<T>(ctx: SliceContext<T>): Promise<void> {
return new Promise((resolve) => {
const sliceStart = () => {
const start = performance.now();
while (ctx.index < ctx.items.length) {
handle(ctx.items[ctx.index]);
ctx.index++;
if (performance.now() - start > 8) { // 帧预算内只做 8ms
setTimeout(sliceStart, 0); // 新 task:渲染机会来了
return;
}
}
resolve();
};
sliceStart();
});
}
分片大小的选择不是拍脑袋:
- 帧预算 16.7ms(60fps)内,留给 JS 的安全余量约 8–12ms——用
performance.now()实测每个切片耗时,而不是数条数(单条成本不稳定时按条数分片会失衡)。 - 切片的上下文(
index、累积结果)必须显式保存,不能依赖闭包里的隐式状态——否则"中途插入别的任务"就丢进度。 - 切片之间用户可能操作了界面:如果
items所依赖的数据源变了(筛选条件变了),下一片开始前要校验代数(generation counter),过期就作废重来。
对可迭代逻辑,可以用生成器让代码保持"同步样子":
function* buildRows(rows: RowData[]) {
for (const row of rows) {
createRowNode(row); // 逐条 yield,外部调度器控制节奏
yield;
}
}
async function run(generator: Generator<void>, budgetMs = 8): Promise<void> {
let start = performance.now();
while (true) {
const { done } = generator.next();
if (done) return;
if (performance.now() - start > budgetMs) {
await nextTask(); // 见下文 MessageChannel 实现
start = performance.now();
}
}
}
同一个 340ms 的初始化任务,同步执行会连续掉帧(示意数据,以自测为准);分片后每帧占 10ms,渲染始终有机会跑,总完成时间略长(多出切片开销),但交互不卡。这就是"用总时间换响应性"的取舍。
scheduler.yield 与新调度 API
新方向是 scheduler.yield():让出主线程并带优先级地恢复,返回 Promise:
const scheduler = (window as any).scheduler;
async function work() {
for (const item of items) {
handle(item);
await scheduler.yield('user-visible'); // 优先级声明:与可见 UI 同级恢复
}
}
它相对 setTimeout 的两个优势:优先级可控(user-blocking / user-visible / background),以及后续调度能插队响应输入。配套的 scheduler.postTask 则用来提交任务时就带优先级。边界:这是较新的 API,Safari/Firefox 支持情况要查表,必须带 setTimeout 回退;它也不能解决"单任务本身太长"的问题——拆分逻辑仍然要自己写。
requestIdleCallback 的真实边界
requestIdleCallback(rIC)听起来是"浏览器空闲时再干活"的完美方案,但它的文档小字才是关键:
- 回调拿到的预算很小:
deadline.timeRemaining()通常只有几毫秒到 50ms,浏览器随时可以给你 0,所以每个回调必须可中断、可续期:
function idleWork(deadline: IdleDeadline): void {
while (queue.length && deadline.timeRemaining() > 2) {
doOneUnit(queue.shift()!); // 每次只做一个"单元"
}
if (queue.length) requestIdleCallback(idleWork, { timeout: 2000 }); // 续期
else finish();
}
- 可能长期不被调用:页面不空闲(动画持续播放、持续输入)时回调可能一直排不上;带
timeout的重载是兜底——超时后回调被强制执行(此时timeRemaining()可能是 0,别假设有预算)。 - 隐藏页面被限频:后台 tab 的 rIC 最低被节流到约 1 秒一次,拿它做"后台整理"要接受这个节流。
- 传入的 deadline 只是当次的预算,跨次续期要自己排队。
适用判断:rIC 适合没有截止时间、做一点是一点的工作(预计算、缓存预热、日志整理)。需要"今天必须做完"的任务(数据迁移、初始化)用主动分片更可控。
MessageChannel 让步:setImmediate 的现代等价物
不依赖任何实验 API、被广泛验证的让步手段是 MessageChannel:宏任务通道,且比 setTimeout(fn, 0) 的最小延迟更真实(后者受嵌套调用节流影响,连续链上可能被强制延迟到 4ms+):
function makeYieldToMain(): () => Promise<void> {
const channel = new MessageChannel();
const pending = new Set<(v?: never) => void>();
channel.port1.onmessage = () => {
const resolvers = [...pending];
pending.clear();
for (const resolve of resolvers) resolve(); // 恢复所有等待者
};
return () =>
new Promise<void>((resolve) => {
pending.add(resolve as never);
channel.port2.postMessage(null); // 空 postMessage 只为制造新 task
});
}
const yieldToMain = makeYieldToMain();
async function chunked(items: Item[]): Promise<void> {
for (const item of items) {
heavyStep(item);
await yieldToMain(); // 让出一帧:输入与渲染有机会插入
}
}
这个模式的另一个收益:让步点之间浏览器可以处理输入。如果整段循环不让步,用户点了取消按钮,处理器根本感知不到。
到这里可以把所有让步手段放进一张表对比选型:
| 手段 | 让出粒度 | 恢复优先级 | 兼容性 | 适用 |
|---|---|---|---|---|
setTimeout(fn, 0) | 新 task | 固定(有嵌套 4ms 强制延迟) | 全部 | 简单场景兜底 |
MessageChannel | 新 task | 普通优先级 | 全部(现代浏览器) | 通用分片的默认选型 |
requestIdleCallback | 空闲帧 | 低(可能长期不执行) | Chrome 系 + 节流 | 无截止时间的整理工作 |
scheduler.yield() | 新 task | 可声明(三级优先级) | 较新 Chromium | 支持面足够后替代 MessageChannel |
requestAnimationFrame | 渲染前 | 与渲染绑定 | 全部 | 动画/布局相关,不是通用让步 |
一个容易被忽略的坑:让步不等于渲染一定发生。浏览器只在"有脏区域或动画"时才真正渲染,让步后立刻读布局可能拿到旧值。依赖"让步后界面已更新"的逻辑(如测量新节点高度)要用 rAF 或 ResizeObserver 确认,而不是赌渲染时机。
用户输入优先:isInputPending 思路
让步本身有成本(调度开销、缓存变冷),理想状态是"没有输入时一直跑,有输入立刻停"。navigator.scheduling.isInputPending() 提供了这个判断(Chromium 实现的非标准 API,使用前查兼容性,并提供降级):
const scheduling = (navigator as any).scheduling;
async function inputAwareLoop(items: Item[]): Promise<void> {
for (const item of items) {
heavyStep(item);
const inputPending = scheduling?.isInputPending?.({ includeContinuous: true });
if (inputPending) await yieldToMain(); // 有输入等着处理:立即让步
else if (performance.now() - lastYieldAt > 20) await yieldToMain(); // 否则长间隔让步
}
}
注意它只能看到排队中的输入,看不到"用户正在瞄向按钮";因此要保留"最长 20ms 必让步"的保底规则,不能完全依赖它。没有该 API 的浏览器退化为纯时间片。
三个实践场景
| 场景 | 坏味道 | 调度方案 |
|---|---|---|
| 大列表初始化(万级节点) | 一次性 appendChild 上万个节点,页面冻结数秒 | 分片插入(每片 8ms)+ 虚拟化减少实际节点量;插入用 DocumentFragment 批量挂载 |
| 大文件解析(JSON/表格 MB 级) | JSON.parse 一口气阻塞几百 ms 起步 | Worker 里跑解析,主线程只收结构化结果;无法 Worker 化的按流分块 |
| 批量 DOM 读写 | 循环里交替读写样式(强制同步布局抖动) | 读写分离:先全部读、后全部写;写集中在 rAF |
批量 DOM 场景多给一眼,因为它常和长任务互相伪装:
// 坏:每轮迭代都触发强制同步布局(layout thrashing)
for (const el of elements) el.style.height = el.offsetTop + 10 + 'px';
// 好:读写分离,只触发一次布局
const offsets = elements.map((el) => el.offsetTop);
elements.forEach((el, i) => (el.style.height = `${offsets[i] + 10}px`));
这类问题拆任务救不了——总布局成本没变,只是把抖动变成一次大任务。先消除抖动,再看是否还需要分片。
注意事项与边界
- 所有"按时间分片"的方案在低端机上都要重新调参:同样的代码在高端机每片 8ms、低端机可能 20ms 才完成一半的工作,阈值用实测数据定。
- 别在
await yieldToMain()的分片循环里持有锁类状态(如全局"正在初始化"标志却从不重置),中断与并发路径很容易制造"永远完成不了"的任务。 - 用户可感知的等待(点按钮→出结果)里不要过度分片:把 300ms 的同步任务拆成 60 帧意味着用户多等 1 秒。分片对象是"用户没在等"的工作,交互响应路径上应该直接优化算法或挪去 Worker。
setTimeout(…, 0)不是让步的可靠原语:HTML 规范允许嵌套超 5 层后强制 4ms 最小延迟,且优先级不可控。- 分片逻辑要可取消(用户翻页、切筛选时旧分片必须停),实现上用 generation/AbortSignal 打标。
- 调度优化做完后用 RAIL 口径复查:输入响应 <100ms、动画 16ms/帧、空闲工作 <50ms/片——指标复核比体感更可信。
总结
任务调度的核心是让出主线程的三个手段按优先级取用:交互路径直接优化或进 Worker;可延迟工作用主动分片(时间预算 + 状态保存 + 可取消);空闲工作用 rIC 或 scheduler.yield。所有手段都从同一个事实出发——渲染和输入只发生在 task 之间,把 task 切小,一切才有插队的机会。动手之前先用 PerformanceObserver 与 Performance 面板拿到长任务清单,让每一次拆分都有数据可验证。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。