推出 scheduler.yield 源试用

打造能够快速响应用户输入的网站一直是 Web 性能最具挑战性的方面之一,Chrome 团队一直在努力帮助 Web 开发者实现这一目标。就在今年,我们宣布Interaction to Next Paint (INP) 指标将从实验性指标转为待处理指标。现在,它即将于 2024 年 3 月取代 First Input Delay (FID) 成为一项 Core Web Vitals 指标。

为了持续提供有助于 Web 开发者尽可能提高网站运行速度的新 API,Chrome 团队目前正在开展scheduler.yield 源试用,从 Chrome 115 版开始。scheduler.yield 是调度程序 API 中提议的新功能,与传统上依赖的方法相比,它提供了一种更简单、更有效的方式来将控制权让渡回主线程。

在让出时

JavaScript 使用“运行到完成”模型来处理任务。这意味着,当任务在主线程上运行时,该任务会在必要时运行,直到完成为止。任务完成后,系统会将控制权让出给主线程,以便主线程处理队列中的下一个任务。

除了任务永远无法完成的极端情况(例如无限循环)之外,让出是 JavaScript 任务调度逻辑中不可避免的方面。这种情况一定会发生,只是时间早晚的问题,越早越好。如果任务运行时间过长(确切地说,超过 50 毫秒),则会被视为耗时较长的任务

冗长任务是导致网页响应速度缓慢的一个原因,因为它们会延迟浏览器响应用户输入的能力。长时间运行的任务越频繁,运行时间越长,用户就越有可能认为网页运行缓慢,甚至认为网页完全崩溃。

不过,即使您的代码在浏览器中启动了任务,也不意味着您必须等到该任务完成后,控制权才会交还给主线程。您可以通过在任务中明确让出,提高对网页上用户输入的响应速度,这会将任务拆分为在下次有机会时完成的任务。这样,其他任务就可以更早地在主线程上获得时间,而无需等待耗时任务完成。

一张图片,展示了拆分任务如何有助于提高输入响应速度。在顶部,长任务会阻止事件处理脚本运行,直到任务完成。在底部,分块任务允许事件处理程序比以往更快地运行。
可视化地展示了将控制权让渡回主线程的过程。在顶部,只有在任务运行完毕后才会发生让出,这意味着任务可能需要更长时间才能完成,然后才能将控制权返回给主线程。归根结底,系统会显式地进行让出,将一个长任务拆分为多个较小的任务。这样,用户互动就可以更早运行,从而提高输入响应速度和 INP。

显式让出时,您是在告诉浏览器:“嘿,我知道我要执行的工作可能需要一段时间,我不希望您必须先完成所有工作,然后才能响应用户输入或执行其他可能也很重要的任务。”它是开发者工具箱中一款非常有用的工具,可以大大提升用户体验。

当前收益策略存在的问题

一个常见的让出方法是使用 setTimeout,超时值为 0。之所以能这样做,是因为传递给 setTimeout 的回调会将剩余工作移至单独的任务,该任务将加入队列以供后续执行。您不是等待浏览器自行让出,而是说“让我们将这项大工作拆分成更小的部分”。

不过,使用 setTimeout 让出会产生一个可能不希望的副作用:在让出点之后执行的工作将移至任务队列的后面。由用户互动调度的任务仍会按预期移至队列前面,但您在明确让出后想要执行的剩余工作最终可能会因排在前面的来自竞争来源的其他任务而进一步延迟。

如需了解此功能的实际运作方式,请试用此 Glitch 演示,或在下面嵌入的版本中进行实验。该演示包含几个可点击的按钮,以及用于记录任务运行时间的下方框。进入该页面后,执行以下操作:

  1. 点击顶部的标签为定期运行任务的按钮,系统会安排每隔一段时间运行一次阻塞任务。点击此按钮后,任务日志中会填充多条消息,其中会显示 Ran blocking task with setInterval
  2. 接下来,点击标签为运行循环,并在每次迭代中使用 setTimeout 进行求值的按钮。

您会发现,演示底部的框中会显示如下内容:

Processing loop item 1
Processing loop item 2
Ran blocking task via setInterval
Processing loop item 3
Ran blocking task via setInterval
Processing loop item 4
Ran blocking task via setInterval
Processing loop item 5
Ran blocking task via setInterval
Ran blocking task via setInterval

此输出演示了使用 setTimeout 让出时发生的“任务队列结束”行为。运行的循环会处理五个项,并在处理完每个项后使用 setTimeout 进行 yield。

这说明了 Web 上的一个常见问题:脚本(尤其是第三方脚本)注册一个按一定间隔运行工作的计时器函数并非不寻常。使用 setTimeout 让出时出现的“任务队列末尾”行为意味着,来自其他任务来源的工作可能会排在循环在让出后必须执行的剩余工作之前。

这可能或许不是理想的结果,具体取决于您的应用;但在许多情况下,正是由于这种行为,开发者可能不愿意轻易放弃对主线程的控制。让出主线程有好处,因为用户互动有机会更早运行,但这也让其他非用户互动工作也有机会在主线程上运行。这确实是一个问题,但 scheduler.yield 可以帮助解决!

输入 scheduler.yield

从 Chrome 115 版开始,scheduler.yield 作为实验性 Web 平台功能通过标志提供。您可能会问:“为什么我需要使用特殊函数来产生 yield,而 setTimeout 已经可以执行此操作?”

值得注意的是,让线程让出 CPU 并不是 setTimeout 的设计目标,而是在安排回调在未来的某个时间点运行时产生的一个良好副作用,即使指定了 0 的超时值也是如此。不过,更重要的是要记住,使用 setTimeout 让出时,系统会将剩余工作发送到任务队列的后面。默认情况下,scheduler.yield 会将剩余工作发送到队列的前面。也就是说,您希望在让出后立即恢复的工作不会被其他来源的任务所替代(用户互动是个例外情况)。

scheduler.yield 是一个函数,会让出主线程,并在被调用时返回 Promise。这意味着您可以在 async 函数中对其执行 await 操作:

async function yieldy () {
  // Do some work...
  // ...

  // Yield!
  await scheduler.yield();

  // Do some more work...
  // ...
}

如需查看 scheduler.yield 的实际运作方式,请执行以下操作:

  1. 导航到 chrome://flags
  2. 启用实验性 Web 平台功能实验。执行此操作后,您可能需要重启 Chrome。
  3. 前往演示页面,或使用此列表下方的嵌入式版本。
  4. 点击顶部的标签为定期运行任务的按钮。
  5. 最后,点击标签为运行循环,每次迭代使用 scheduler.yield 返回的按钮。

页面底部框中的输出结果将如下所示:

Processing loop item 1
Processing loop item 2
Processing loop item 3
Processing loop item 4
Processing loop item 5
Ran blocking task via setInterval
Ran blocking task via setInterval
Ran blocking task via setInterval
Ran blocking task via setInterval
Ran blocking task via setInterval

与使用 setTimeout 产生 yield 的演示不同,您可以看到,即使循环在每次迭代后产生 yield,也不会将剩余工作发送到队列的后面,而是发送到队列的前面。这样,您既可以让步以提高网站上的输入响应速度,又可以确保您希望在让步完成的工作不会延迟。

快来试试吧!

如果您对 scheduler.yield 感兴趣并想试用,从 Chrome 115 版开始,您可以通过以下两种方式进行试用:

  1. 如果您想在本地实验 scheduler.yield,请在 Chrome 的地址栏中输入 chrome://flags,然后从实验性 Web 平台功能部分的下拉菜单中选择启用这样一来,scheduler.yield(以及任何其他实验性功能)将仅在您的 Chrome 实例中可用。
  2. 如果您想为公开可访问的来源中的真实 Chromium 用户启用 scheduler.yield,则需要注册 scheduler.yield 源试用。这样,您就可以在给定的时间段内安全地试用建议的功能,并为 Chrome 团队提供有关这些功能在实际中使用方式的宝贵洞见。如需详细了解来源试用版的运作方式,请参阅这份指南

如何使用 scheduler.yield(同时支持未实现 scheduler.yield 的浏览器)取决于您的目标。您可以使用官方 polyfill。如果您遇到以下情况,该 polyfill 会很有用:

  1. 您已在应用中使用 scheduler.postTask 来安排任务。
  2. 您希望能够设置任务和让出优先级。
  3. 您希望能够通过 scheduler.postTask API 提供的 TaskController 类取消或重新排定任务的优先级。

如果您的情况与上述情况不符,则该 polyfill 可能不适合您。在这种情况下,您可以通过多种方式推出自己的回退方案。第一种方法会在 scheduler.yield 可用时使用 scheduler.yield,但在 scheduler.yield 不可用时回退为使用 setTimeout

// A function for shimming scheduler.yield and setTimeout:
function yieldToMain () {
  // Use scheduler.yield if it exists:
  if ('scheduler' in window && 'yield' in scheduler) {
    return scheduler.yield();
  }

  // Fall back to setTimeout:
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

// Example usage:
async function doWork () {
  // Do some work:
  // ...

  await yieldToMain();

  // Do some other work:
  // ...
}

这可以起作用,但正如您可能猜到的那样,不支持 scheduler.yield 的浏览器会让出,而不会出现“队列前端”行为。如果这意味着您不想完全让出,可以尝试另一种方法,即在 scheduler.yield 可用时使用 scheduler.yield,但在 scheduler.yield 不可用时完全不让出:

// A function for shimming scheduler.yield with no fallback:
function yieldToMain () {
  // Use scheduler.yield if it exists:
  if ('scheduler' in window && 'yield' in scheduler) {
    return scheduler.yield();
  }

  // Fall back to nothing:
  return;
}

// Example usage:
async function doWork () {
  // Do some work:
  // ...

  await yieldToMain();

  // Do some other work:
  // ...
}

scheduler.yield 是调度程序 API 中令人兴奋的新功能,有望比当前的让出策略更便于开发者提高响应能力。如果您认为 scheduler.yield 是一款实用的 API,请参与我们的调研,帮助我们改进它,并提供反馈,告诉我们如何进一步改进它。

主打图片来自 Unsplash,作者:Jonathan Allison