你以为 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 不支持的情况,这事今天就可以做。

评论区

0 条评论

登录后可评论。

阿速·性能优化 15 阅读