我以为setTimeout(fn, 0)解决了长任务切分,直到换成了scheduler.yield()——同样都是让路,体感为什么完全不一样

长任务跑到一半,点了按钮没反应——这个坑我以为 setTimeout(fn, 0) 已经解决了,直到我换成了 scheduler.yield(),体感完全是两回事。

点筛选按钮,页面要处理几千条数据。切长任务这个思路大家都熟,最常见的写法就是 setTimeout(fn, 0),确实不卡了。但有时候那种”让了但又没完全让”的感觉特别明显——点击是立刻有反馈了,可我那段计算的后半截,迟迟跑不完。

换了 scheduler.yield() 之后,体感完全不一样:点击立刻响应,后半截计算也几乎无缝接着跑完。差别在哪?就在四个字——优先级。

setTimeout 让完,掉队尾了

主线程同一时间只能干一件事。你那段长 JS 跑起来,点击、渲染全都得排队。切任务的本质,是在大循环里主动留个”喘息点”,让浏览器去处理等着的高优先级操作。

setTimeout(fn, 0) 也能制造喘息点。问题不在于它让不让,而在于让完之后,你的后半段排在了哪儿。

浏览器任务队列里有一套隐性的优先级规则:

  • 用户交互:点击 / 输入 / 键盘,最优先,不能让用户等
  • 渲染相关:样式计算 / 布局 / 绘制
  • 普通宏任务:setTimeout 回调 ← 你的后半段在这里
  • 空闲回调:requestIdleCallback,有空才跑

用 setTimeout 把后半段甩出去,它就掉到了”普通宏任务”这一层。只要队列里还有别的 setTimeout、别的宏任务排在前面,你的后半段就得等。这就是”让得不够干脆”的原因——不是没让,是让完被挤到后面去了。

打个比方:你在银行办业务到一半想去洗手间,setTimeout 的做法相当于直接撕掉手里的号,回来重新从队尾排——白等一轮。

scheduler.yield() 让权,但不让位

scheduler.yield() 的核心设计一句话概括:让出执行权给浏览器处理高优先级的活儿,但你的后半段,优先级被原样保留下来。

还是银行那个例子。scheduler.yield() 相当于有”叫号保留”机制的银行:你去洗手间前跟柜员说一声,号还给你留着。期间柜员先处理加急业务,但你一回来,马上轮到你,不用从队尾重排。

对比两段代码,差别全在最后那个”让”字怎么写:

// 写法一:setTimeout 切分 —— 让完掉队尾
function handleHeavyWork() {
  processChunk1();
  setTimeout(processChunk2, 0); // 后半段沦为普通宏任务,前面有谁就等谁
}

// 写法二:scheduler.yield() 切分 —— 让完保位置
async function handleHeavyWork() {
  processChunk1();
  await scheduler.yield(); // 让出主线程,但后半段优先级不变
  processChunk2(); // 高优先级处理完,立刻轮到我
}

两段逻辑做的是同一件事:处理完前半段,喘口气,再处理后半段。但写法二里,processChunk2 既给点击事件让了路,又不会被别的任务插队——这就是”响应快、收尾也快”的来源。

它能做到这点,靠的是返回一个 Promise。await 之后的后半段,会被调度成一个带着原优先级的任务重新入队。当轮到普通任务时,它排在”同级队伍的最前面”,而不是被丢到队尾。

把两种写法的主线程时间轴摆在一起,差别一眼看出来——假设让出那一刻,队列里正好还堆着别人的两个 setTimeout:

setTimeout 切分:后半段被别人插到前面
─────────────────────────────────────────────
前半段 | 处理点击 | 别人任务A | 别人任务B | 后半段
         ↑让出                      ↑你在这才轮到
         (拖了好久)

scheduler.yield() 切分:让完立刻轮到你
─────────────────────────────────────────────
前半段 | 处理点击 | 后半段 | 别人任务A | 别人任务B
         ↑让出   ↑你紧接着就跑完
         (别人被排到你后面)

最实用的场景:点击后的即时反馈

回到筛选按钮那个场景,写法非常自然:

async function onFilterClick() {
  // 1. 先给用户一个立刻能看见的反馈
  showLoadingSpinner();

  // 2. 让出主线程,让这次点击的视觉反馈先画出来
  await scheduler.yield();

  // 3. 再跑那段重计算,优先级仍被保留,不会被别的任务插队
  const result = runHeavyFilter(rawList);
  renderList(result);
}

用户手指刚离开按钮,加载动画立刻出现(因为渲染被让出去先做了),紧接着结果几乎无缝刷出来(因为重计算保住了优先级,没被别人挤掉)。这种”跟手”的感觉,就是 INP 想衡量的东西。

这种”大循环卡主线程”的场景平时到处都是:

  • 后台管理表格导出:点”导出 Excel”,前端要逐行格式化上万条数据,一卡就是两三秒,这期间整个页面点啥都没反应
  • 富文本实时渲染:用户在编辑器里狂敲字,每次重新解析一大段文档,打字就开始掉帧
  • 首屏一次性渲染长列表:商品列表、评论列表上千条直接 map 出来,首屏直接白屏一下
  • 批量图片前端压缩:选了几十张图要压缩上传,循环处理时按钮全卡死

这些场景的共同点是:一个停不下来的大循环。处理思路也一样——在循环里按时间片定期 yield,既不卡顿又不拖慢整体:

async function processInChunks(items) {
  let lastYield = performance.now();
  for (const item of items) {
    handle(item);
    // 每跑够 50ms 就让一让,把交互的口子留出来
    if (performance.now() - lastYield > 50) {
      await scheduler.yield();
      lastYield = performance.now();
    }
  }
}

配合 postTask:整段任务降级,内部仍保相对优先级

如果整段活儿本身不急,可以用 scheduler.postTask() 把它标成低优先级丢到后台,而 scheduler.yield() 在它内部依然好使:

async function backgroundJob() {
  part1();
  await scheduler.yield(); // 后半段排在其他 background 任务之前,但依旧让位给高优先级
  part2();
}

scheduler.postTask(backgroundJob, { priority: "background" });

这里的”相对优先级”很关键:后半段会排在其他 background 任务前面,但依旧让位给点击、渲染这些高优先级的活儿。整段任务对外是”背景级”,对内仍保持着自己的次序。

什么时候别急着用

截至 2026 年中,scheduler.yield() 在 Chrome、Edge、Firefox 都已支持(Firefox 是 2025 年 8 月跟上的),但 Safari 至今还没实现,所以还没进入 Baseline”广泛可用”行列。不能裸用,得做兜底:

// 简易兜底:没有 scheduler.yield 就退回 setTimeout
function yieldToMain() {
  if (typeof scheduler !== "undefined" && scheduler.yield) {
    return scheduler.yield();
  }
  return new Promise(resolve => setTimeout(resolve, 0));
}

用 polyfill 模拟不了”优先级继承”那部分能力——在不支持的浏览器里,退化成的还是 setTimeout 那套”让完掉队尾”的行为。Safari 用户享受不到”保位置”的红利,但至少不会报错、不会卡死。

另外,切任务不是越碎越好。每次 yield 都有调度开销,如果任务本来就只有十几毫秒,硬切反而得不偿失。一般以单段不超过 50ms 作为参考线——这也是浏览器判定”长任务”的门槛。

写在最后

把长任务切开这件事大家都在做;真正容易被忽略的是”切完之后,后半段排到哪去了”。setTimeout 让你掉到队尾,scheduler.yield() 让你既让了路又守住了位置——同样一行”让一让”,体感天差地别。下次再写那种会卡主线程的重计算时,不妨先问自己一句:这地方,是不是该让它”让权不让位”?

评论区

0 条评论

登录后可评论。

阿速·性能优化 15 阅读