优化耗时较长的任务

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

发布时间: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: not supported.
  • Safari: not supported.

Source

scheduler.yield() 是一个专为让出浏览器中主线程而设计的 API。

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

在实践中:插入 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 操作。

例如,如果您要运行一系列作业,这些作业通常会合并为一个长任务,那么您可以插入 yield 来拆分该任务。

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 对本指南进行了技术审核。

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