我以为 setTimeout(fn,0) 就是长任务切分?Chrome 129+ 把这件事彻底打脸了

用户点了按钮,页面纹丝不动;又点了一次,还是没反应——这不是网络问题,是主线程被一段长任务堵死了。

这是 Interaction to Next Paint(INP)最常见的表现。INP 测量的是用户交互到下一帧渲染的完整延迟,Google 规定超过 200ms 就是”需要改进”。2025 Web Almanac 的数据显示,移动端页面中位数 Total Blocking Time(TBT)高达 1,916ms——将近两秒,主线程一直被 JavaScript 占着,浏览器根本没有机会处理用户输入。

业界解决这个问题的主流方案是 setTimeout(fn, 0),把长任务拆成小块,让浏览器在任务之间喘口气。这个思路是对的,但 setTimeout(fn, 0) 有一个根本性缺陷:你的续体被放到了任务队列的队尾。如果在那个间隙里有 50 个其他任务在等,你的代码要等它们全部跑完才能恢复。一个本该 200ms 完成的任务,可能被拖到数分钟。

Chrome 129 引入了 Prioritized Task Scheduling API 的核心方法——scheduler.yield(),从根本上解决了这个问题。

为什么 setTimeout(fn, 0) 不是真正的答案

setTimeout(fn, 0) 的本质是把你的续体重新放进任务队列,并希望浏览器在调度下一个任务时选择你。但任务队列是公平的,续体在所有待执行任务之后排队,没有优先权。更糟糕的是,浏览器对嵌套 setTimeout 有 4ms 的最低延迟压制——如果你把 200ms 的任务拆成 1ms 一块,200 个碎片 × 4ms 最低延迟 = 800ms 额外开销。

scheduler.yield() 改变了这个逻辑。调用时,当前任务主动让出主线程,浏览器去处理用户输入、渲染等高优先级工作。关键区别在于:恢复时,你的续体被放进优先级队列而非普通队尾,浏览器处理完紧急任务后会立即恢复你的执行,不需要排队等所有其他任务。

实战代码:长任务怎么拆

基本模式——循环大数据处理:

async function processLargeDataset(items) {
  let lastYield = performance.now();
  for (const item of items) {
    expensiveWork(item);
    if (performance.now() - lastYield > 50) {
      await scheduler.yield();
      lastYield = performance.now();
    }
  }
}

事件处理器里的模式——先给视觉反馈,再做重活:

button.addEventListener('click', async () => {
  showSpinner();
  await scheduler.yield();
  const data = await fetchCartData();
  await scheduler.yield();
  renderCartDetails(data);
});

没有第一次 yield,spinner 和 fetch 是同一个长任务,spinner 永远等 fetch 完成后才能被渲染——用户根本看不到 loading 态。两次 yield 把整个交互拆成了三个可中断的小任务,INP 从原来的 600ms 级别直接掉到 200ms 以内。

兼容性兜底代码(Safari不支持scheduler.yield):

function yieldToMain() {
  if ('scheduler' in globalThis && 'yield' in globalThis.scheduler) {
    return globalThis.scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}
await yieldToMain();

什么场景不该用 scheduler.yield()

不是所有地方都要 yield。用之前问三个问题:任务是否超过 50ms(用 performance.now() 测量)?用户在此期间是否会交互?是否有视觉更新需要在中途渲染?三个都是”否”就不用 yield。

纯计算型任务且完全不碰 DOM,直接上 Web Worker;已经在 async 函数里自然 yield 过的代码不需要重复 yield;单次执行低于 50ms 的微任务 yield 的收益抵不过调度开销。

怎么验证 INP 真的变好了

INP 是现场指标,实验室测量只能辅助调试,真实数据才决定你的 Core Web Vitals 分数。用 Google 的 web-vitals 库上报字段数据:

import { onINP } from 'web-vitals';
onINP(({ value, rating }) => {
  console.log('INP: ' + value + 'ms, 评级: ' + rating);
});

真实案例:意大利最大分类信息平台 Subito 移除一个 TikTok 追踪脚本后,INP 从 208ms 降到 170ms。Taboola 某次数据表排序优化后,INP 从 600ms 级别降到 200ms 以内。

下一步

问题从来不是”不知道长任务影响 INP”,而是 setTimeout(fn, 0) 从来不是真正的解法。现在 Chrome 129+、Edge 129+、Firefox 142+ 已经稳定,Safari 用 polyfill 兜底,这件事已经可以全量上线了。

第一步:Chrome DevTools → Performance 面板录一次交互,找到超过 50ms 的长任务。第二步:把这些任务用 scheduler.yield() + polyfill 兜底的方式拆开。第三步:用 web-vitals 上报字段数据,验证真实用户 INP 是否下降。

评论区

0 条评论

登录后可评论。

阿速·性能优化 15 阅读