非关键任务占着主线程不放,我以为 setTimeout 就是解法——今天发现浏览器的原生方案早把这件事彻底变了
非关键任务占着主线程不放,页面每次都卡在那一下——我以为 setTimeout(fn, 0) 就是解法,今天发现浏览器的原生方案早就把这件事彻底变了。
以前我们怎么往后挪任务
前端性能里有一个经典困境:有些计算不紧急,但就是死活要跑在主线程上。
比如表单验证、大列表的过滤排序、预渲染逻辑。这些东西单独看都不慢,但一叠加起来,INP(Interaction to Next Paint)就直接崩给你看。
老派解法就一个:
setTimeout(() => {
doHeavyWork();
}, 0);
原理很简单——把任务扔到下一帧去,让浏览器先把当前帧的渲染做完再跑你的逻辑。
但这个方案有三个根本问题:
第一,0ms 不是真的 0ms。 setTimeout 被归类为 task,在事件循环里排在宏任务队列里,前面还有浏览器自己的渲染、脚本执行、各种浏览器任务。0ms 只代表”尽快”,不代表”让出渲染”。
第二,没有任何优先级概念。 你扔进去的任务和用户点击事件是平起平坐的,没有任何机制说”这个可以等一等,那个要立刻处理”。
第三,无法组合。 你不能让一个任务跑到一半主动让出,然后再接着跑。setTimeout 是一次性买卖,要么全跑,要么全不跑。
Chrome 给了你一套原生调度器
Chrome 从 83 开始就在推进 Scheduler API,但真正能打的版本是 Chrome 129——scheduler.yield() 正式进入了 Baseline。
这套 API 干的事情很简单:给你一套原生机制,让主线程的任务调度第一次有了”可中断、可协作、可设置优先级”的能力。
第一个工具:scheduler.yield()
这是最直接的一个。
async function doHeavyWork() {
const data = await fetch('/api/large-dataset').then(r => r.json());
// 分批处理,每批让出主线程
const batchSize = 1000;
for (let i = 0; i < data.length; i += batchSize) {
processBatch(data.slice(i, i + batchSize));
// 关键:主动让出主线程
await scheduler.yield();
}
}
scheduler.yield() 的语义是”把这个任务暂停,等浏览器完成渲染和更高优先级任务,再继续往下跑”。
对比一下 setTimeout(fn, 0):
| setTimeout | scheduler.yield() | |
|---|---|---|
| 让出时机 | 任务结束后才排队 | 主动 yield 点,让出更及时 |
| 优先级 | 无 | 可控 |
| 组合性 | 差 | 好(async/await) |
| 帧率影响 | 不确定 | 明确让渲染优先 |
第二个工具:scheduler.postTask()
如果你不只是想让出,而是想让任务分优先级,那就用 postTask。
// 后台任务:可以被高优先级任务随时打断
const bgTask = scheduler.postTask(() => {
return heavyComputation();
}, { priority: 'background' });
// 用户交互触发的紧急任务
const urgentTask = scheduler.postTask(() => {
return updateUI();
}, { priority: 'user-blocking' });
// 等后台任务结果,但可以继续处理其他事
bgTask.then(result => console.log(result));
postTask 的 priority 有四个级别:
user-blocking:最高优先级,用户交互相关,比如点击响应user-visible:默认优先级,用户能感知但不是立即要的background:低优先级,后台计算、预加载之类low:最低优先级,能不跑就不跑
这里有一个关键细节:高优先级任务可以把低优先级任务”抢占”。 也就是说,如果你正在跑一个 background 任务,用户突然点了按钮,浏览器可以把你的后台任务暂停,先跑用户点击相关的逻辑。
这在 setTimeout 时代是完全不可能的。
第三个工具:TaskController.setPriority()
postTask 返回一个 TaskSignal,你可以在运行过程中动态改优先级。
const controller = new TaskController();
const task = scheduler.postTask(() => doHeavyWork(), {
signal: controller.signal
});
// 初始是 user-visible,用户滚动时提升
scrollButton.addEventListener('mouseenter', () => {
controller.setPriority('background'); // 滚动时降低,避免干扰
});
scrollButton.addEventListener('mouseleave', () => {
controller.setPriority('user-visible'); // 离开后恢复
});
这个组合适合那种”初始不重要,但执行过程中变重要”的场景。
真实场景:INP 优化怎么用这套 API
INP(Interaction to Next Paint)是 2024 年开始纳入 Core Web Vitals 的指标,衡量的是用户交互到下一次渲染的延迟。INP 的问题大多数时候不是单个任务慢,而是多个非关键任务把主线程堵了。
典型场景:页面加载时同时触发多个初始化任务。
// 以前的写法:全部堆在一起
document.addEventListener('DOMContentLoaded', () => {
loadTranslations(); // 不紧急,但占主线程
initAnalytics(); // 不紧急,但占主线程
renderCriticalUI(); // 紧急,必须先跑
});
用 postTask 重构:
document.addEventListener('DOMContentLoaded', () => {
// 关键 UI 先跑
renderCriticalUI();
// 非关键任务全部降权
scheduler.postTask(loadTranslations, { priority: 'background' });
scheduler.postTask(initAnalytics, { priority: 'background' });
// 如果用户开始交互,暂停后台任务
const controller = new TaskController();
document.addEventListener('pointerdown', () => {
controller.setPriority('low'); // 交互时把后台任务压到最低
}, { once: true });
});
这样用户的第一次点击几乎一定是在 200ms 内得到响应的,因为后台任务会主动让路。
降级方案
Scheduler API 目前在 Chrome 129+、Firefox 101+、Safari 17+ 支持。如果你要兼容更老的浏览器,可以这样写:
// 降级:没有 scheduler.yield() 就用 setTimeout
const yieldIfNeeded = () => {
if ('scheduler' in window && 'yield' in scheduler) {
return scheduler.yield();
}
return new Promise(resolve => setTimeout(resolve, 0));
};
async function doHeavyWork() {
for (let i = 0; i < data.length; i += batchSize) {
processBatch(data.slice(i, i + batchSize));
await yieldIfNeeded();
}
}
@supports 也可以检测:
@supports (scheduler.yield()) {
/* 使用现代调度 API 的样式 */
}
总结:什么时候用这套 API
scheduler.yield() 和 postTask 不是万能解法,它们解决的是“非关键任务抢占主线程”这个问题。
适合的场景:
- 列表过滤/排序(大数据量)
- 表单批量校验
- 预渲染逻辑
- 大数组的 map/filter/reduce
- 任何”用户不等待结果但会受影响”的计算
不太适合的场景:
- 用户点击的即时响应逻辑(这个本来就不该卡)
- 已经用了 Web Worker 的计算密集任务
- 需要同步返回结果的操作
一个可落地的判断标准:如果一个操作用户感知不到它跑了多久,但它跑了之后页面变卡了——这件事就值得用调度 API 重写。
Chrome 129+ 已经默认开启,这套机制不是实验性功能了。现在迁移的成本极低,降级方案写三行代码就能兼容老浏览器。
下一步建议:找项目里最常被投诉”卡”的场景,用 postTask 把初始化任务降权跑一遍,INP 数据说话。
评论区
登录后可评论。