优化耗时较长的任务

您可能听过“不要阻塞主线程”和“拆分长任务”等建议,但这些建议具体意味着什么?

发布时间:2022 年 9 月 30 日,最后更新时间:2024 年 12 月 19 日

关于如何保持 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 毫秒或更长时间。在最坏的情况下,更多此类任务的运行时间可能会长得多,尤其是在资源受限的设备上。

在这种情况下,saveSettings() 由用户点击触发,并且由于浏览器在整个函数运行完毕之前无法显示响应,因此这个长任务会导致界面缓慢且无响应,并会被衡量为Interaction to Next Paint (INP) 较差。

手动推迟代码执行

为确保重要的面向用户的任务和界面响应在低优先级任务之前发生,您可以让出主线程,方法是短暂中断工作,让浏览器有机会运行更重要的任务。

开发者用来将任务拆分为较小任务的一种方法涉及 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() 后,浏览器将开始对每个额外的 setTimeout() 施加至少 5 毫秒的延迟。

在让出主线程方面,setTimeout 还有另一个缺点:当您通过使用 setTimeout 延迟代码在后续任务中运行来让出主线程时,该任务会被添加到队列的末尾。如果有其他任务在等待,它们将在您的延迟代码之前运行。

专用让出 API:scheduler.yield()

Browser Support

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

Source

scheduler.yield() 是一个专门用于让出浏览器主线程的 API。

它不是语言级语法或特殊构造;scheduler.yield() 只是一个返回 Promise 的函数,该函数将在未来的任务中解析。在 解析后,任何链接到该 Promise 的代码(在显式 .then() 链中或在异步函数中 await 之后)都将在该未来任务中运行。

在实践中:插入 await scheduler.yield(),函数将在该点暂停执行并让出主线程。该函数的其余部分(称为该函数的延续)将安排在新的事件循环任务中运行。当该任务启动时,等待的 promise 将得到解决,函数将从上次停止的位置继续执行。

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

  // Yield to the main thread:
  await scheduler.yield()

  // Work that isn't user-visible, continued in a separate task:
  saveToDatabase();
  sendAnalytics();
}
Chrome 性能分析器中显示的 saveSettings 函数,现在已拆分为两个任务。第一个任务调用两个函数,然后让出控制权,以便执行布局和绘制工作,并向用户提供可见的响应。因此,点击事件在 64 毫秒内完成,速度要快得多。第二个任务会调用最后三个函数。
函数 saveSettings() 的执行现在分为两个任务。因此,布局和绘制可以在任务之间运行,从而为用户提供更快的视觉响应,这可以通过现在更短的指针互动来衡量。

不过,与其他让出方法相比,scheduler.yield() 的真正优势在于其延续任务会优先执行,这意味着,如果您在任务执行过程中让出,当前任务的延续任务将在任何其他类似任务启动之前运行。

这样可以避免来自其他任务源(例如来自第三方脚本的任务)的代码中断代码的执行顺序。

三张图表,分别描绘了不让出、让出以及让出和继续执行的任务。如果不让出,就会出现长任务。通过让出,会有更多较短的任务,但可能会被其他不相关的任务中断。通过让出和延续,任务数量会增加,但执行时间会缩短,并且执行顺序会保持不变。
使用 scheduler.yield() 时,延续任务会从上次中断的地方继续执行,然后再继续执行其他任务。

跨浏览器支持

并非所有浏览器都支持 scheduler.yield(),因此需要提供后备方案。

一种解决方案是将 scheduler-polyfill 放入 build 中,然后直接使用 scheduler.yield();polyfill 将处理回退到其他任务调度函数,以便在不同浏览器中以类似方式运行。

或者,也可以用几行代码编写一个不太复杂的版本,仅使用封装在 Promise 中的 setTimeout 作为后备方案(如果 scheduler.yield() 不可用)。

function yieldToMain () {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }

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

虽然不支持 scheduler.yield() 的浏览器不会获得优先继续执行,但它们仍会暂停执行,以使浏览器保持响应。

最后,在某些情况下,如果代码的后续执行没有优先级,则可能无法让出主线程(例如,在已知繁忙的页面上,让出主线程可能会导致一段时间内无法完成工作)。在这种情况下,scheduler.yield() 可以被视为一种渐进增强:在支持 scheduler.yield() 的浏览器中让出控制权,否则继续执行。

这可以通过以下方式实现:检测功能,并回退到等待单个微任务,只需一行代码即可:

// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();

使用 scheduler.yield() 拆分长时间运行的工作

使用上述任一方法来使用 scheduler.yield() 的好处是,您可以在任何 async 函数中 await 它。

例如,如果您有一系列要运行的作业,这些作业通常会累加成一个长任务,则可以插入让步来拆分该任务。

async function runJobs(jobQueue) {
  for (const job of jobQueue) {
    // Run the job:
    job();

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

runJobs() 的延续将优先执行,但仍允许执行更高优先级的工作(例如直观地响应用户输入),而不必等待可能很长的作业列表完成。

不过,这并不是一种高效的让出方式。scheduler.yield() 快速高效,但确实会产生一些开销。如果 jobQueue 中的某些作业非常短,那么开销可能会很快累积到比执行实际工作更多的时间。

一种方法是将作业分批,仅在距离上次让出时间足够长时才在作业之间让出时间。常见的截止时间是 50 毫秒,目的是防止任务变成长任务,但可以根据响应速度和完成作业队列所需时间之间的权衡进行调整。

async function runJobs(jobQueue, deadline=50) {
  let lastYield = performance.now();

  for (const job of jobQueue) {
    // Run the job:
    job();

    // If it's been longer than the deadline, yield to the main thread:
    if (performance.now() - lastYield > deadline) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}

这样一来,作业会被拆分,运行时间不会太长,但运行程序大约每 50 毫秒才会让出主线程。

Chrome 开发者工具的“性能”面板中显示的一系列作业函数,其执行过程被拆分为多个任务
作业被分批处理为多个任务。

请勿使用 isInputPending()

Browser Support

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

Source

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

这样,如果没有待处理的输入,JavaScript 就可以继续执行,而不是让出控制权并最终排在任务队列的末尾。如发布意向中所述,对于原本可能不会让出主线程的网站,这可以显著提升性能。

不过,自该 API 发布以来,我们对让步的理解有所加深,尤其是引入 INP 之后。我们不再建议使用此 API,而是建议无论输入是否处于待处理状态都让出控制权,原因如下:

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

总结

管理任务具有挑战性,但这样做可确保您的网页更快地响应用户互动。管理任务和确定任务优先级没有单一的方法,而是有许多不同的技巧。重申一下,在管理任务时,您需要考虑以下主要事项:

  • 让出主线程,以便执行面向用户的关键任务。
  • 使用 scheduler.yield()(具有跨浏览器回退功能)以符合人体工程学的方式让出控制权并获得优先延续
  • 最后,在函数中尽可能少执行操作。

如需详细了解 scheduler.yield()、其显式任务调度相对 scheduler.postTask() 以及任务优先级,请参阅优先任务调度 API 文档。

借助其中一个或多个工具,您应该能够对应用中的工作进行结构化处理,使其优先满足用户的需求,同时确保不太重要的工作仍能完成。这将带来更出色的用户体验,让用户能够更快速地响应,并享受更愉悦的使用体验。

特别感谢 Philip Walton 对本指南的技术审核。