优化长任务

您已经被告知“不要阻塞主线程”和“分解您的长任务”,但是执行这些操作意味着什么?

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

关于保持 JavaScript 应用快速运行的常见建议往往可以归结为以下建议

  • “不要阻塞主线程。”
  • “分解您的长任务。”

这些都是很好的建议,但是它们涉及哪些工作呢?减少 JavaScript 的交付是好的,但这是否会自动等同于更灵敏的用户界面?也许是,但也可能不是。

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

什么是任务?

任务是浏览器执行的任何离散的工作单元。这项工作包括渲染、解析 HTML 和 CSS、运行 JavaScript 以及您可能无法直接控制的其他类型的工作。在所有这些工作中,您编写的 JavaScript 可能是最大的任务来源。

A visaulization of a task as depicted in the performance profliler of Chrome's DevTools. The task is at the top of a stack, with a click event handler, a function call, and more items beneath it. The task also includes some rendering work on the right-hand side.
click 事件处理程序启动的任务,在 Chrome DevTools 的性能分析器中显示。

与 JavaScript 关联的任务会以多种方式影响性能

  • 当浏览器在启动期间下载 JavaScript 文件时,它会将任务排队以解析和编译该 JavaScript,以便稍后可以执行。
  • 在页面生命周期的其他时间,当 JavaScript 执行工作时,例如通过事件处理程序、JavaScript 驱动的动画以及后台活动(如分析收集)响应交互时,任务会被排队。

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

什么是主线程?

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

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

当任何长度的任务正在运行时,浏览器会阻止交互发生,但只要任务运行时间不太长,用户就不会感觉到这一点。但是,当用户尝试在存在许多长任务的页面上进行交互时,用户界面会感到反应迟钝,如果主线程被阻塞很长时间,甚至可能会崩溃。

A long task in the performance profiler of Chrome's DevTools. The blocking portion of the task (greater than 50 milliseconds) is depicted with a pattern of red diagonal stripes.
Chrome 性能分析器中描绘的长任务。长任务在任务的角落用红色三角形指示,任务的阻塞部分填充有红色对角条纹图案。

为了防止主线程被阻塞太久,您可以将长任务分解为几个较小的任务。

A single long task versus the same task broken up into shorter task. The long task is one large rectangle, whereas the chunked task is five smaller boxes which are collectively the same width as the long task.
单个长任务与分解为五个较短任务的同一任务的可视化效果。

这一点很重要,因为当任务被分解时,浏览器可以更快地响应更高优先级的工作(包括用户交互)。之后,其余任务将运行完成,确保您最初排队的工作完成。

A depiction of how breaking up a task can facilitate a user interaction. At the top, a long task blocks an event handler from running until the task is finished. At the bottom, the chunked up task permits the event handler to run sooner than it otherwise would have.
当任务太长且浏览器无法足够快地响应交互时,交互会发生什么情况的可视化效果,以及当较长的任务被分解为较小的任务时的情况。

在上图中,用户交互排队的事件处理程序必须等待单个长任务才能开始。这延迟了交互的发生。在这种情况下,用户可能会注意到延迟。在底部,事件处理程序可以更快地开始运行,并且交互可能会感觉瞬间发生。

既然您知道为什么分解任务很重要,那么您可以学习如何在 JavaScript 中执行此操作。

任务管理策略

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

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

在此示例中,有一个名为 saveSettings() 的函数,该函数调用五个函数来验证表单、显示微调器、将数据发送到应用程序后端、更新用户界面以及发送分析。

从概念上讲,saveSettings() 的架构良好。如果您需要调试这些函数之一,您可以遍历项目树以找出每个函数的作用。像这样分解工作使项目更易于导航和维护。

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

The saveSettings function as depicted in Chrome's performance profiler. While the top-level function calls five other functions, all the work takes place in one long task that makes it so the user-visible result of running the function is not visible until all are complete.
调用五个函数的单个函数 saveSettings()。该工作作为单个长单片任务的一部分运行,阻止任何视觉响应,直到所有五个函数都完成。

在最佳情况下,即使只是其中一个函数也可能为任务的总长度贡献 50 毫秒或更多的时间。在最坏的情况下,更多这些任务可能会运行更长时间,尤其是在资源受限的设备上。

在这种情况下,saveSettings() 由用户单击触发,并且由于浏览器在整个函数运行完成之前无法显示响应,因此此长任务的结果是 UI 缓慢且无响应,并且将被衡量为较差的 交互到下次绘制 (INP)

手动延迟代码执行

为了确保重要的面向用户的任务和 UI 响应在较低优先级的任务之前发生,您可以通过短暂中断您的工作来让步于主线程,从而为浏览器提供运行更重要任务的机会。

开发人员用于将任务分解为较小任务的一种方法涉及 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()

浏览器支持

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

来源

scheduler.yield() 是专门为在浏览器中让步于主线程而设计的 API。

它不是语言级别的语法或特殊构造;scheduler.yield() 只是一个返回 Promise 的函数,该 Promise 将在未来的任务中解析。链接到在该 Promise 解析后运行的任何代码(在显式 .then() 链中或在 async 函数中 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();
}
The saveSettings function as depicted in Chrome's performance profiler, now broken up into two tasks. The first task calls two functions, then yields, allowing layout and paint work to happen and give the user a visible response. As a result, the click event is finished in a much quicker 64 milliseconds. The second task calls the last three functions.
函数 saveSettings() 的执行现在分为两个任务。因此,布局和绘制可以在任务之间运行,从而为用户提供更快的视觉响应,正如现在更短的指针交互所衡量的那样。

但是,scheduler.yield() 相对于其他让步方法的真正好处在于,它的延续是优先级的,这意味着如果您在任务中间让步,则当前任务的延续将在任何其他类似任务启动之前运行。

这避免了来自其他任务源的代码中断代码执行顺序,例如来自第三方脚本的任务。

Three diagrams depicting tasks without yielding, yielding, and with yielding and continuation. Without yielding, there are long tasks. With yielding, there are more tasks that are shorter, but may be interrupted by other unrelated tasks. With yielding and continuation, there are more tasks that are shorter, but their order of execution is preserved.
当您使用 scheduler.yield() 时,延续会在继续执行其他任务之前,从其停止的位置开始。

跨浏览器支持

scheduler.yield() 尚未在所有浏览器中都受支持,因此需要回退。

一种解决方案是将 scheduler-polyfill 放入您的构建中,然后可以直接使用 scheduler.yield();polyfill 将处理回退到其他任务调度函数,以便它在浏览器中的工作方式类似。

或者,可以使用几行代码编写一个不太复杂的版本,如果 scheduler.yield() 不可用,则仅使用包装在 Promise 中的 setTimeout 作为回退。

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 毫秒才让步于主线程。

A series of job functions, shown in the Chrome DevTools performance panel, with their execution broken up over multiple tasks
分批处理到多个任务中的作业。

不要使用 isInputPending()

浏览器支持

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

来源

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

这让 JavaScript 在没有输入待处理时继续运行,而不是让步并最终进入任务队列的末尾。这可以带来令人印象深刻的性能改进,如 Intent to Ship 中详述的那样,对于可能不会让步回到主线程的站点。

但是,自从该 API 发布以来,我们对让步的理解有所提高,尤其是在引入 INP 之后。由于多种原因,我们不再建议使用此 API,而是建议无论输入是否待处理都让步

  • 在某些情况下,尽管用户已经进行了交互,isInputPending() 可能会错误地返回 false
  • 输入不是任务应该让步的唯一情况。动画和其他常规用户界面更新对于提供响应迅速的网页也同样重要。
  • 此后,引入了更全面的让步 API,这些 API 解决了让步问题,例如 scheduler.postTask()scheduler.yield()

结论

管理任务具有挑战性,但这样做可以确保您的页面更快地响应用户交互。 管理和优先处理任务没有唯一的建议,而是有许多不同的技巧。 重申一下,以下是您在管理任务时需要考虑的主要事项

  • 对于关键的、面向用户的任务,让位于主线程。
  • 使用 scheduler.yield() (带有跨浏览器回退) 以符合人体工程学的方式让步并获得优先的延续
  • 最后,在您的函数中尽可能少地执行工作。

要了解更多关于 scheduler.yield()、其显式的任务调度相关项 scheduler.postTask() 以及任务优先级的信息,请参阅 优先级任务调度 API 文档

通过使用这些工具中的一个或多个,您应该能够组织应用程序中的工作,使其优先考虑用户的需求,同时确保不太重要的工作仍然可以完成。 这将创造更好的用户体验,使其更具响应性且更易于使用。

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

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