使用 scheduler.yield() 拆分长任务

发布时间:2024 年 3 月 6 日

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox: not supported.
  • Safari: not supported.

Source

如果长任务占用主线程,使其无法执行其他重要工作(例如响应用户输入),网页就会运行缓慢且无响应。因此,即使是内置表单控件,在用户看来也可能会出现损坏的情况,就像页面冻结了一样,更不用说更复杂的自定义组件了。

scheduler.yield() 是一种让主线程让出资源的方法,可让浏览器运行所有待处理的高优先级工作,然后从上次中断的地方继续执行。这有助于提高网页的响应速度,进而改进 Interaction to Next Paint (INP)

scheduler.yield 提供了一个符合人体工学的 API,该 API 会完全按照其说明执行操作:在 await scheduler.yield() 表达式处暂停调用的函数的执行,并让出主线程,从而拆分任务。函数其余部分的执行(称为函数的接续)将安排在新的事件循环任务中运行。

async function respondToUserClick() {
  giveImmediateFeedback();
  await scheduler.yield(); // Yield to the main thread.
  slowerComputation();
}

scheduler.yield 的具体优势在于,系统会安排在运行网页排队的任何其他类似任务之前运行 yield 后的 continuation。它会优先继续执行任务,而不是启动新任务。

setTimeoutscheduler.postTask 等函数也可以用于拆分任务,但这些接续通常在所有已加入队列的新任务之后运行,这可能会导致在让出主线程和完成工作之间出现长时间延迟。

让出后优先的接续

scheduler.yieldPrioritized Task Scheduling API 的一部分。作为 Web 开发者,我们通常不会以显式优先级来讨论事件循环运行任务的顺序,但相对优先级始终存在,例如 requestIdleCallback 回调会在所有已加入队列的 setTimeout 回调之后运行,或者触发的输入事件监听器通常会在加入队列的 setTimeout(callback, 0) 任务之前运行。

优先任务调度功能只是让这种情况更加明确,让您更轻松地确定哪个任务会在另一个任务之前运行,并能够根据需要调整优先级以更改执行顺序。

如前所述,在使用 scheduler.yield() 让出后继续执行函数的优先级高于启动其他任务。指导性概念是,应先运行任务的接续,然后再执行其他任务。如果任务是良好行为的代码,会定期让出资源以便浏览器执行其他重要操作(例如响应用户输入),则不应因让出资源而受到惩罚,即不应将其优先级设为低于其他类似任务。

下面是一个示例:两个函数已加入队列,以便使用 setTimeout 在不同的任务中运行。

setTimeout(myJob);
setTimeout(someoneElsesJob);

在本例中,两个 setTimeout 调用紧挨在一起,但在真实网页中,它们可能会在完全不同的位置调用,例如第一方脚本和第三方脚本独立设置要运行的工作,或者可能是框架调度程序深处触发的来自不同组件的两个任务。

在 DevTools 中,该操作可能如下所示:

Chrome DevTools 性能面板中显示的两个任务。这两项任务都被标记为长任务,其中函数“myJob”占据了第一项任务的整个执行时间,而“someoneElsesJob”占据了第二项任务的整个执行时间。

myJob 被标记为长任务,会阻止浏览器在运行期间执行任何其他操作。假设它来自第一方脚本,我们可以将其拆分为:

function myJob() {
  // Run part 1.
  myJobPart1();
  // Yield with setTimeout() to break up long task, then run part2.
  setTimeout(myJobPart2, 0);
}

由于 myJobPart2 已安排在 myJob 内与 setTimeout 一起运行,但该安排会在 someoneElsesJob 已安排运行运行,因此执行情况如下所示:

Chrome DevTools 性能面板中显示的三个任务。第一个任务是运行函数“myJobPart1”,第二个任务是运行“someoneElsesJob”这一长任务,最后一个任务是运行“myJobPart2”。

我们使用 setTimeout 拆分了任务,以便浏览器在 myJob 执行期间保持响应能力,但现在 myJob 的第二部分仅在 someoneElsesJob 完成后运行。

在某些情况下,这样做也许没问题,但通常这并不是最佳做法。myJob 让出主线程是为了确保页面能够继续响应用户输入,而不是完全放弃主线程。如果 someoneElsesJob 运行速度特别慢,或者除了 someoneElsesJob 之外还安排了许多其他作业,则 myJob 的后半部分可能需要很长时间才能运行。开发者在向 myJob 添加 setTimeout 时可能并非如此意图。

输入 scheduler.yield(),这会将调用它的任何函数的接续操作放入优先级略高于启动任何其他类似任务的队列中。如果将 myJob 更改为使用它,则:

async function myJob() {
  // Run part 1.
  myJobPart1();
  // Yield with scheduler.yield() to break up long task, then run part2.
  await scheduler.yield();
  myJobPart2();
}

现在,执行过程如下所示:

Chrome DevTools 性能面板中显示的两个任务。这两项任务都被标记为长任务,其中函数“myJob”占据了第一项任务的整个执行时间,而“someoneElsesJob”占据了第二项任务的整个执行时间。

浏览器仍然有机会做出响应,但现在,继续执行 myJob 任务的优先级高于启动新任务 someoneElsesJob,因此 myJob 会在 someoneElsesJob 开始之前完成。这更接近于预期,即让出主线程以保持响应能力,而不是完全放弃主线程。

继承优先级

作为更大的优先级任务调度 API 的一部分,scheduler.yield() 可与 scheduler.postTask() 中提供的显式优先级很好地组合。如果未明确设置优先级,则 scheduler.postTask() 回调中的 scheduler.yield() 的行为与前面的示例基本相同。

不过,如果设置了优先级(例如使用较低的 'background' 优先级),则:

async function lowPriorityJob() {
  part1();
  await scheduler.yield();
  part2();
}

scheduler.postTask(lowPriorityJob, {priority: 'background'});

系统会将接续任务的优先级设为高于其他 'background' 任务(在任何待处理的 'background' 工作之前获取预期的优先级接续),但仍低于其他默认或高优先级任务;它仍然是 'background' 工作。

这意味着,如果您使用 'background' scheduler.postTask()(或 requestIdleCallback)调度低优先级工作,则在其中执行 scheduler.yield() 后的继续操作也会等待,直到大多数其他任务完成且主线程空闲运行,这正是您希望在低优先级作业中让出所需的操作。

如何使用该 API

目前,scheduler.yield() 仅适用于基于 Chromium 的浏览器,因此如需使用它,您需要进行功能检测,并针对其他浏览器回退到次要让出方式。

scheduler-polyfillscheduler.postTaskscheduler.yield 的一款小型 polyfill,它在内部使用多种方法来模拟其他浏览器中许多强大的调度 API(但不支持 scheduler.yield() 优先级继承)。

对于想要避免使用 polyfill 的开发者,一种方法是使用 setTimeout() 让出并接受优先级较高的继续执行作业丢失,或者在无法接受这种情况时,甚至可以在不受支持的浏览器中不让出。如需了解详情,请参阅“优化长时间运行的任务”中的 scheduler.yield() 文档

如果您要检测 scheduler.yield() 功能并自行添加回退,也可以使用 wicg-task-scheduling 类型来获取类型检查和 IDE 支持。

了解详情

如需详细了解该 API 及其与任务优先级和 scheduler.postTask() 的互动方式,请参阅 MDN 上的 scheduler.yield()有序任务调度文档。

如需详细了解长任务、长任务对用户体验的影响以及如何应对长任务,请参阅优化长任务