Yield Points

2022-11-02 · 1,063 chars · 6 min read

前端性能优化的关键之一,就是避免长任务(Long Task),尽可能的将长任务拆分为若干个小的任务,使得浏览器可以响应用户操作等高优先级的事情。以往任务的拆分,最简单粗暴的方式就是使用 setTimeout,比如:

fun1()

setTimeout(() => {
  fun2()
}, 0)

这样可以确保 fun1fun2 在两个独立的 Task 中(注意必须是 setTimeout 等 macrotasks,microtasks 不行)。这样虽然可以一定程度上解决问题,但是看着比较丑,逻辑也会被拆的很乱。

最近无意中看到一篇 web.dev 上的文章:Optimize long tasks,提到一个叫 yield points 的概念,算是对 setTimeout 的优雅升级,今天我们就一起来看看。

什么是 Yield Points#

yield points 可以理解为“让步点”,也就是在 yield points 的位置上,我们将主线程的控制权交还给浏览器,让浏览器可以优先响应用户的高优操作,比如点击事件等等。直接来看看具体的代码实现:

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

这就是所谓的 yield points 了。上文也提到过,只有 macrotask 才可以打断长任务,所以 yield points 的核心依然是 setTimeout,而 Promise 只是为了让我们的代码更加优雅,方便使用 async/await 来组织。

我们看个具体的实例:

function fun() {
  let total = 0

  for (let i = 0; i < 20000000; i++) {
    total = total + i
  }

  console.log(total)
}

async function run() {
  const fun1 = () => fun()
  const fun2 = () => fun()
  const fun3 = () => fun()
  const fun4 = () => fun()
  const fun5 = () => fun()

  const tasks = [fun1, fun2, fun3, fun4, fun5]

  while (tasks.length > 0) {
    const task = tasks.shift()

    task()
  }
}

run()

fun 就是模拟一个比较耗时的任务,我们主要执行的是 run 函数,它里面会依次执行 5 个耗时较长的任务。火焰图如下:

长任务的火焰图

可以清楚的看到,fun1fun5 全部集中在一个 120ms+ 的长任务里。作为对比,我们加上 yieldToMain 再看看。

//...

while (tasks.length > 0) {
  const task = tasks.shift()

  task()

  // 只添加了这一行 !!!
  await yieldToMain()
}

//...

执行并查看火焰图:

使用 yieldToMain 的火焰图

在任务调度的时候,每个任务执行完,都执行一次 yieldToMain,拆开长任务,让步于主线程。另外,对比两张图可以发现,页面的 DOMContentLoaded 和 Onload 两个事件都提前了。

文章到这里,基本就说明白什么是 yield points 了。但是,现在是在每次子任务执行后,手动调用 yieldToMain,如果每个子任务都执行的很快,那这么做显然是没必要的。我们需要一种方法,仅在真正有必要的时候执行。

按需执行 Yield Points#

isInputPending() 函数可以用来判断用户是否正在和页面发生交互,是的话返回 true,否则 false。我们可以方便的使用这个 API 来判断是否需要让步于主线程。不过目前兼容性还不太好,可以通过 js 的运行时间做个兜底。

// 长任务是大于 50ms 的
let deadline = performance.now() + 50

while (tasks.length > 0) {
  if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
    await yieldToMain()

    // 延长 deadline
    deadline += 50

    continue
  }

  const task = tasks.shift()

  task()
}

总结#

简单梳理下目前打断长任务的方案:

  • 简单的场景,直接 setTimeout 即可。
  • 相对复杂一些,涉及到较多任务的,可以使用比较优雅的 yieldToMain
  • 更复杂的情况,无法直观判断 yield points,可以利用 isInputPending + 时间,实现一定程度上的自动调度。

这些方案的核心还是 setTimeout,插到任务循环的队尾,无法控制优先级。chrome 实现了一个新的 API:postTask,可以实现任务的优先级调度,目前兼容性还不太好。不过相信等这些 API 普及之后,前端在任务处理方面会更加的精细,面对复杂需求时依然能给用户提供良好的体验。

更多阅读#

赞赏

微信