not a better man

前端技术

怎样去优化长任务

在web前端中,我们总是会听说不要阻塞主线程,我们要打断长任务的运行。做这些事情的意义到底在哪里?

如果我们看过很多关于web 性能优化的文章,我们会发现保持我们的javascript 应用程序变快的建议中往往都会提到如下的建议:

  • 不要阻塞主线程
  • 把长任务打断

我们在加载的页面的时候,基本上都会去考虑怎么减少javascript 代码的体积,这个能够提高页面打开的速度,但是更少的代码并不一定意味着用户的界面体验会更流畅。

要懂得优化js中任务的重要性。那么我们首先需要了解什么是任务,任务的角色,以及浏览器是怎么处理任务的。我们先来了解一下什么是任务?

什么是任务

浏览器执行的各个任务之间是相互独立的。例如页面渲染,解析html和css,运行我们编写的javascript代码,以及一些我们无法直接控制的事情都输入任务的范畴。但是浏览器中任务的主要来源是我们编写和部署的代码。

在Chrome DevTools的性能分析器中,描述了由点击事件处理器启动的任务。

任务影响性能的方式很多,比如我们在打开网站时下载js代码,浏览器会把任务放在队列中,当解析编译javascript完成执行,再去排队执行任务。随后页面的任务会随着用户交互驱动的事件处理器,js动画以及分析收集的后台活动等js活动触发(web worker 等类似的api除外)。

什么是主线程

浏览器中大多数任务都发生在主线程中。主线程之所以被称为主线程的一个主要原因是:我们写的javascript代码几乎都在该线程上执行。

在同一时刻,主线程只能运行一个任务。当该任务的执行时间超过50ms的时候,那么这个任务就会被称之为长任务(long task)。当一个长任务正在运行的过程中,这个时候用户也在与页面进行交互,或者这个时候有个关键的渲染更新需要进行。这个时候,浏览器会延迟用户交互或者渲染。这个时候导致用户交互或渲染延迟,也是就页面发生卡顿。

Chrome浏览器的性能分析器中描述的长任务。长任务的右上角一个红色的三角形,任务的阻塞部分用对角线的红色条纹填充。

我们需要把长任务进行分解,也就是把一个长任务分解长几个小的任务,让每个任务的执行时间变短。

将一个长任务分解成五个短任务

分拆任务是个很重要的,原因是当长任务被分拆成短任务之后,浏览器有更多的机会去执行优先级比较高的工作,如用户的交互。

上图是可视化的描述运行长任务时候接受到用户交互与运行短任务时,接受到用户交互的不同点,长任务不能及时处理用户交互,而短任务处理用户交互会变的更快。

从上图可以看出,因为长任务的原因,处理用户交互的时间会被延迟到等待长任务的完成,这个时候会让用户感觉页面卡顿。当把长任务拆分成短任务时,

用户交互产生的事件处理器会在短任务之间执行,这样会让用户感觉体验变动流畅,页面并不卡顿。问题是,我们都知道要把长任务拆分成短任务,减少主线程的阻塞,但是在实际的操作中,我们该怎么去做,怎么去拆分长任务?

任务管理策略

在软件架构中,常常提及到将功能分拆成一个个小功能。这样在代码可读性,项目可维护程度会更高,此外测试用例也更容易.

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

在上面的例子中,名为saveSettings()的函数调用了五个函数完成保存设置的功能。保存设置的功能验证表单,显示加载条,保存数据等等。从设计概念上来讲,这是一个很好的架构,如果我们想要调试其中的一个函数,我们能够查到具体每个函数的功能实现。

需要注意的是,在javascript 并不是把每个子函数当一个独立的任务来执行,因为他们是在saveSettings()函数中执行的。这意味着上面的五个函数被当做个任务进行执行。

注意
javascript使用 run-to-completion model 来执行每一个任务。这意味着每个任务都会执行完之后,才会退出主线程,不会考虑阻塞主线程多长时间。

调用五个函数的saveSettings()函数,该函数耗时在长任务中占比很大

在最好的情况下,即使只是这些功能中的一个,也可以为任务的总时长贡献50毫秒或更多的时间。在最坏的情况下,更多的这些任务可以运行相当长的时间–特别是在低端设备上。我们可以使用下面的策略来分解,优先处理优先级高的任务。

手动延迟代码执行

我们之前经常使用setTImeout这个函数来将长任务分解成一个个更小的任务。我们可以在函数中使用setTimout来将saveSettings 进行分解。利用它将回调函数分割成一个独立的任务,即使我们将延迟时间设置为0

function saveSettings () {
  // 执行关键任务
  validateForm();
  showSpinner();
  updateUI();

  // 将用户视觉上看不到的工作推迟到一个独立的任务中执行
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

如果我们有一系列需要连续执行的函数,使用上述的方式,能够达到预期的效果,但是我们的代码并不总是以上述的方式进行组织。例如,我们有个巨大的数据需要在一个队列中处理,当数据量为百万级别时,需要花费很长的时间。

function processData(){
  for (const itme of largeDataArray) { 
     //处理每一个item
  }
}

在上述代码中使用setTimeout() 是有很大问题的,因为它的人机工程学使得它很难实现,而且整个数据数组可能需要很长的时间来处理,即使每个item都可以很快处理。在这里使用setTimeout并不合时宜。

除了使用 setTimeout(),我们还可以使用其他的api将代码延迟到后续的任务中执行。我们可以使用postMessage()让延迟时间更短。我们也可以使用requestIdleCallback()来拆分任务,但是我们必须要注意这个函数。requestIdleCallback()这个函数调度的task的优先级相当低,它只有当浏览器有空闲的时间时候,才会去执行。当主线线程一直处于拥挤状态,由requestIdleCallback()生成的任务可能会一直不执行。

使用 aysnc/await 来创建让步点

在本文的剩下部分我们会到看到一个短语就是,在主线程上创建让步点。这个是什么意思?我们为什么要这么做?哪种情况下我们需要这么做?

重要提示
当让步于主线程之后,给了主线程一个机会去处理比当前正在排队的任务更重要的任务。理想情况下我们有一些关键的面向的用户的工作需要更快的执行时。我们应该创建让步点,为主线程更快的执行关键的工作创造机会。

当长任务被拆分之后,根据浏览器自带的执行策略划分,浏览器能够更好的执行其他优先级级别高的任务。让步给主线程的一种方式是使用setTimeoutPromise

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

注意
尽管这个例子在返回promise中通过setimeout来调用resolve,但是并没有新开一个任务让promise执行后续代码,而是通过setTimeout调用。因为promise的回调属于微任务,因此不会让步于主线程。

在saveSettings() 函数中,如果在每个函数之后添加 await yieldToMain() 代码,就会在每个task处理完之后,让步给主线程去执行更重要的工作。代码如下图所示

async function saveSettings () {
  // 创建一个函数队列
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    //拆分任务
    const task = tasks.shift();

    // 运行任务
    task();

    // 让步给主线程
    await yieldToMain();
  }
}

重要提示
我们并不一定需要在每个函数执行之后创建让步点。举个例子,如果现在我们执行了两个函数,而这两个函数会导致页面进行关键的更新,这个时候我们并一定需要在这两个函数之间创建让步点。我们在哪些并不需要运行关键任务的函数之间创建让步点。

通过上述代码,我们将一个长任务分拆成一个个独立的任务。

现在savSettings被分拆成一个独立的任务

我们使用基于promise的方法而不是使用直接使用setTimeOut来创建让步点。让代码的可读性变强。

只在有必要的情况让步

如果我们有一堆任务,,但是我们只想在用户试图与页面交互时让出主线程,这个时候该怎么办?(其实需要知道用户是否在与页面进行交互,需要有这样的API)这个时候我们可以使用isInputPending()这个API来确定用户是否与页面进行交互。

我们可以在任何时候调用isInputPending()来判断用户是否企图与页面元素进行交互。当正在交互的时候,isInputPending() 返回true,否则的话返回false

假如我们有一系列需要运行的任务看,但是我们不想妨碍任何输入。下面的这段代码,它同时使用了isInputPending()和我们自定义的yieldToMain()函数,来确保用户在尝试交互时输入不会被延迟。

async function saveSettings () {
  // 任务队列
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // 让步等待用户输入
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // 分拆任务
      const task = tasks.shift();

      // 执行任务
      task();
    }
  }
}

saveSettings()运行的时候,它将遍历队列的任务。如果在循环期间,有用户输入,这个时候isInputPending()将返回true。saveSettings()将调用yieldToMain()处理用户输入,否则将下一个任务从队列中移出,并执行该任务,直到所有任务都执行完成。

saveSettings()处理五个任务的队列,在第二个任务的运行的时候,用户点击了打开菜单操作,会让步给主线程处理用户操作,然后再执行其他的任务。

isInputPending() 可能并不总是在用户输入之后立即返回true,这是因为操作系统需要时间来告诉浏览器发生了交互。这意味着其他的代码已经开始执行了(IsInputPending的返回延迟,如上图所示,在执行完saveToDatabase()之后,浏览器才知道有交互发生)。因此,即使我们使用了isInputPending(),但是我们仍然需要限制每个函数执行的工作量。

将isInputPending()与让步机制结合使用,这是个让浏览器停止正在处理的任何任务,来响应关键的用户交互的好方法。当有繁重的任务在进行的时候,这可以提高页面的响应能力。

当浏览器不支持isInputPending()时候,我们可以利用performance来进行降级处理,如下面代码所示。

async function saveSettings () {
  // 任务队列
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    /
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      
      // 当有用户交互或者占用时长超过50ms时,让出主线程
      await yieldToMain();

      // 将deadline时间延长50ms
      deadline += 50;

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

    // 将队头任务推出
    const task = tasks.shift();

    // 运行任务
    task();
  }
}

利用这种方式,我们可以在浏览器不支持isInputPending()的情况下来进行降级处理。

上述API的缺点

到目前为止提到的API可以帮助我们分解长任务,但是都有个明显的缺点:这些API只是让任务延迟执行。如果页面能够控制页面上的所有代码,那么我们可以创建自己的调度程序,并确定任务的优先级,但是当引入第三方脚本时,我们是无法控制工作的优先级的。我们只能够将其分块,或者显性的地让步给用户交互。

很高兴,目前浏览器提供了一个专用的调度api来解决这些难题。

专门用于编排优先级的 API

scheduler API 提供了 postTask() 函数,目前的支持情况如下

postTask目前浏览器的支持情况

postTask() 函数支持颗粒度更新的调度任务,可以给任务排优先级,让低优先级的任务让步给主线程。postTask() 返回promise,接受的参数用来设置优先权。

postTask()支持三个层级的优先权设置,分别如下

  • background 代表最低优先级的任务
  • user-visible 代表中等优先级的任务,这个是默认值设置
  • user-blocking 代码优先级最高的任务

我们拿下面的代码作为例子,有三个任务设置了最高的优先级,有两个任务设置了最低的优先级。

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() 将子任务编排成五个独立的任务,与交互相关的任务优先级高,用户感知不到的任务优先级低,这样能够使用户交互更快的执行。

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

重要提示
postTask()目前的兼容性并不好,如果使用它,可以考虑使用polyfill。

内置不中断的让步方法

scheduler API 的一个目标是内置一个让步机制,这个机制目前还没有在浏览器中实现,目前还是个提案。我们可以参考WICG的说明yield-and-continuation

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();
  }
}
不中断的让步与中断的让步以及没有中断机制的对比

使用scheduler.yield的好处是不中断,也就意味着如果是在一连串任务中yield,那么从yield的时间点开始,其他编排好的任务的执行会继续执行,不会被第三方js代码阻塞代码的执行。

总结

虽然管理任务富有挑战性,但管理任务却能让我们受益颇多,会让网站能有更快的用户交互体验。管理和调优没有万灵药,但确有一系列不同的技巧。最后总结一下,管理任务时主要需要考虑以下几点:

  • 遇到关键任务和用户侧的任务需要让步于主线程
  • 使用isInputPending来让步主线程让用户可以与页面交互
  • 适应postTask来调整任务的优先级
  • 最后,每个函数尽可能地减少活动

使用以上一个或多个方法,就能够将应用中的任务进行管理,根据用户需要来调整优先级,同时能保证相对不那么重要的工作得以继续执行,来创造更好的用户体验,使网站响应更快,使用更令人心情愉悦。

发表评论