阅读 414

详解 requestIdleCallback

为什么需要 requestIdleCallback ?

在网页中,有许多耗时但是却又不能那么紧要的任务。它们和紧要的任务,比如对用户的输入作出及时响应的之类的任务,它们共享事件队列。如果两者发生冲突,用户体验会很糟糕。我们可以使用setTimout,对这些任务进行延迟处理。但是我们并不知道,setTimeout在执行回调时,是否是浏览器空闲的时候。

而requestIdleCallback就解决了这个痛点,requestIdleCallback会在帧结束时并且有空闲时间。或者用户不与网页交互时,执行回调。

requestIdleCallback API简介

  • requestIdleCallback的第一个参数时callback
    • 当callback被调用时,回接受一个参数 deadline,deadline是一个对象,对象上有两个属性
      • timeRemaining,timeRemaining属性是一个函数,函数的返回值表示当前空闲时间还剩下多少时间
      • didTimeout,didTimeout属性是一个布尔值,如果didTimeout是true,那么表示本次callback的执行是因为超时的原因
  • requestIdleCallback的第二个参数是options
    • options是一个对象,可以用来配置超时时间

requestIdleCallback((deadline) => {
    // deadline.timeRemaining() 返回当前空闲时间的剩余时间
    if (deadline.timeRemaining() > 0) {
        task()
    }
}, {
    timeout: 500
})
复制代码

空闲时间

requestIdleCallback 的callback会在浏览器的空闲时间运行,那么什么是空闲时间呢?

空闲时间1.png

如上图。当我们在执行一段连续的动画的时候,第一帧已经渲染到屏幕上了,到第二帧开始渲染,这段时间内属于空闲时间。这种空闲时间会非常的短暂,如果我们的屏幕是60hz(1s内屏幕刷新60次)的。那么空闲时间会小于16ms(1000ms / 16)。

空闲时间2.png

另外一种空闲时间,当用户属于空闲状态(没有与网页进行任何交互),并且没有屏幕中也没有动画执行。此时空闲时间是无限长的。但是为了避免不可预测的事(用户突然和网页进行交互),空闲时间最大应该被限制在50ms以内。

为什么最大是50ms?人类对100ms内的响应会认为是瞬时的。将空闲时间限制在50ms以内,是为了避免,空闲时间内执行任务,从而导致了对用户操作响应的阻塞,使用户感到明显的响应滞后。

在空闲期间,callback的执行顺序是以FIFO(先进先出)的顺序。但是如果在空闲时间内依次执行callback时,有一个callback的执行时间,已经将空闲时间用完了,剩下的callback将会在下一次的空闲时间执行。

const task1 = () => console.log('执行任务1')
const task2 = () => console.log('执行任务2')
const task3 = () => console.log('执行任务3')

// console
// 执行任务1
// 执行任务2
// 执行任务3
requestIdleCallback(task1)
requestIdleCallback(task2)
requestIdleCallback(task3)
复制代码

如果当前的任务所需要的执行时间,超过了当前空闲时间周期内的剩余时间,我们也可以将任务带到下一个空闲时间周期内执行。在下一个空闲周期开始后,新添加的callback会被添加到callback列表的末尾。


const startTask = (deadline) {
    // 如果 `task` 花费的时间是20ms
    // 超过了当前空闲时间的剩余毫秒数,我们等到下一次空闲时间执行task
    if (deadline.timeRemaining() <= 20) {
        // 将任务带到下一个空闲时间周期内
        // 添加到下一个空闲时间周期callback列表的末尾
        requestIdleCallback(startTask)
    } else {
        // 执行任务
        task()
    }
}
复制代码

当我们网页处于不可见的状态时(比如切换到其他的tag),我们空闲时间将会每10s, 触发一次空闲期。

timeout

如果指定了timeout,但是浏览器没有在timeout指定的时间内,执行callback。在下次空闲时间时,callback会强制执行。并且callback的参数,deadline.didTimeout等于true, deadline.timeRemaining()返回0。


requestIdleCallback((deadline) => {
    // true
    console.log(deadline.didTimeout)
}, {
    timeout: 1000
})

// 这个操作大概花费5000ms
for (let i = 0; i < 3000; i++) {
    document.body.innerHTML = document.body.innerHTML + `<p>${i}</p>`
}
复制代码

requestIdleCallback实践:在requestIdleCallback中打点

使用requestIdleCallback延迟数据的上报,可以避免一些渲染阻塞。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <input type="text" id="text" />
</body>
<script>
    const datas = []
    const text = document.getElementById('text')
    let isReporting = false

    function sleep (ms = 100) {
        let sleepSwitch = true
        let s = Date.now()
        while (sleepSwitch) {
            if (Date.now() - s > ms) {
                sleepSwitch = false
            }
        } 
    }
    function handleClick () {
        datas.push({
            date: Date.now()
        })
        // 监听用户响应的函数,需要花费150ms
        sleep(150)
        handleDataReport()
    }

    // =========================  使用requestIdleCallback  ==============================

    function handleDataReport () {
        if (isReporting) {
            return
        }
        isReporting = true
        requestIdleCallback(report)
    }

    function report (deadline) {
        isReporting = false
        while (deadline.timeRemaining() > 0 && datas.length > 0) {
            get(datas.pop())
        }
        if (datas.length) {
            handleDataReport()
        }
    }

    // =========================  使用requestIdleCallback结束  ==============================

    function get(data) {
        // 数据上报的函数,需要话费20ms
        sleep(20)
        console.log(`~~~ 数据上报 ~~~: ${data.date}`)
    }

    text.oninput = handleClick
</script>
</html>
复制代码

QQ20200304-173611-HD.gif

而如果不使用 requestIdleCallback , 直接进行数据上报,会直接卡死主线程,影响到浏览器的渲染。


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <input type="text" id="text" />
</body>
<script>
    const datas = []
    const text = document.getElementById('text')
    let isReporting = false

    function sleep (ms = 100) {
        let sleepSwitch = true
        let s = Date.now()
        while (sleepSwitch) {
            if (Date.now() - s > ms) {
                sleepSwitch = false
            }
        } 
    }
    function handleClick () {
        datas.push({
            date: Date.now()
        })
        // 监听用户响应的函数,需要花费150ms
        sleep(150)
        handleDataReport()
    }

    // =========================  不使用requestIdleCallback  ==============================

    function handleDataReport () {
        if (isReporting) {
            return
        }
        isReporting = true
        report()
    }

    function report (deadline) {
        isReporting = false
        while (datas.length > 0) {
            get(datas.pop())
        }
        if (datas.length) {
            handleDataReport()
        }
    }

    // =========================  不使用requestIdleCallback结束  ==============================

    function get(data) {
        // 数据上报的函数,需要话费20ms
        sleep(20)
        console.log(`~~~ 数据上报 ~~~: ${data.date}`)
    }

    text.oninput = handleClick
</script>
</html>
复制代码

QQ20200304-175859-HD.gif

原因分析:

如果使用了requestIdleCallback:

监听事件处理 --> 页面渲染 --> 数据上报(空闲时) --> 监听事件处理 --> 页面渲染 --> 数据上报(空闲时)

如果不使用requestIdleCallback:

监听事件处理 --> 数据上报(被添加到主线程中) --> 监听事件处理 --> 数据上报(被添加到主线程中) --> 监听事件处理 --> 数据上报(被添加到主线程中) --> 页面渲染

常见Q&A

Q1: requestIdleCallback 会在每一次帧结束时执行吗?

A1: 只会在帧末尾有空闲时间时会执行,不应该期望每一次帧结束都会执行requestIdleCallback。

😂😂😂😂😂😂😂😂😂😂😂😂😂😂😂😂😂😂😂😂

Q2: 什么操作不适合放到 requestIdleCallback 的callback中。

A2: 更新DOM,以及Promise的回调(会使帧超时),什么意思?请看下面的代码。requestIdleCallback中代码,应该是一些可以预测执行时间的小段代码。


// console
// 空闲时间1
// 等待了1000ms
// 空闲时间2
// Promise 会在空闲时间1接受后立即执行,即使没有空闲时间了也是如此。拖延了进入下一帧的时间

requestIdleCallback(() => {
    console.log('空闲时间1')
    Promise.resolve().then(() => {
        sleep(1000)
        console.log('等待了1000ms')
    })
})

requestIdleCallback(() => {
    console.log('空闲时间2')
})
复制代码

参考