优化耗时较长的任务

您可能听说过“不要阻塞主线程”和“分解长时间运行的任务”,但这些话是什么意思呢?

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

Source

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

它不是语言级语法或特殊构造;scheduler.yield() 只是一个返回 Promise 的函数,该 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它。

例如,如果您有一系列要运行的作业,这些作业通常会累积成一个长时间运行的任务,您可以插入 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() 和任务优先级,请参阅 Prioritized Task Scheduling API 文档

借助这些工具中的一个或多个,您应该能够合理安排应用中的工作,优先满足用户需求,同时确保完成不太重要的工作。这将打造更出色的用户体验,让用户能够更顺畅、更愉快地使用应用。

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

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