优化耗时较长的任务

您可能听过“请勿阻塞主线程”和“请拆分长时间运行的任务”这两条建议,但这两条建议具体意味着什么?

关于如何让 JavaScript 应用保持快速运行的常见建议通常归结为以下建议:

  • “避免阻塞主线程。”
  • “拆分长任务。”

这是一个很好的建议,但需要做哪些工作?减少 JavaScript 代码量是件好事,但这是否会自动带来响应更快的界面?也许,也许不会。

如需了解如何优化 JavaScript 中的任务,您首先需要了解什么是任务以及浏览器如何处理任务。

什么是任务?

任务是指浏览器执行的任何单独工作。这些工作包括呈现、解析 HTML 和 CSS、运行 JavaScript 以及您可能无法直接控制的其他类型的工作。在所有这些任务中,您编写的 JavaScript 可能是任务的主要来源。

任务可视化图,如 Chrome 开发者工具的性能分析器中所示。任务位于堆栈顶部,点击事件处理脚本、函数调用和其他项位于其下方。该任务还包括右侧的一些渲染工作。
click 事件处理脚本启动的任务,显示在 Chrome 开发者工具的性能分析器中。

与 JavaScript 相关的任务会通过以下几种方式影响性能:

  • 当浏览器在启动期间下载 JavaScript 文件时,会将任务加入队列,以便解析和编译该 JavaScript 文件,以便稍后执行。
  • 在网页生命周期的其他时间,当 JavaScript 执行工作(例如通过事件处理脚本、JavaScript 驱动的动画以及分析数据收集等后台活动来促成互动)时,系统会将任务加入队列。

所有这些操作(Web Worker 和类似 API 除外)都发生在主线程上。

什么是主线程?

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

主线程一次只能处理一个任务。任何用时超过 50 毫秒的任务都是耗时较长的任务。对于耗时超过 50 毫秒的任务,任务的总时间减去 50 毫秒称为任务的阻塞时段

在任何长度的任务运行期间,浏览器都会阻止发生互动,但只要任务运行时间不太长,用户就不会察觉到这一点。不过,如果有许多耗时任务正在运行,当用户尝试与网页互动时,界面会感觉无响应,如果主线程被阻塞很长时间,界面甚至可能会崩溃。

Chrome 开发者工具性能分析器中的长任务。任务的阻塞部分(超过 50 毫秒)用红色对角线条纹图案表示。
Chrome 性能分析器中显示的耗时较长的任务。长任务的角落会显示一个红色三角形,任务的阻塞部分会填充红色对角线条纹图案。

为防止主线程阻塞时间过长,您可以将长任务拆分为多个较小的任务。

单个长任务与将同一任务拆分为较短任务。长任务是一个大矩形,而分块任务是五个较小的框,总宽度与长任务相同。
一项长任务与将该任务拆分为五项更短任务的可视化效果。

这一点很重要,因为当任务被拆分后,浏览器可以更快地响应更高优先级的工作,包括用户互动。之后,系统会运行剩余任务,确保您最初加入队列的工作得到完成。

一张图片,展示了拆分任务如何促进用户互动。在顶部,长任务会阻止事件处理脚本运行,直到任务完成。在底部,分块任务允许事件处理程序比以往更快地运行。
直观呈现以下情况:当任务过长且浏览器无法快速响应互动时,与将较长的任务拆分为较小任务时,互动情况有何不同。

在前面的图表顶部,由用户互动加入队列的事件处理脚本必须等待单个长任务完成才能开始,这会延迟互动发生。在这种情况下,用户可能会注意到延迟。在底部,事件处理脚本可以更早开始运行,并且互动可能感觉即时

现在,您已经了解了拆分任务的重要性,接下来可以学习如何在 JavaScript 中拆分任务。

任务管理策略

软件架构中的一个常见建议是将工作拆分为更小的函数:

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

在此示例中,有一个名为 saveSettings() 的函数会调用五个函数来验证表单、显示旋转图标、将数据发送到应用后端、更新界面和发送分析数据。

从概念上讲,saveSettings() 的架构设计得当。如果您需要调试其中某个函数,可以遍历项目树,了解每个函数的用途。这样拆分工作有助于更轻松地浏览和维护项目。

不过,这里有一个潜在问题,即 JavaScript 不会将这些函数中的每个函数都作为单独的任务运行,因为它们是在 saveSettings() 函数中执行的。这意味着,所有五个函数都将作为一个任务运行。

Chrome 性能分析器中显示的 saveSettings 函数。虽然顶级函数会调用其他五个函数,但所有工作都发生在一个会阻塞主线程的长任务中。
一个函数 saveSettings() 调用了五个函数。工作会作为一项长时间的单体任务的一部分运行。

在理想情况下,即使其中只有一个函数,也可能会使任务的总时长增加 50 毫秒或更多。在最糟糕的情况下,更多此类任务的运行时间可能会延长很多,尤其是在资源受限的设备上。

手动推迟代码执行

开发者将任务拆分为更小任务的方法之一涉及 setTimeout()。使用此方法,您可以将函数传递给 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);
}

这称为让出,最适合需要顺序运行的一系列函数。

不过,您的代码可能并不总是按这种方式组织的。例如,您可能有大量数据需要循环处理,如果迭代次数很多,该任务可能需要很长时间。

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

由于开发者工效学问题,此处使用 setTimeout() 会出现问题,并且整个数据数组的处理可能需要很长时间,即使每个单独的迭代运行速度很快也是如此。综合考虑,setTimeout() 并不是完成此任务的合适工具,至少不能以这种方式使用。

使用 async/await 创建收益点

为确保面向用户的重要任务在低优先级任务之前执行,您可以通过短暂中断任务队列来让出主线程,以便浏览器有机会运行更重要的任务。

如前所述,setTimeout 可用于让出主线程。不过,为了方便和提高可读性,您可以在 Promise 内调用 setTimeout,并将其 resolve 方法作为回调传递。

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

yieldToMain() 函数的好处在于,您可以在任何 async 函数中对其执行 await 操作。基于上一个示例,您可以创建要运行的函数数组,并在每个函数运行后让出主线程:

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

setTimeout 是一种有效的分解任务的方法,但可能存在缺点:当您通过推迟在后续任务中运行的代码来让主线程让出,该任务会添加到队列的末尾

如果您控制网页上的所有代码,则可以创建自己的调度程序,以便对任务进行排优先级,但第三方脚本不会使用您的调度程序。实际上,您无法在这种环境中优先处理工作。您只能将其分块,或明确让出用户互动。

浏览器支持

  • Chrome:94.
  • Edge:94.
  • Firefox:需要切换标志才能使用。
  • Safari:不受支持。

来源

调度程序 API 提供了 postTask() 函数,可用于更精细地调度任务,这是一种帮助浏览器确定工作优先级的方法,以便低优先级任务让出主线程。postTask() 使用 promise,并接受以下三种 priority 设置之一:

  • '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() 调度各个函数。面向用户的关键工作会安排为高优先级,而用户不知道的工作会安排在后台运行。这样,系统就可以将工作拆分并适当优先级,从而更快地执行用户互动。

以下是一个简单的 postTask() 使用示例。您可以实例化不同的 TaskController 对象,这些对象可以在任务之间共享优先级,包括根据需要更改不同 TaskController 实例的优先级。

使用 scheduler.yield() API 的内置 yield 和继续

浏览器支持

  • Chrome:129.
  • Edge:129。
  • Firefox:不受支持。
  • Safari:不受支持。

来源

scheduler.yield() 是一个专门用于让出浏览器中主线程的 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();
  }
}

这段代码在很大程度上与前面介绍的代码类似,但它使用的是 await scheduler.yield(),而不是 yieldToMain()

三个示意图,分别描绘了没有让出、让出以及让出和继续的任务。如果不让出,就会出现长任务。使用让出功能后,会有更多时间较短的任务,但这些任务可能会被其他不相关的任务中断。通过让出和接续,可以让更多任务变短,但其执行顺序会保留。
使用 scheduler.yield() 时,即使在挂起点之后,任务执行也会从上次中断的地方继续。

scheduler.yield() 的好处在于能够继续执行,这意味着,如果您在一组任务中途让出,其他已安排的任务将在让出点之后按相同的顺序继续执行。这样可以避免第三方脚本中的代码干扰代码的执行顺序。

请勿使用 isInputPending()

浏览器支持

  • Chrome:87.
  • Edge:87.
  • Firefox:不受支持。
  • Safari:不受支持。

来源

isInputPending() API 提供了一种方法,可用于检查用户是否尝试过与网页互动,并且仅在有输入待处理时才会让出。

这样,如果没有待处理的输入,JavaScript 就会继续执行,而不是让出并位于任务队列的后面。这可能会显著提升性能,如发布意图中所详述,对于可能不会让出主线程的网站,这种做法尤其有效。

不过,自该 API 发布以来,我们对让出内存的理解不断加深,尤其是在引入 INP 后。出于多种原因,我们不再建议使用此 API,而是建议无论输入是否待处理,都进行让出:

  • 在某些情况下,即使用户进行了互动,isInputPending() 也可能会错误地返回 false
  • 任务应让出执行权的情况并不仅限于输入。动画和其他常规界面更新对于提供自适应网页同样重要。
  • 此后,我们引入了更全面的让出 API,以解决让出问题,例如 scheduler.postTask()scheduler.yield()

总结

管理任务是一项艰巨的任务,但这样做可以确保您的网页更快地响应用户互动。没有任何单一建议可以帮助您管理和确定任务优先级,但您可以采用多种不同的方法。再次强调一下,在管理任务时,您需要考虑以下主要事项:

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

借助其中一个或多个工具,您应该能够在应用中构建工作结构,以便优先考虑用户的需求,同时确保完成不太重要的工作。这将带来更出色的用户体验,让用户获得更流畅、更愉快的使用体验。

特别感谢 Philip Walton 对本指南进行了技术审核。

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