你以为 setTimeout(resolve,0) 解决了长任务?今天 Chrome 129 把这件事用 scheduler.yield() 彻底原生化了

2025 Web Almanac 出了个数据:mobile 端 Total Blocking Time 中位数 1,916ms,比前一年涨了 58%。换句话说,用户点一下按钮,平均要等将近 2 秒才能看到响应。长任务(Long Task)是罪魁祸首,而大多数前端团队还在用 setTimeout(resolve,0) 这个 2010 年的 hack 假装解决了问题——其实只是把任务排到了队列末尾,根本没真正拆分。

Chrome 129(2024年9月)正式支持的 scheduler.yield() 把这件事彻底原生化了,跟 React 团队联合开发,WHATWG 标准,是浏览器内建的调度原语。


setTimeout(resolve,0) 到底有什么问题

它的原理很简单:把后续代码排到 macrotask 队列末尾,等当前任务跑完、浏览器完成其他工作后,再开一个新任务。听起来没问题,但有三个隐藏的坑:

第一,其他脚本可以插队。 setTimeout 把 continuation 放在队列最末端,和其他新加入的任务同等优先级。如果页面上有个第三方脚本在用户点击后 30ms 被加载并加入队列,它会抢在你前面执行。

第二,嵌套限制。 连续 5 层 setTimeout(fn,0) 之后,最小延迟从 0ms 变成 5ms——浏览器强制加的,不是你写的。

第三,最关键的问题:它没有优先级概念。 浏览器不知道你的 continuation 是从长任务里”逃出来”的,它只知道队列里有个新任务。

结果就是:用户点了按钮,event handler 跑了 180ms → 浏览器不能 paint → 60ms 时 setTimeout(fn,0) 加入队列 → handler 跑完 → 浏览器选下一个任务 → 如果有 Analytics 脚本在队列前面,用户继续等 → 用户感觉到的是「点完按钮要卡好久」。


scheduler.yield() 的本质差异

// 之前:setTimeout 的问题
async function processLargeData(items) {
  for (let i = 0; i < items.length; i++) {
    // 处理每条数据...
    if (i % 100 === 0) {
      // continuation 放队列末尾,其他脚本可能插队
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

// 现在:scheduler.yield 真正拆分任务
async function processLargeData(items) {
  for (let i = 0; i < items.length; i++) {
    // 处理每条数据...
    if (i % 100 === 0) {
      // 明确告诉浏览器:这里需要切分,continuation 优先级高于同类任务
      await scheduler.yield();
    }
  }
}

核心区别:setTimeout 把 continuation 放队列末尾(和其他新任务同等优先级);scheduler.yield() 把 continuation 放在当前任务之后、队列中其他任务之前(浏览器把它识别为高优先级 continuation)。

另一个关键点:yield 的位置比频率重要。在 event handler 开头 yield 等于没 yield——用户事件都还没处理完就让出主控权,浏览器根本不知道你是在处理用户的交互。应该在数据处理循环中间 yield,让浏览器在每段之间有机会处理 input event、paint frame。


什么时候 yield:50ms 法则

单任务超过 50ms 就会触发 Long Task,INP(Interaction to Next Paint)的 Processing Time 就是 event handler 中超过 50ms 的部分。yield 策略很简单:每处理 50ms 的工作量就让出一次

// 实用策略:按处理块 yield,而非按时间 yield
async function processWithYield(items, chunkSize = 50) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    processChunk(chunk);
    // 每处理完一块,让浏览器喘口气
    await scheduler.yield();
  }
}

// 兜底写法:Safari 未支持时的兼容方案
function yieldToMain() {
  if ('scheduler' in window && 'yield' in window.scheduler) {
    return window.scheduler.yield();
  }
  return new Promise(resolve => setTimeout(resolve, 0));
}

Yield 不是万能的。纯计算、DOM 读操作(不触发 layout)不需要 yield;yield 有上下文切换开销,频繁 yield 会让你的处理速度变慢。


实际收益

假设你有个处理 1,000 条数据的 handler,无 yield:Processing Time = 200ms,INP 差不多就是这个数。改成每 50 条 yield 一次:handler 被切成 20 个小任务,每个 ~10ms,浏览器在每段之间可以处理 input event、paint frame。用户感知延迟从 ~200ms 变成 ~10ms。

Chrome DevTools 里能直接看到这个变化:录制同一交互,有 yield 的版本里 frame 数量明显增加,每个 frame 时长从 200ms+ 变成 16ms 左右。


三步上手

第一步,找长任务。 Chrome DevTools Performance 面板里,超过 50ms 的任务会标红;或者用 PerformanceObserver 线上监控:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.log('Long task:', entry.duration, 'ms');
    }
  }
});
observer.observe({ type: 'longtask', buffered: true });

第二步,在长循环里加 yield。 大数据处理、复杂计算、第三方 SDK 调用,都是优先目标。

第三步,量效果。 用 web-vitals.js 采集 INP,对比 before/after;Chrome DevTools 录制同一交互,对比 frame 数量和每帧时长。

Safari 还没支持这个 API,feature detection 兜底写法必须保留,否则 Safari 用户会报错。Chrome 129+ 已经稳定,生产环境可以直接用。

评论区

0 条评论

登录后可评论。

阿速·性能优化 15 阅读