优化耗时较长的任务

有人要求您“不要阻塞主线程”和“分解长任务”,但执行这些任务意味着什么?

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

  • "请勿阻塞主线程。"
  • “将冗长的任务分门别类。”

这是很好的建议,但涉及哪些工作?少加载 JavaScript 固然很好,但这是否会自动等同于界面响应更灵敏?也许,但不一定。

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

什么是任务?

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

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

与 JavaScript 相关的任务会在以下几个方面影响性能:

  • 当浏览器在启动过程中下载 JavaScript 文件时,会将任务排入队列,以解析和编译该 JavaScript,以便稍后执行。
  • 在页面生命周期的其他时间,当 JavaScript 起作用时(例如通过事件处理程序、JavaScript 驱动的动画和后台活动(例如 Analytics 收集)驱动互动),系统会将任务排入队列。

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

什么是主线程?

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

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

浏览器会在任何长度的任务运行时阻止交互,但只要任务运行时间不过长,用户就不会察觉到这种情况。但是,如果用户在执行许多耗时较长的任务时尝试与页面进行交互,界面会感觉无响应,如果主线程长时间处于阻塞状态,甚至可能被破坏。

Chrome 开发者工具性能分析器中的一项长任务。任务的阻塞部分(大于 50 毫秒)以红色对角线图案绘制。
Chrome 性能分析器中显示的长任务。耗时较长的任务由其一角的红色三角形表示,任务的阻塞部分填充有红色对角线图案。

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

单个长任务与拆分为较短任务的相同任务。长任务是一个大矩形,而分块任务是五个小框,它们的宽度总和与长任务相同。
直观呈现单个长任务与相同任务(分为五个较短任务)对比情况。

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

对分解任务如何促进用户互动的描述。在顶部,长时间运行的任务会阻塞事件处理程序的运行,直到任务完成为止。在底部,分块任务允许事件处理程序比其运行得更早运行。
与当任务时间过长且浏览器无法足够快速地响应互动时,与将较长的任务拆分为较小的任务相比,互动会发生什么情况的可视化。

在上图的顶部,由用户互动排入队列的事件处理程序必须等待一个长任务才能开始,这就延迟了互动的发生。在这种情况下,用户可能已注意到延迟。在底部,事件处理脚本可以更早开始运行,用户可能感觉到即时互动。

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

任务管理策略

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

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

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

从概念上讲,saveSettings() 具有良好的架构。如果您需要调试其中某个函数,可以遍历项目树来确定每个函数的用途。像这样拆分工作会使项目更易于导航和维护。

不过,这里的潜在问题是,JavaScript 不会将每个函数作为单独的任务运行,因为它们是在 saveSettings() 函数中执行的。也就是说,这五个函数都将作为一个任务运行。

Chrome 性能分析器中所述的 SaveSettings 函数。虽然顶级函数会调用其他五个函数,但所有工作都发生在一个阻塞主线程的长任务中。
一个调用 5 个函数的单个函数 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 是拆分任务的有效方式,但也有一个缺点:当您通过推迟在后续任务中运行代码来让出主线程时,该任务会被添加到队列的末尾。

如果您控制着页面上的所有代码,则可以自行创建调度程序并设定任务的优先级,但第三方脚本不会使用您的调度程序。实际上,在此类环境中,您无法确定工作的优先次序。您只能细分数据,或者明确让出用户互动。

浏览器支持

  • 94
  • 94
  • x

来源

调度器 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 实现延续

我们提议在调度器 API 中添加 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();
  }
}

大家对这段代码非常熟悉,但它不使用 yieldToMain(),而是使用 await scheduler.yield()

三个示意图,分别显示了未让出、未让出以及有让出和连续的任务。如果没有退让,就会有很长的任务。借助收益功能,会有较多的任务耗时较短,但可能会被其他不相关的任务中断。通过让出和延续功能,会有更多时间更短的任务,但其执行顺序会保留。
使用 scheduler.yield() 时,即使在屈服点之后,任务执行会从中断的位置继续。

scheduler.yield() 的优势在于连续性,这意味着,如果您在一组任务的中间让出,其他计划任务在屈服点后将按相同的顺序继续执行。这样可以避免来自第三方脚本的代码中断代码的执行顺序。

由于 user-blocking 优先级较高,将 scheduler.postTask()priority: 'user-blocking' 一起使用也很有可能会继续运行,因此在此期间可以使用此方法作为替代方案。

使用 setTimeout()(或将 scheduler.postTask()priority: 'user-visibile' 或没有显式 priority 搭配使用)可将任务安排在队列后部,以便允许其他待处理任务在继续前运行。

不使用 isInputPending()

浏览器支持

  • 87
  • 87
  • x
  • x

isInputPending() API 提供了一种方法,可用于检查用户是否尝试过与网页互动,并且仅当某项输入处于待处理状态时才会产生收益。

这样,即使没有待处理的输入,JavaScript 也能继续运行,而不是在任务队列后退并结束。对于那些可能不会回退到主线程的网站,这可能会带来显著的性能提升(如 Intent to Ship 中所述)。

不过,自该 API 推出以来,我们对收益的理解加深了,尤其是在引入 INP 后。我们不再建议使用此 API,而是建议执行生成操作,无论输入是否处于待处理状态,原因如下:

  • 尽管在某些情况下用户发生了互动,isInputPending() 仍可能会错误地返回 false
  • 输入并不是任务应产生的唯一情况。动画和其他定期的界面更新对于提供自适应网页同样重要。
  • 此后,引入了更全面的收益生成 API 来解决收益问题,例如 scheduler.postTask()scheduler.yield()

总结

管理任务颇具挑战性,但这样做可确保您的网页更迅速地响应用户交互操作。管理任务和确定任务优先级时,并不是一个唯一的建议,而是许多不同的技术。再次强调,下面是您在管理任务时需要考虑的主要事项:

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

使用这些工具中的一个或多个,您应该能够在应用中构建工作的结构,以便它优先考虑用户的需求,同时确保仍能完成不太重要的工作。这将带来更好的用户体验,响应速度更快,使用起来更愉快。

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

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