你以为 setTimeout(fn,0) 能解决 UI 冻结——今天 scheduler.yield() 证明了这件事根本不是那么回事
长task把UI卡死了,你第一反应是 setTimeout(fn, 0)——但这件事今天被 Chrome 彻底扒开了:setTimeout 把你的代码排到了任务队列的末尾,而 Chrome 129 出来的 scheduler.yield() 把它排到了最前面。这个区别,实测差 100 倍。
你的 UI 冻结,根子在哪里
用户点了一下按钮,页面没反应,过了 200ms 才动。中间这 200ms 发生了什么?
浏览器把一次交互的响应拆成了三段:
Input Delay——浏览器接收到点击事件,但主线程正在忙,排在后面的 click 事件只能等着。主线程被一个 300ms 的 JS 长任务占着,这 300ms 全算 Input Delay。
Processing Time——你的 click handler 真正开始跑了。DOM 更新、fetch 请求、大循环……这段越久,Processing Time 越长。
Presentation Delay——handler 跑完了,但浏览器还要重新 layout、paint,下一帧才能画出来。DOM 太重、CSS 太复杂,这段也会拖很久。
三段加在一起,就是 INP(Interaction to Next Paint)的值。Google 的标准是:≤200ms 是「Good」,200-500ms 要优化,超过 500ms 就是「Poor」,直接进搜索排名扣分项。
主线程被长任务占着,是 INP 最大的凶手。
setTimeout(fn, 0) 这个问题从来没被真正解决
前端圈子的土办法:把长任务切开,每 50 个 item 插一个 setTimeout(resolve, 0),让浏览器有机会在间隙里处理输入。
这个思路是对的。但 setTimeout 的实现有个被低估的坑:
你的代码在让出主线程之后,被排到了任务队列的末尾。
如果这时候队列里有其他任务——第三方脚本的回调、其他组件的初始化、另一个 setTimeout——你的代码要等它们全部跑完才能继续。浏览器给了你一次让路的机会,但转头就让别人插队了。
雪上加霜的是嵌套 setTimeout 的 4ms 限制。每嵌套一层,最小延迟从 0ms 变成 4ms。如果你把一个 200ms 的任务切成 200 份,每份 1ms:
- setTimeout 切片:200 × 4ms = 800ms 纯等待(不算任务本身)
- 任务队列里如果还有其他等待的任务,时间更难预估
一个「让 UI 不卡」的土办法,自己悄悄埋了快 1 秒的税。
scheduler.yield() 的区别:把代码排到队伍最前面
Chrome 129 出来的 scheduler.yield() 做了同一件事——让出主线程——但机制完全不同:
await scheduler.yield() 之后,你的代码不是排到队列末尾,而是以高优先级继续。下一次浏览器有空,优先跑你的代码,而不是让其他任务插队。
两者的对比:
| setTimeout(fn, 0) | scheduler.yield() | |
|---|---|---|
| 队列位置 | 末尾 | 前排(高优先级) |
| 最小延迟 | 4ms(嵌套后累积) | ~0ms |
| 200 次切片实际耗时 | ~2 分钟(800ms 纯等待) | ~1 秒 |
| 浏览器支持 | 所有浏览器 | Chrome 129+ / Edge 129+ / Firefox 142+ |
真实案例: Jangwook 的博客记录了一次 INP 优化过程。一个点击响应 264ms,把它拆成 scheduler.yield() 切片,实测降到 56ms——4.7 倍的改善,不是靠删代码,是靠把切片的机制换掉了。
怎么用:三行代码的渐进增强
scheduler.yield() 目前还不是 Baseline(Safari 没上),所以要写渐进增强:
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
return new Promise(resolve => setTimeout(resolve, 0));
}
注意 globalThis.scheduler 这个写法。Safari 上没有 scheduler 全局变量,如果写 scheduler?.yield?.() 会直接 ReferenceError,必须通过 globalThis 做属性查找。
基本用法:
// 把大循环切片
async function processItems(items) {
for (let i = 0; i < items.length; i++) {
doExpensiveWork(items[i]);
if (i % 50 === 0) {
await yieldToMain();
}
}
}
// click handler 里先让 UI 响应,再跑重活
button.addEventListener("click", async () => {
showSpinner(); // 先让用户看到反馈
await yieldToMain(); // 让浏览器把 spinner 画出来
const data = await fetchData();
render(data); // 再跑重任务
});
时间驱动比计数驱动更精准:
let lastYield = performance.now();
for (const item of items) {
process(item);
if (performance.now() - lastYield > 50) {
await yieldToMain();
lastYield = performance.now();
}
}
如果每个 item 耗时不一样(有的 10ms,有的 0.1ms),按 item 数量切不一定能在 50ms 边界让出,时间驱动就能准确卡住。
你真正应该做的第一步
打开 Chrome DevTools → Performance 面板,录一次你觉得「慢」的交互,找到 Main Thread 里有红色三角的长任务。哪个长任务占用时间最长,就从哪个开始。
然后问自己:这段逻辑必须跑在主线程吗?能进 Web Worker 的,进 Web Worker。如果必须留在主线程,加 await yieldToMain()。
不需要删代码,不需要换框架,不需要等 Safari 支持——这个改动,三行以内。
Chrome 129 + Firefox 142 的覆盖率已经相当可观,渐进增强兜底 Safari 不支持的情况,这事今天就可以做。
评论区
登录后可评论。