优化耗时较长的任务

关于如何提高 JavaScript 应用速度的常见建议通常包括“不要阻塞主线程”和“拆分长任务”。本页详细介绍了这些建议的含义,以及使用 JavaScript 优化任务的重要性。

什么是任务?

任务是指浏览器执行的任何独立工作。这包括呈现、解析 HTML 和 CSS、运行您编写的 JavaScript 代码,以及您无法直接控制的其他事项。网页的 JavaScript 是浏览器任务的主要来源。

Chrome 开发者工具的性能分析器中的任务的屏幕截图。该任务位于堆栈的顶部,下方有点击事件处理脚本、函数调用和更多项。此任务还包含右侧一些渲染工作。
click 事件处理脚本启动的任务,显示在 Chrome 开发者工具的性能分析器中。

任务会在多个方面影响性能。例如,当浏览器在启动期间下载一个 JavaScript 文件时,它会将任务加入队列以解析和编译该 JavaScript,以便能够执行该 JavaScript。在网页生命周期的后期,当 JavaScript 正常运行时,其他任务(例如通过事件处理程序、JavaScript 驱动的动画和后台活动(如分析数据收集)驱动互动)就会开始。除网页工作器和类似 API 外,所有这些操作都发生在主线程上。

主线程是什么?

主线程是大多数任务在浏览器中运行的位置,您编写的几乎所有 JavaScript 都会在该线程中执行。

主线程一次只能处理一个任务。任何耗时超过 50 毫秒的任务均算作“耗时”。如果用户尝试在长时间的任务或渲染更新期间与页面互动,浏览器必须等待处理该互动,从而导致延迟。

Chrome 开发者工具的性能分析器中的一项耗时较长的任务。任务的阻塞部分(大于 50 毫秒)用红色对角条标记。
Chrome 性能分析器中显示的一项耗时较长的任务。耗时较长的任务在任务的一角用红色三角形表示,任务的阻塞部分填充的是红色对角线图案。

为了避免这种情况,请将每个耗时较长的任务划分为多个小任务,每个小任务的运行时间较短。这称为拆分长任务。

单个长任务与拆分成较短的任务的相同任务的对比。长任务是一个大矩形,分块任务是五个小方框,其长度加起来等于长任务的长度。
直观呈现单个长任务与拆分为五个较短任务的相同任务。

拆分任务可让浏览器有更多机会响应其他任务之间优先级较高的工作,包括用户互动。这样可以更快地进行交互,否则用户在浏览器等待长时间任务完成时可能会注意到延迟。

拆分任务可促进用户互动。在顶部,较长的任务会阻止事件处理脚本运行,直到任务完成为止。在底部,分块任务可让事件处理脚本比其他时间更早运行。
如果任务过长,浏览器就无法足够快速地响应互动。 拆分任务可让这些互动更快完成。

任务管理策略

JavaScript 将每个函数视为单个任务,因为它使用“运行到完成”的任务执行模式。这意味着,一个调用了多个其他函数的函数(如以下示例)必须一直运行到所有被调用的函数完成为止,这会导致浏览器速度变慢:

function saveSettings () { //This is a long task.
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}
Chrome 性能分析器中显示的 SaveSettings 函数。当顶层函数调用其他五个函数时,所有工作都在一个阻塞主线程的长任务中进行。
调用五个函数的单个函数 saveSettings()。该工作作为一个较长的单体式任务的一部分运行。

如果您的代码包含调用多个方法的函数,请将其拆分为多个函数。这不仅让浏览器有更多机会响应互动,还可让您的代码更易于读取、维护和编写测试。以下部分介绍了一些策略,用于分解长函数以及确定构成长函数的任务的优先级。

手动推迟代码执行

您可以通过将相关函数传递给 setTimeout() 来推迟某些任务的执行。即使您将超时指定为 0,此方法也有效。

function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Defer work that isn't user-visible to a separate task:
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

这最适合一系列需要按顺序运行的函数。采用不同组织方式的代码需要采用不同的方法。下一个示例是使用循环处理大量数据的函数。数据集越大,所需的时间越长,在循环中不一定适合放置 setTimeout()

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

幸运的是,还有一些其他 API 可让您将代码执行推迟到后续任务中。我们建议使用 postMessage() 来缩短超时时间

您还可以使用 requestIdleCallback() 拆分工作,但它会以最低优先级调度任务,并且仅在浏览器空闲期间调度任务,这意味着,如果主线程特别繁忙,使用 requestIdleCallback() 调度的任务可能永远无法运行。

使用 async/await 创建挂起点

为确保面向用户的重要任务先于优先级较低的任务发生,请回退到主线程,具体做法是短暂中断任务队列,让浏览器有机会运行更重要的任务。

要做到这一点,最明确的方法是使用通过调用 setTimeout() 进行解析的 Promise

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

saveSettings() 函数中,如果在每次函数调用后 await yieldToMain() 函数,您可以在每步后让出给主线程。这样可以有效地将冗长的任务分解为多个任务:

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread:
    await yieldToMain();
  }
}

要点:您不必在每次调用函数后让出让。例如,如果您运行两个函数,它们会为界面做出重要更新,您可能不希望在两者之间让步。如果可以的话,先让该工作运行,然后考虑在执行用户看不到的后台工作或不太重要工作的函数之间让出。

Chrome 性能分析器中的同一 SaveSettings 函数,现在通过收益组触发。
任务现在拆分为五个单独的任务,每个功能对应一个功能。
saveSettings() 函数现在将其子函数作为单独的任务执行。

专用调度器 API

到目前为止提到的 API 可以帮助您拆分任务,但它们有一个明显的缺点:如果您通过将代码推迟到后续任务中运行让主线程运行,该代码就会添加到任务队列的末尾。

如果您控制页面上的所有代码,则可以创建自己的调度器来划分任务的优先级。但是,第三方脚本不会使用您的调度器,因此您在这种情况下无法真的确定工作的优先级。您只能拆分该模块,或让它让出用户互动

浏览器支持

  • 94
  • 94
  • x

来源

调度器 API 提供 postTask() 函数,可以更精细地安排任务,并帮助浏览器确定工作的优先级,使低优先级任务让出到主线程。postTask() 使用 promise 并接受 priority 设置。

postTask() API 有三个优先级:

  • 'background',用于优先级最低的任务。
  • 'user-visible',适用于中优先级任务。如果未设置 priority,则此值为默认值。
  • 'user-blocking',适用于需要以高优先级运行的关键任务。

以下示例代码使用 postTask() API 以尽可能高的优先级运行三个任务,并以尽可能低的优先级运行其余两个任务:

function saveSettings () {
  // Validate the form at high priority
  scheduler.postTask(validateForm, {priority: 'user-blocking'});

  // Show the spinner at high priority:
  scheduler.postTask(showSpinner, {priority: 'user-blocking'});

  // Update the database in the background:
  scheduler.postTask(saveToDatabase, {priority: 'background'});

  // Update the user interface at high priority:
  scheduler.postTask(updateUI, {priority: 'user-blocking'});

  // Send analytics data in the background:
  scheduler.postTask(sendAnalytics, {priority: 'background'});
};

在这里,任务的优先级是安排好的,以便浏览器优先的任务(例如用户互动)可以正常发挥作用。

Chrome 性能分析器中显示的 SaveSettings 函数,但使用的是 postTask。postTask 会拆分运行的每个 SaveSettings 函数,并确定这些函数的优先级,以确保用户互动能够运行而不会被屏蔽。
saveSettings() 运行时,该函数会使用 postTask() 安排各个函数调用。面向用户的关键工作被安排为高优先级,而用户不知道的工作则被安排在后台运行。这样可以更快地执行用户互动,因为系统会相应地拆分工作并相应地排定优先级。

您还可以实例化在任务之间共享优先级的不同 TaskController 对象,包括根据需要更改不同 TaskController 实例的优先级的功能。

内置收益,可持续使用即将推出的 scheduler.yield() API

要点:如需详细了解 scheduler.yield(),请参阅其源试用(自结束之日起算)以及其解释器

我们提议将 scheduler.yield() 作为调度器 API 的补充,该 API 是专为让出浏览器中的主线程而设计的 API。其用法类似于本页前面演示的 yieldToMain() 函数:

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread with the scheduler
    // API's own yielding mechanism:
    await scheduler.yield();
  }
}

大家熟悉这段代码,但这里使用的不是 yieldToMain(),而是 await scheduler.yield()

三个图,分别显示了未屈服的任务、正在屈服的任务和屈服任务和接续任务。如果不让退,就会有冗长的任务。通过退让功能,有更多时间较短的任务会被其他不相关任务中断。通过让出和延续,可以保留时间较短的任务的执行顺序。
使用 scheduler.yield() 时,任务执行会从上次中断的地方继续,即使在屈服点之后也是如此。

scheduler.yield() 的优势是延续,也就是说,如果您在一组任务的中间让退,其他计划任务在挂起点之后会按相同顺序继续运行。这样可以防止第三方脚本控制代码的执行顺序。

scheduler.postTask()priority: 'user-blocking' 搭配使用还很有可能继续运行,因为 user-blocking 优先级较高,因此在 scheduler.yield() 覆盖范围更广之前,您可以将其作为替代方案。

使用 setTimeout()(或者结合使用 scheduler.postTask()priority: 'user-visible' 或者没有显式 priority)可将任务安排在队列末尾,让其他待处理任务在接续前运行。

采用 isInputPending() 输入时的收益率

浏览器支持

  • 87
  • 87
  • x
  • x

借助 isInputPending() API,可以检查用户是否尝试与网页互动,并仅在有输入正等待处理时才让退。

这样,JavaScript 便可以在没有待处理输入时继续运行,而不是让出去并最终在任务队列的后面结束。对于原本可能无法回退到主线程的网站,这可能会带来显著的性能提升(如 Intent to Ship 中所述)。

然而,自该 API 推出以来,我们对收益的理解有了增长,尤其是在引入 INP 之后。我们不再建议使用此 API,而是建议无论输入是否待处理。造成建议这种变化的原因有很多:

  • 在用户已进行互动的情况下,API 可能会错误地返回 false
  • 输入并不是任务应产生的唯一情况。动画和其他定期界面更新对于提供自适应网页同样重要。
  • 此后,我们引入了更全面的生成 API(如 scheduler.postTask()scheduler.yield()),以解决此类问题。

总结

管理任务非常具有挑战性,但这样做有助于网页更快地响应用户互动。有多种技术可用于管理任务并确定其优先级,具体取决于您的使用场景。重申一下,管理任务时您需要考虑以下主要事项:

  • 让线程执行面向用户的关键任务。
  • 请考虑使用 scheduler.yield() 进行实验。
  • 使用 postTask() 确定任务的优先级。
  • 最后,在函数中尽可能少做工作

借助其中一种或多种工具,您应该能够设计应用中的工作结构,使其优先考虑用户的需求,同时确保仍然完成不太重要的工作。这使得它的响应速度更快、使用起来更愉快,从而改善了用户体验。

特别感谢 Philip Walton 对本文档的技术审查。

缩略图图片来自 Unsplash 用户,由 Amirali Mirhashemian 提供。