非关键任务占着主线程不放,我以为 setTimeout 就是解法——今天发现浏览器的原生方案早把这件事彻底变了

非关键任务占着主线程不放,页面每次都卡在那一下——我以为 setTimeout(fn, 0) 就是解法,今天发现浏览器的原生方案早就把这件事彻底变了。


以前我们怎么往后挪任务

前端性能里有一个经典困境:有些计算不紧急,但就是死活要跑在主线程上。

比如表单验证、大列表的过滤排序、预渲染逻辑。这些东西单独看都不慢,但一叠加起来,INP(Interaction to Next Paint)就直接崩给你看。

老派解法就一个:

setTimeout(() => {
  doHeavyWork();
}, 0);

原理很简单——把任务扔到下一帧去,让浏览器先把当前帧的渲染做完再跑你的逻辑。

但这个方案有三个根本问题:

第一,0ms 不是真的 0ms。 setTimeout 被归类为 task,在事件循环里排在宏任务队列里,前面还有浏览器自己的渲染、脚本执行、各种浏览器任务。0ms 只代表”尽快”,不代表”让出渲染”。

第二,没有任何优先级概念。 你扔进去的任务和用户点击事件是平起平坐的,没有任何机制说”这个可以等一等,那个要立刻处理”。

第三,无法组合。 你不能让一个任务跑到一半主动让出,然后再接着跑。setTimeout 是一次性买卖,要么全跑,要么全不跑。


Chrome 给了你一套原生调度器

Chrome 从 83 开始就在推进 Scheduler API,但真正能打的版本是 Chrome 129——scheduler.yield() 正式进入了 Baseline。

这套 API 干的事情很简单:给你一套原生机制,让主线程的任务调度第一次有了”可中断、可协作、可设置优先级”的能力。

第一个工具:scheduler.yield()

这是最直接的一个。

async function doHeavyWork() {
  const data = await fetch('/api/large-dataset').then(r => r.json());

  // 分批处理,每批让出主线程
  const batchSize = 1000;
  for (let i = 0; i < data.length; i += batchSize) {
    processBatch(data.slice(i, i + batchSize));
    // 关键:主动让出主线程
    await scheduler.yield();
  }
}

scheduler.yield() 的语义是”把这个任务暂停,等浏览器完成渲染和更高优先级任务,再继续往下跑”。

对比一下 setTimeout(fn, 0):

setTimeout scheduler.yield()
让出时机 任务结束后才排队 主动 yield 点,让出更及时
优先级 可控
组合性 好(async/await)
帧率影响 不确定 明确让渲染优先

第二个工具:scheduler.postTask()

如果你不只是想让出,而是想让任务分优先级,那就用 postTask。

// 后台任务:可以被高优先级任务随时打断
const bgTask = scheduler.postTask(() => {
  return heavyComputation();
}, { priority: 'background' });

// 用户交互触发的紧急任务
const urgentTask = scheduler.postTask(() => {
  return updateUI();
}, { priority: 'user-blocking' });

// 等后台任务结果,但可以继续处理其他事
bgTask.then(result => console.log(result));

postTask 的 priority 有四个级别:

  • user-blocking:最高优先级,用户交互相关,比如点击响应
  • user-visible:默认优先级,用户能感知但不是立即要的
  • background:低优先级,后台计算、预加载之类
  • low:最低优先级,能不跑就不跑

这里有一个关键细节:高优先级任务可以把低优先级任务”抢占”。 也就是说,如果你正在跑一个 background 任务,用户突然点了按钮,浏览器可以把你的后台任务暂停,先跑用户点击相关的逻辑。

这在 setTimeout 时代是完全不可能的。

第三个工具:TaskController.setPriority()

postTask 返回一个 TaskSignal,你可以在运行过程中动态改优先级。

const controller = new TaskController();
const task = scheduler.postTask(() => doHeavyWork(), {
  signal: controller.signal
});

// 初始是 user-visible,用户滚动时提升
scrollButton.addEventListener('mouseenter', () => {
  controller.setPriority('background'); // 滚动时降低,避免干扰
});

scrollButton.addEventListener('mouseleave', () => {
  controller.setPriority('user-visible'); // 离开后恢复
});

这个组合适合那种”初始不重要,但执行过程中变重要”的场景。


真实场景:INP 优化怎么用这套 API

INP(Interaction to Next Paint)是 2024 年开始纳入 Core Web Vitals 的指标,衡量的是用户交互到下一次渲染的延迟。INP 的问题大多数时候不是单个任务慢,而是多个非关键任务把主线程堵了

典型场景:页面加载时同时触发多个初始化任务。

// 以前的写法:全部堆在一起
document.addEventListener('DOMContentLoaded', () => {
  loadTranslations();   // 不紧急,但占主线程
  initAnalytics();      // 不紧急,但占主线程
  renderCriticalUI();   // 紧急,必须先跑
});

用 postTask 重构:

document.addEventListener('DOMContentLoaded', () => {
  // 关键 UI 先跑
  renderCriticalUI();

  // 非关键任务全部降权
  scheduler.postTask(loadTranslations, { priority: 'background' });
  scheduler.postTask(initAnalytics, { priority: 'background' });

  // 如果用户开始交互,暂停后台任务
  const controller = new TaskController();

  document.addEventListener('pointerdown', () => {
    controller.setPriority('low'); // 交互时把后台任务压到最低
  }, { once: true });
});

这样用户的第一次点击几乎一定是在 200ms 内得到响应的,因为后台任务会主动让路。


降级方案

Scheduler API 目前在 Chrome 129+、Firefox 101+、Safari 17+ 支持。如果你要兼容更老的浏览器,可以这样写:

// 降级:没有 scheduler.yield() 就用 setTimeout
const yieldIfNeeded = () => {
  if ('scheduler' in window && 'yield' in scheduler) {
    return scheduler.yield();
  }
  return new Promise(resolve => setTimeout(resolve, 0));
};

async function doHeavyWork() {
  for (let i = 0; i < data.length; i += batchSize) {
    processBatch(data.slice(i, i + batchSize));
    await yieldIfNeeded();
  }
}

@supports 也可以检测:

@supports (scheduler.yield()) {
  /* 使用现代调度 API 的样式 */
}

总结:什么时候用这套 API

scheduler.yield() 和 postTask 不是万能解法,它们解决的是“非关键任务抢占主线程”这个问题。

适合的场景:

  • 列表过滤/排序(大数据量)
  • 表单批量校验
  • 预渲染逻辑
  • 大数组的 map/filter/reduce
  • 任何”用户不等待结果但会受影响”的计算

不太适合的场景:

  • 用户点击的即时响应逻辑(这个本来就不该卡)
  • 已经用了 Web Worker 的计算密集任务
  • 需要同步返回结果的操作

一个可落地的判断标准:如果一个操作用户感知不到它跑了多久,但它跑了之后页面变卡了——这件事就值得用调度 API 重写。

Chrome 129+ 已经默认开启,这套机制不是实验性功能了。现在迁移的成本极低,降级方案写三行代码就能兼容老浏览器。

下一步建议:找项目里最常被投诉”卡”的场景,用 postTask 把初始化任务降权跑一遍,INP 数据说话。

评论区

0 条评论

登录后可评论。

阿速·性能优化 976 阅读