Skip to main content

实现任务切片,更好控制渲染行为(scheduler.yield)

了解浏览器渲染机制对前端开发者来说非常重要,scheduler.yield 新 API 的出现,可以帮助编写出更高效、响应更快的应用程序。

下面根据一些文章按照我能理解的方式整理出这篇文章,主要目的是想让知识点和逻辑更加清晰易懂。如有错误,欢迎指正。

基础知识

开始进入今天主题之前,首先我们要知道 JS 的事件循环机制和浏览器渲染机制是如何配合互动的。

  1. 浏览器渲染周期
  • 浏览器通常尝试以每秒 60 帧的速率渲染内容,这意味着大约每 16.66 毫秒浏览器会有一个渲染机会(也被称为帧)。
  • 渲染周期包括 JS 运行、样式计算、布局、绘制等步骤。

浏览器渲染周期

  • 在大多数浏览器中,GUI 渲染线程与 JS 引擎线程是互斥的。当执行 JS 引擎线程时,GUI 渲染线程会被挂起,当前任务队列为空时,JS 引擎才会去执行 GUI 渲染。
  • 如果在 16.66 毫秒中没完成一帧的绘制,那么页面上的动画就会出现卡顿,就会导致用户对于界面感知的不友好性。
  1. JS 执行与帧限制
  • 当 JS 执行时,它可能会修改 DOM,导致页面变化,这些变化需要在下一个帧中被渲染。
  • 因此通常情况下,浏览器每次绘制之前会先运行完 JS 主线程的任务。如果 JS 执行时间过长,它可能会延迟浏览器的渲染过程,导致掉帧,使得用户体验变差。
  • 这里可以尝试下在控制台执行一个死循环 while(true){},会发现无限循环阻塞该线程上的其他操作,不会执行后面的脚本,且页面冻结,甚至浏览器奔溃。
  1. 宏任务、微任务与渲染
  • 宏任务可能是由事件处理、setTimeout、setInterval 或者其他异步 API 触发。
  • 在每个宏任务执行完毕后,浏览器会处理所有的微任务,这些通常由异步操作如 Promises 触发。
  • 微任务的处理保证在渲染之前,页面的状态是最新的,因为微任务执行后可能会产生对 DOM 的更改。
  • 一旦微任务队列清空,浏览器将检查是否需要执行渲染更新,执行渲染操作(如计算样式、布局、绘制等)。
  1. requestAnimationFrame
  • requestAnimationFrame(rAF)是一个特别的 API,它允许调度在下一个渲染帧之前执行的回调,开发者可以利用 rAF 将长 JS 任务在一个或多个帧中分散优化页面。
  • 使用 rAF 进行动画或 DOM 操作可以保证在浏览器准备好渲染下一个帧时执行,从而提高性能并减少不必要的布局重新计算。

下次绘制交互 (INP)

INP(Interaction to Next Point)是一项新的性能指标,浏览器计划于 2024 年 3 月将其取代取代首次输入延迟 (FID) ,成为最新的 Web Core Vitals((Web 核心性能指标)。

Chrome 使用数据显示,用户在页面上花费的时间有 90% 是在网页加载完成后花费的,因此,仔细测量整个页面生命周期的响应能力是非常重要的,这就是 INP 指标评估的内容,通过观察用户访问页面的整个生命周期中发生的所有单击、敲击和键盘交互的延迟来评估页面对用户交互的整体响应能力。

INP 的满意分数范围

良好的响应能力意味着页面可以快速响应并且与用户进行的交互。当页面响应交互时,最直接的结果就是视觉反馈,由浏览器在浏览器渲染的下一帧中体现。

例如,视觉反馈会告诉我们是否确实添加了购物车的商品、是否快读打开了导航菜单、服务器是否正在对登录表单的内容进行身份验证等等。

INP 的目标就是确保对于用户进行的所有或大多数交互,从用户发起交互到绘制下一帧的时间尽可能短。

INP 考虑的是所有页面的交互,而首次输入延迟 (FID) 只会考虑第一次交互。而且 FID 只测量了第一次交互的输入延迟,而不是运行事件处理程序所需的时间或下一帧渲染的延迟。

浏览器希望使用 INP 替代 FID 就意味着用户的交互体验越来越重要了,我们常常听到的时间切片任务切片的概念,实际上就是为了提升网页的交互响应能力。

任务切片

JS 使用 run-to-completion 模型来处理任务。这意味着,当任务在主线程上运行时,该任务将运行必要的时间才能完成。任务完成后,控制权交会还给主线程,这样主线程就可以处理队列中的下一个任务。

除了任务永远不会完成的极端情况(例如无限循环)之外,任务切割是 JS 任务调度逻辑中不可避免的情况,它发生只是时间问题,越早越好。当任务运行时间过长(>= 50ms)时,它们被认为是长任务(long-task)。

长任务是页面响应差的一个原因,因为它们延迟了浏览器响应用户行为的速度。长任务出现的频率越高,它运行的时间越长,用户体验就越差,甚至无法正常使用。

任务切片前后

这张图可以很直观的显示:

  • 优化前,任务切割只发生在任务完成之后,这意味着任务在将控制权返回给主线程之前可能需要更长的时间才能完成。
  • 优化后,简短的任务允许事件处理程序比正常情况下更快的运行,这样,用户能更快的接受到行为反馈,从而提高输入响应速度和 INP。

这也是 React Fiber 的核心思想之一: 把可中断的异步渲染任务分割开,并且根据任务的优先级决定其执行顺序。这种方式使得主线程能够更为有效地利用,并且在必要的时候,能够让用户交互和动画保持流畅的同时,还能继续进行后台的渲染工作。

如何切片

在原生的 JS 代码,或者其他框架中我们如何实现任务切片?

setTimeout

一种常见的过渡方法是使用时间为 0 的 setTimeout。 这种方法之所以有效,是因为传递给 setTimeout 的回调会将剩余工作转移到一个单独的任务中,这个任务将进入任务队列的最尾部,等待后续执行,这样就实现了把一大块工作分成更小的部分。

但是任务进入队列的最尾部可能不是我们期望的。

通过用户交互安排的任务会比其他任务优先级更高,它们仍会排在任务队列的前面。这意味着 setTimeout 里我们可能想尽快或必须完成的剩余任务可能会被排在它前面的其他任务进一步延迟。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Task chunking demo</title>
</head>
<body>
<button id="setinterval" tabindex="0">Run blocking tasks periodically (click me first)</button>
<button id="settimeout" tabindex="0">Run loop, yielding with <code>setTimeout</code> on each iteration</button>
<div id="task-queue"></div>
<script>
const TASK_OUTPUT = document.getElementById('task-queue')
const MAX_TASK_OUTPUT_LINES = 10
let taskOutputLines = 0
let intervalId
function blockingTask(ms = 200) {
let arr = []
const blockingStart = performance.now()
console.log(`Synthetic task running for ${ms} ms`)
while (performance.now() < blockingStart + ms) {
arr.push((Math.random() * performance.now) / blockingStart / ms)
}
}
function yieldToMain() {
return new Promise(resolve => {
setTimeout(resolve, 0)
})
}
function logTask(msg) {
if (taskOutputLines < MAX_TASK_OUTPUT_LINES) {
TASK_OUTPUT.innerHTML += `${msg}<br>`
taskOutputLines++
}
}
function clearTaskLog() {
TASK_OUTPUT.innerHTML = ''
taskOutputLines = 0
}
async function runTaskQueueSetTimeout() {
if (typeof intervalId === 'undefined') {
alert('Click the button to run blocking tasks periodically first.')
return
}
clearTaskLog()
for (const item of [1, 2, 3, 4, 5]) {
blockingTask()
logTask(`Processing loop item ${item}`)
await yieldToMain()
// await scheduler.yield()
}
}
document.getElementById('setinterval').addEventListener(
'click',
({ target }) => {
clearTaskLog()
intervalId = setInterval(() => {
if (taskOutputLines < MAX_TASK_OUTPUT_LINES) {
blockingTask()
logTask('Ran blocking task via setInterval')
}
})
target.setAttribute('disabled', true)
},
{
once: true
}
)
document.getElementById('settimeout').addEventListener('click', runTaskQueueSetTimeout)
</script>
</body>
</html>
  • 点击 Run blocking tasks periodically (click me first) 按钮,我们会打印出来一些内容,这些内容会读取到 setInterval 运行阻塞任务。
  • 点击 Run loop, yielding with setTimeout on each iteration 按钮,在每次遍历中产生 setTimeout 来模拟任务切片。

我们会得到下面这两种输出结果其中之一:

setTimeout result

输出结果交错且不确定的原因是,用户交互安排的任务会比其他任务优先级更高,因此不能确定用户点击时,浏览器的事件触发线程是否已将 setInterval 的宏任务加入到任务队列。

假设用户点击时,setInterval 已加入到宏任务队列,那么主线程 console 输出和宏任务队列中的任务执行流程如下图:

setTimeout reason1

假设用户点击时,setInterval 暂未加入到宏任务队列,那么主线程 console 输出和宏任务队列中的任务执行流程如下图:

setTimeout reason2

所以用 setTimeout 带来的任务队列结束行为意味着 来自其他任务源的工作(setInterval) 可能会 在循环任务切割之后必须完成的剩余工作(setTimeout 后面的 for 循环)之前进入队列。

task-silce

有一个 npm 库 task-slice 使用 es6 的 generator 的特性和 rAF 去实现任务切片。

初始化任务
function init({ sliceList, callback }) {
if (!isFunction(callback)) {
console.error('callback 为必传参数并为 function')
return
}
// 添加切片队列
this.generator = this.sliceQueue({
sliceList, // 数字 就是按次去切片
callback // 切片任务
})

this.next() // 开始切片
}
切片队列
function* sliceQueue({ sliceList, callback }) {
let listOrNum = (isNum(sliceList) && sliceList) || (isArray(sliceList) && sliceList.length)
for (let i = 0; i < listOrNum; ++i) {
const start = performance.now()
callback(i)
while (performance.now() - start < 16.7) {
yield
}
}
}
何时执行下一个切片任务
function next() {
const { generator } = this
const start = performance.now()
let res = null
do {
res = generator.next()
} while (!res.done && performance.now() - start < 16.7)
if (res.done) return
raf(this.next.bind(this))
}

根据上面的核心代码,我理解的作者的思路如下:

  1. 初始化任务,将长任务拆分到循环中执行,具体拆分多少个任务自己定;
  2. generator.next() 开始执行任务,如果该任务耗时小于 16.7ms,则 yield 出去,然后在 next 函数的 while 循环中继续下一个任务;
  3. 如果该任务耗时大于 16.7ms,则在 next 函数中退出 while 循环,在下一次绘制(requestAnimFrame)的时候再去执行该任务,让页面有时间渲染和响应用户交互。

scheduler.yield

scheduler.yieldChrome 115 版本开始,就作为一个实验性的 web 平台特性隐藏起来了。

使用实验性功能需要在 Chrome://flags 中开启 Experimental Web Platform features 选项。

上面 setTimeout 的方法能交出主线程控制权,实现任务切片,但是这并不是 setTimeout 的设计目标。它的核心目标是能在未来某个时间完成某个任务,所以它会把任务中的工作排在队列的最后面

但是与之相反,默认情况下,scheduler.yield 会将剩余的工作发送到队列的前面。这意味着你想要在 yield 后立即恢复的工作不会让位于其他来源的任务(用户交互除外)。

scheduler.yield 是一个向主线程进行任务切割并在调用时返回 Promise 的函数,因此可以在异步函数中等待它:

async function doWork() {
// Do some work:
// ...

await yieldToMain()

// Do some other work:
// ...
}

还是用上面 setTimeout 的 demo,我们用 scheduler.yield 来实现任务切割,将 await yieldToMain() 注释替换成 await scheduler.yield(),能看到运行结果稳定输出:

scheduler.yield result

可以看到循环(尽管它在每次遍历后生成)没有将剩余的工作发送到队列的后面,而是发送到队列的前面。

这样就可以达到两全其美的效果:既能将长任务进行分割,主动给主线程让出控制权来提高网站的交互响应速度,又能确保让出主线程后要剩余的工作不会被延迟。

基于 scheduler.yield 我们可以对上面的 yieldToMain 做些优化,兼容不支持的低版本浏览器:

// A function for shimming scheduler.yield and setTimeout:
function yieldToMain() {
// Use scheduler.yield if it exists:
if ('scheduler' in window && 'yield' in scheduler) {
return scheduler.yield()
}

// Fall back to setTimeout:
return new Promise(resolve => {
setTimeout(resolve, 0)
})
}

// Example usage:
async function doWork() {
// Do some work:
// ...

await yieldToMain()

// Do some other work:
// ...
}

参考文章