迹忆客 专注技术分享

当前位置:主页 > 学无止境 >

对长任务进行优化

作者:迹忆客 最近更新:2023/01/18 浏览次数:

在变成过程中你被告知“不要阻塞主线程”和“分解你的长任务”,但是做这些事情意味着什么?

如果大家阅读了很多关于 Web 性能的文章,那么保持 JavaScript 应用程序快速运行的建议往往涉及以下一些内容:

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

这是什么意思? 更少的 JavaScript 是好的,但这是否自动等同于整个页面生命周期中更快速的用户界面? 也许,但也许不是。

要理解为什么在 JavaScript 中优化任务很重要,大家需要了解任务的作用以及浏览器如何处理它们——首先要了解什么是任务。


什么是任务?

任务是浏览器执行的任何离散工作。 任务涉及渲染、解析 HTML 和 CSS、运行我们编写的 JavaScript 代码以及可能无法直接控制的其他事情。 在所有这一切中,我们编写并部署到 Web 的 JavaScript 是任务的主要来源。

 

对 Chrome DevTools 性能分析器中点击事件处理程序启动的任务的描述
对 Chrome DevTools 性能分析器中点击事件处理程序启动的任务的描述

 

任务以多种方式影响性能。 例如,当浏览器在启动期间下载 JavaScript 文件时,它会将任务排队以解析和编译该 JavaScript,以便执行。 在页面生命周期的后期,任务会在我们的 JavaScript 正常工作时启动,例如通过事件处理程序驱动交互、JavaScript 驱动的动画和分析收集等后台活动。 所有这些东西——除了 web workers 和类似的 API——都发生在主线程上。


什么是主线程?

主线程是大多数任务在浏览器中运行的地方。 它被称为主线程是有原因的:它是我们编写的几乎所有 JavaScript 工作的唯一线程。

主线程一次只能处理一个任务。 当任务超过某个点时——准确地说是 50 毫秒——它们被归类为长任务。 如果用户在运行较长的任务时尝试与页面交互——或者如果需要进行重要的渲染更新——浏览器将延迟处理该工作。 这会导致交互或渲染延迟。

 

Chrome 性能分析器中描述的一项长期任务长任务由任务角落的红色三角形表示任务的阻塞部分用对角线红色条纹图案填充
Chrome 性能分析器中描述的一项长期任务长任务由任务角落的红色三角形表示任务的阻塞部分用对角线红色条纹图案填充

 

我们需要分解任务。 这意味着将一项长任务分解为更小的任务,这些任务单独运行所需的时间更少。

 

单个长任务与分解为五个较短任务的相同任务的可视化
单个长任务与分解为五个较短任务的相同任务的可视化

 

这很重要,因为当任务被分解时,浏览器有更多机会响应更高优先级的工作——包括用户交互。

 

当任务太长且浏览器无法足够快地响应交互时交互发生的情况的可视化,以及当较长的任务被分解成较小的任务时的情况
当任务太长且浏览器无法足够快地响应交互时交互发生的情况的可视化,以及当较长的任务被分解成较小的任务时的情况

 

在上图的顶部,由用户交互排队的事件处理程序必须等待一个长任务才能运行,这会延迟交互的发生。 在底部,事件处理程序有机会更快地运行。 因为事件处理程序有机会在较小的任务之间运行,所以它比必须等待较长任务完成的情况运行得更快。 在上面的示例中,用户可能已经注意到延迟; 在底部,交互可能是即时的。

但是,问题是“分解你的长期任务”和“不要阻塞主线程”的建议不够具体,除非你已经知道如何做这些事情。 这就是本篇文章将解释的内容。


任务管理策略

软件架构中的一个常见建议是将我们的工作分解为更小的功能。 这为我们带来了更好的代码可读性和项目可维护性的好处。 这也使得测试更容易编写。

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

在此示例中,有一个名为 saveSettings() 的函数调用其中的五个函数来完成工作,例如验证表单、显示微调器、发送数据等。 从概念上讲,这是精心设计的。 如果我们需要调试其中一个函数,我们可以遍历项目树来找出每个函数的作用。

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

重要的 :JavaScript 以这种方式工作,因为它使用任务执行的运行到完成模型。 这意味着每个任务都将运行直到它完成,而不管它阻塞主线程多长时间。

 

调用五个函数的单个函数 saveSettings()。 这项工作作为一项长期整体任务的一部分运行
调用五个函数的单个函数 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() 不是完成这项工作的正确工具——至少在以这种方式使用时不是。

除了 setTimeout() 之外,还有一些其他 API 允许我们将代码执行推迟到后续任务。 一种涉及使用 postMessage() 来实现更快的超时。 我们也可以使用 requestIdleCallback() 分解工作——但要小心!——requestIdleCallback() 以尽可能低的优先级安排任务,并且只在浏览器空闲时间。 当主线程拥塞时,使用 requestIdleCallback() 安排的任务可能永远无法运行。


使用 async/await 创建 yield 点

我们将在本指南的其余部分看到的一个短语是“yield于主线程”——但这是什么意思? 你为什么要这样做? 你应该什么时候做?

重要的 : 当我们 yield 到主线程时,我们将有机会处理比当前排队的任务更重要的任务。 理想情况下,只要有一些重要的面向用户的工作需要比不让步更快地执行,就应该让步到主线程。 让步到主线程为关键工作更快运行创造了机会。

当任务被分解时,其他任务可以通过浏览器的内部优先级方案更好地排列优先级。 让步于主线程的一种方法涉及使用通过调用 setTimeout() 解析的 Promise 的组合:

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

警告 : 虽然此代码示例返回一个在调用 setTimeout() 后解析的 Promise,但负责在新任务中运行其余代码的不是 Promise,而是 setTimeout() 调用。 Promise 回调作为微任务而不是任务运行,因此不会让步于主线程。

saveSettings() 函数中,如果在每次函数调用后等待 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:
    await yieldToMain();
  }
}

重要的 : 我们不必在每次函数调用后都让步。 例如,如果我们运行两个导致对用户界面进行重要更新的函数,我们可能不希望在它们之间进行让步。 如果可以,让该工作先运行,然后考虑在执行不太重要的功能或用户看不到的后台工作之间让步。

结果是曾经的整体任务现在被分解成单独的任务。

 

saveSettings() 函数现在将其子函数作为单独的任务执行
saveSettings() 函数现在将其子函数作为单独的任务执行

 

使用基于 Promise 的方法来产生而不是手动使用 setTimeout() 的好处是更好的人体工程学。 yield 点变成声明式的,因此更容易编写、阅读和理解。


仅在必要时产生

如果我们有一堆任务,但我们只想在用户尝试与页面交互时让权怎么办? 这就是 isInputPending() 的用途。

isInputPending() 是一个我们可以随时运行以确定用户是否试图与页面元素交互的函数:调用 isInputPending() 将返回 true。 否则返回 false

假设我们有一个需要运行的任务队列,但我们不想妨碍任何输入。 这段代码——同时使用 isInputPending() 和我们自定义的 yieldToMain() 函数——确保在用户尝试与页面交互时输入不会被延迟:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // Yield to a pending user input:
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // Shift the the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

saveSettings() 运行时,它将遍历队列中的任务。 如果 isInputPending() 在循环期间返回 truesaveSettings() 将调用 yieldToMain() 以便处理用户输入。 否则,它将把下一个任务从队列的前面移开并连续运行。 它将执行此操作,直到没有更多任务为止。

 

saveSettings() 为五个任务运行一个任务队列,但用户在第二个工作项运行时单击打开菜单。 isInputPending() 让主线程处理交互,并恢复运行其余任务
saveSettings() 为五个任务运行一个任务队列,但用户在第二个工作项运行时单击打开菜单。 isInputPending() 让主线程处理交互,并恢复运行其余任务

 

isInputPending() 可能并不总是在用户输入后立即返回 true。 这是因为操作系统需要时间来告诉浏览器交互发生了。 这意味着其他代码可能已经开始执行(如我们在上面的屏幕截图中使用 saveToDatabase() 函数所见)。 即使我们使用 isInputPending() ,限制我们在每个函数中所做的工作量仍然很重要。

isInputPending()yield 机制结合使用是让浏览器停止其正在处理的任何任务的好方法,以便它可以响应关键的面向用户的交互。 这有助于提高我们的页面在许多情况下响应用户的能力,当许多任务正在进行时。

另一种使用 isInputPending() 的方法——特别是如果我们担心为不支持它的浏览器提供回退——是结合使用基于时间的方法和可选的链接运算符:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline = performance.now() + 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

使用这种方法,我们可以通过使用基于时间的方法使用(并调整)截止日期,以便在必要时分解工作,无论是通过让步于用户输入, 或在某个时间点。


当前 API 中的差距

到目前为止提到的 API 可以帮助我们分解任务,但它们有一个明显的缺点:当我们通过延迟代码在后续任务中运行而屈服于主线程时,该代码将被添加到任务队列的最后。

如果我们控制页面上的所有代码,则可以创建自己的调度程序并能够确定任务的优先级,但第三方脚本不会使用我们的调度程序。 实际上,我们无法真正确定在此类环境中工作的优先级。 我们只能将其分块,或明确 yield 于用户交互。

幸运的是,目前正在开发的专用调度程序 API 可以解决这些问题。


专用的调度程序 API

调度程序 API 目前提供 postTask() 函数,在撰写本文时,该函数在 Chromium 浏览器和 Firefox 中可用。 postTask() 允许更细粒度的任务调度,并且是帮助浏览器确定工作优先级以便低优先级任务让步给主线程的一种方法。 postTask() 使用 Promise,并接受优先级设置。

postTask() API 具有三个我们可以使用的优先级:

  • 最低优先级任务的“背景”。
  • 中等优先级任务的“用户可见”。 如果没有设置优先级,这是默认值。
  • 需要以高优先级运行的关键任务的“用户阻塞”。

以下面的代码为例,其中 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'});
};

在这里,任务优先级的安排方式使得浏览器优先级任务(例如用户交互)可以按自己的方式进行。

 

运行 saveSettings() 时 该函数使用 postTask() 调度各个函数 面向用户的关键工作安排在高优先级 而用户不知道的工作安排在后台运行 这允许用户交互执行得更快 因为工作既被分解又被适当地确定了优先级
运行 saveSettings() 时 该函数使用 postTask() 调度各个函数 面向用户的关键工作安排在高优先级 而用户不知道的工作安排在后台运行 这允许用户交互执行得更快 因为工作既被分解又被适当地确定了优先级

 

这是一个如何使用 postTask() 的简单示例。 可以实例化不同的 TaskController 对象,这些对象可以在任务之间共享优先级,包括根据需要更改不同 TaskController 实例的优先级的能力。

重要的 并非所有浏览器都支持 postTask()。 我们可以使用特征检测来查看它是否可用,或者考虑使用 polyfill。


带有延续的内置收益

目前尚未在任何浏览器中实现的调度程序 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(),而是调用并等待 scheduler.yield()

 

使用 scheduler.yield() 时,即使在yield点之后,任务执行也会从中断的地方开始
使用 scheduler.yield() 时,即使在yield点之后,任务执行也会从中断的地方开始

 

scheduler.yield() 的好处是连续性,这意味着如果你在一组任务的中间让步,其他计划任务将在让步点之后以相同的顺序继续。 这可以避免来自第三方脚本的代码篡夺代码的执行顺序。


总结

管理任务具有挑战性,但这样做有助于我们的页面更快地响应用户交互。 没有一条关于管理任务和确定任务优先级的建议。 相反,它是许多不同的技术。 重申一下,这些是我们在管理任务时需要考虑的主要事项:

  • 让主线程执行关键的、面向用户的任务。
  • 当用户尝试与页面交互时,使用 isInputPending() 来让步给主线程。
  • 使用 postTask() 确定任务的优先级。
  • 最后,在你的函数中做尽可能少的工作。

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

转载请发邮件至 1244347461@qq.com 进行申请,经作者同意之后,转载请以链接形式注明出处

本文地址:

相关文章

扫一扫阅读全部技术教程

社交账号
  • https://www.github.com/onmpw
  • qq:1244347461

最新推荐

教程更新

热门标签

扫码一下
查看教程更方便