Published on

优化长任务

我们知道 渲染线程js线程 是互斥的,两者没办法同时执行,所以 长时间的占有 js 线程 会导致挂起主线程,引发用户体验卡顿

会想到两种优化方向:

  • 不阻塞主线程

  • 分解长任务

那要如何优化js线程中任务的执行效率,首先要弄懂浏览器处理任务的机制

浏览器中的任务

主线程一次只能处理一个任务,当任务执行时间超过50ms时,会被标记为长任务

如果正在运行长任务时,尝试与页面进行交互,或者说需要进行优先级更高的渲染,浏览器将延迟执行,会导致交互或渲染出现延迟

长任务

谷歌性能剖析中的长耗时如图所示,一般会在任务角上用红色三角形标出来,其中被阻塞的任务部分用红色细斜条纹标出来

优化长任务,可以想到分解为几个耗时相对较短的任务

分解长任务

拆分长任务后,浏览器就有了更多的机会,可以去处理优先级别更高的工作,其中就包括用户交互行为.

交互行为

如果任务非常长,这时浏览器就没法快速处理用户交互,但拆分长任务后的从图中能看到效果就不一样.

因为长任务的缘故,用户交互产生的事件处理就必须排队,等待长任务执行完后才能执行.这个时候就会导致用户交互的延迟.当拆分成较短的任务后,事件处理器就有机会更快的触发. 因为事件处理器能够在短任务之间得以执行,也就比长任务耗时更短.在长耗时的图片中,用户可能就会感到卡顿;长任务拆分后,用户可能就感觉体验很流畅.

任务管理策略

可以尝试把一个任务拆分成更细维度

const saveSettings = () => {
  validateForm()
  showSpinner()
  saveToDatabase()
  updateUI()
  sendAnalytics()
}

然而,这样也有问题,就是 js 并不是为每个方法开辟一个单独的任务,因为这些方法都包含在 saveSetting 这个函数中,也就是说这五个方法在一个任务中执行

在 js 中,只有一个任务结束才会执行下一个任务,而且不论这个任务会阻塞主线程多久

saveSetting 这个函数调用 5 个函数,这个函数的执行看起来就像一个特别长的长的任务.

长任务执行时间

使用 async、await 来创造让步点

分解任务后,按照浏览器内部的优先级别划分,其他的任务可能优先级别调整的会更高.一种让步于主线程的方式是配合用了 setTimeoutpromise

const yieldToMain = () => new Promise((resolve) => void setTimeout(resolve, 0))

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

saveSettings 的函数中,可以在每次 await 函数 yieldToMain 后让步于主线程:

const saveSettings = async () => {
  // 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函数现在将其子函数作为单独的任务执行

只在必要时让步

假如有一堆的任务,但是只想在用户交互的时候才让步,该怎么办?正好有这种 api isInputPending

isInputPending 这个函数可以在任何时候调用,它能判断用户是否要与页面元素进行交互.调用 isInputPending 会返回布尔值,true 代表要与页面元素交互,false 则不交互.

const saveSettings = async () => {
  // 函数队列
  const tasks = [validateForm, showSpinner, saveToDatabase, updateUI, sendAnalytics]

  while (tasks.length > 0) {
    // 让步于用户输入
    if (navigator.scheduling.isInputPending()) {
      // 如果有用户输入在等待,则让步
      await yieldToMain()
    } else {
      // Shift the the task out of the queue:
      const task = tasks.shift()

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

saveSetting 执行过程中,会逐个循环队列中的任务.如果循环时 isInputPending 结果返回真,saveSetting 就会调用 yieldToMain 函数,这样就能处理用户输入的事件,反之,就会走到队列继续执行下一个,直到队列执行完.

必要时拆分

saveSetting 这个任务队列中有 5 个任务,但此时如果正在执行第二个任务而用户想打开某个菜单,于是点击了这个菜单,isInputPending 就会让步,让主线程处理交互事件,同时也会稍后执行后面剩余的任务. 用户输入后 isInputPending 的返回值不一定总是 true,这是因为操作系统需要时间来通知浏览器交互结束,也就是说其他代码可能已经开始执行,比如截图例子中的 saveToDatabase 这个方法可能已经在执行了.即便使用 isInputPending,还是需要在每个方法限制任务中的方法数量. 使用 isInputPending 配合让步的策略,能让浏览器有机会响应用户的重要交互,这在很多情况下,尤其是很多执行很多任务时,能够提高页面对用户的响应能力.

另一种使用 isInputPending 的方式,特别是担心浏览器不支持该策略,就可以使用另一种结合时间的方式.

const saveSettings = async () => {
  // 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 += 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()
  }
}

使用这种方式,通过结合时间来兼容不支持 isInputPending 的浏览器,尤其是使用截止时间或者在特定时间点,让工作能在适当时候中断,不论是通过让步于用户输入还是在特定时间节点.

编排优先级的 api

该 api 提供 postTask 的功能,对于所有的 chromium 浏览器和 firefox 均可使用.postTask 允许更细粒度的编排任务,该方法能让浏览器编排任务的优先级,以便地优先级别的任务能够让步于主线程.目前 postTask 使用 promise,接受优先级这个参数设定. postTask 方法有三个优先级别:

  • background 级,适用于优先级别最低的任务
  • user-visible 级,适用于优先级别中等的任务,如果没有入参,也是该函数的默认参数.
  • user-blocking 级,适用于优先级别最高的任务.

拿下面的代码来举例,postTask 在三处分别都是最高优先级别,其他的另外两个任务优先级别都是最低.

拿下面的代码来举例,postTask 在三处分别都是最高优先级别,其他的另外两个任务优先级别都是最低.

const 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' })
}

postTask

内置的不中断让步方法

还有一个编排 api 目前还在提议阶段,还没有内置到任何浏览器中.它的用法和本章和开始讲到的 yieldToMain 这个方法类似.

const saveSettings = async () => {
  // 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 方法.

yield

下面三幅图分别是

  • 不使用 yield
  • 使用 yield
  • 使用 yield 且不中断

不使用 yield,出现了长耗时任务。使用 yield,短任务数量变多了,而且还能被其他不相关的任务打断。使用 yield 且不中断,里面的短任务更多,但是执行顺序是固定的。 上面便是三种情况的效果图。使用 scheduler.yield 方法时,任务能在每次让步停止后重新开始。 使用 scheduler.yield 的好处是不中断,也就意味着如果是在一连串任务中 yield,那么从 yield 的时间点开始,其他编排好的任务的执行会继续。这就能避免第三方 js 脚本代码阻塞代码的执行

总结

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