requestIdleCallback让你的页面变丝滑

2,813 阅读2分钟

当浏览器中执行一些计算密集型或耗时的任务时,这可能会导致页面响应变慢,因为当前窗口的JS线程和渲染线程(GUI)同一时间只能执行一个,如果JS线程一直在执行,那么页面就不能渲染和响应了,意味着动画停止、用户事件无法响应,详细的可以查看这篇文章,用户体验下降。requestIdleCallback可以帮助你在主线程空闲时执行一些任务,以避免阻塞用户界面。

讲解

在具体讲解之前,我们先看一个运行效果: 运行效果

我们的任务是向log框中append 1.7万+个div,来记录任务的完成情况。 核心逻辑如下:

function createData() {
        // 随机生成100--300个任务
        let n = getRandomIntInclusive(100, 300);
        allData = []
        let renderDomTotal = 0
        for (let i=0;i < n;i++) {
            // 每个任务需要写入75-150行日志
            const count = getRandomIntInclusive(75, 150)
            renderDomTotal += count
            let taskData = {
                count: count,
                text: "This text is from task number " + (i+1).toString() + " of " + n
            };
            allData.push(taskData)
        }
        // 计算需要渲染的dom,总dom:renderDomTotal个
        document.getElementById('taskNum').innerText = `需渲染${renderDomTotal.toString()}个节点`
    }

可以看到,我们的任务需要渲染的dom特别多。如果不做优化,即任务2的运行情况,直接往log节点append1.7万个div,会导致页面直接卡死,动画停止,事件无法响应,主线程阻塞,上面的gif图看的很明显,点击任务2的开始时,页面卡主接近4s,而任务1完成了同样的需求,但页面很丝滑,接下来我们讲解requestIdleCallback的用法,及具体的代码优化方案。

调用方式

requestIdleCallback(callback)
requestIdleCallback(callback, options)

参数

callback必传

一个在事件循环空闲时即将被调用的函数的引用。函数会接收到一个名为 IdleDeadline 的参数,这个参数可以获取当前空闲时间以及回调是否在超时时间前已经执行的状态。IdleDeadline的类型如下:

interface IdleDeadline {
    // 是否超时,如果任务规定了超时时间的话
    readonly didTimeout: boolean;
    // 给你的空闲时间,如果你的任务需要的时间超过这个时间,浏览器一样会卡顿
    timeRemaining(): DOMHighResTimeStamp;
}

options (可选)

可选的配置参数。具有如下属性: timeout:如果指定了 timeout,并且有一个正值,而回调在 timeout 毫秒过后还没有被调用,那么回调任务将放入事件循环中排队,即使这样做有可能对性能产生负面影响。这个时间跟上面的didTimeout息息相关。

返回值

一个 ID,可以把它传入 Window.cancelIdleCallback() 方法来结束回调。

值得注意的问题

requestIdleCallback充分利用空闲回调,旨在为代码提供一种与事件循环协作的方式,以确保系统充分利用其潜能,不会过度分配任务,从而导致延迟或其他性能问题,因此您应该考虑如何使用它,但以下几点一定要注意。

1. 对非高优先级的任务使用空闲回调,优先极高的不适合使用

已经创建了多少回调,用户系统的繁忙程度,你的回调多久会执行一次(除非你指定了 timeout),这些都是未知的。不能保证每次事件循环(甚至每次屏幕更新)后都能执行空闲回调;如果事件循环用尽了所有可用时间,那你可就倒霉了(再说一遍,除非你用了 timeout)。

2. 空闲回调应尽可能不超支分配到的时间

尽管即使你超出了规定的时间上限,通常来说浏览器、代码、网页也能继续正常运行,这里的时间限制是用来保证系统能留有足够的时间去完成当前的事件循环然后进入下一个循环,而不会导致其他代码卡顿或动画效果延迟。目前,timeRemaining() 有一个 50 ms 的上限时间,但实际上你能用的时间比这个少,因为在复杂的页面中事件循环可能已经花费了其中的一部分,浏览器的扩展插件也需要处理时间,等等

3. 避免在空闲回调中改变 DOM

空闲回调执行的时候,当前帧已经结束绘制了,所有布局的更新和计算也已经完成。如果你做的改变影响了布局,你可能会强制停止浏览器并重新计算,而从另一方面来看,这是不必要的。如果你的回调需要改变 DOM,它应该使用Window.requestAnimationFrame()来调度它

4. 避免运行时间无法预测的任务

你的空闲回调必须避免做任何占用时间不可预测的事情。比如说,应该避免做任何会影响页面布局的事情。你也必须避免 执行Promise (en-US)resolvereject,因为这会在你的回调函数返回后立即引用 Promise 对象对resolvereject的处理程序。

5. 在你需要的时候要用 timeout,但记得只在需要的时候才用

使用 timeout 可以保证你的代码按时执行,但是在剩余时间不足以强制执行你的代码的同时保证浏览器的性能表现的情况下,timeout 就会造成延迟或者动画不流畅。

总结一下:只有优先级没那么高的任务,你才用requestIdleCallback来执行,执行的任务耗时不要超过50sm,不要执行需要改变dom的任务,非要有就需要用requestAnimationFrame来调度,避免执行Promise任务,必要时,为了保证任务按时执行,可以配置timeout参数。

优化文章开头的需求

管理任务队列

function enqueueTask(taskHandler, taskData) {
// 构建任务list,放入回调和数据,这里的回调是执行写日志的操作,就是append dom
taskList.push({
    handler: taskHandler,
    data: taskData
});
// 总任务数加1
totalTaskCount++;

if (!taskHandle) {
    // 第一次,则启动我们的`requestIdleCallback`,
    //放入回调,每个任务超时时间1000ms,意味着操作1000ms还未执行,则强制执行回调
    taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000 });
}

scheduleStatusRefresh();
}

上面主要是为了构造我们的:taskHandle ,传入了我们需要执行的回调runTaskQueuetimeout

执行任务

我们的空闲回调处理方法,runTaskQueue(),将在浏览器确定有足够的可用空闲时间让我们做一些我们的工作时,或者 1 秒的timeout到期时被调用。这个方法的作用是执行队列中的任务。

// 执行任务队列中的任务
function runTaskQueue(deadline) {
  // 有空闲执行,空闲时间大于0,
  //或者已经超时了,则立即执行
  while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && taskList.length) {
      // 有空闲,一直执行,直到超时或执行完毕
      // 拿出并移除一个任务
      let task = taskList.shift();
      // 下一个任务
      currentTaskNumber++;
      // 执行这个任务,其实就是append div 到log节点
      task.handler(task.data);
      scheduleStatusRefresh();
  }
  // 没有空闲了,还有任务,则继续调度
  if (taskList.length) {
      taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000} );
  } else {
      // 没有任务了,不再递归调用,while也停止了
      console.timeEnd('task1')
      document.getElementById('taskTime1').innerText = `任务耗时:${Date.now() - taskTime1}ms`
      taskHandle = 0;
  }
}

runTaskQueue()的核心是一个循环,只要有剩余时间(通过检查IdleDeadline.timeRemaining来确认它大于 0),或者已经达到了 timeout 期限(deadline.didTimeout (en-US)值为真),且任务列表中有任务就会一直持续。

对队列中每个我们有时间执行的任务,我们做以下操作:

  1. 把任务对象(object)从队列中移除。
  2. 我们让currentTaskNumber增加来追踪我们已执行的任务数量。
  3. 我们调用任务处理方法,task.handler,并任务的数据对象(task.data)传入其中。
  4. 我们调用一个方法,scheduleStatusRefresh(),去处理调度一个屏幕更新来体现我们进度的变化。 没有空闲了,还有任务,则继续调度requestIdleCallback。直到taskList.length为空,则代表所有任务执行完毕。

更新状态显示

我们想要能够做的一件事是根据记录输出和进度信息来更新文档。然后在空闲回调中改变 DOM 是不安全的。作为替代,我们使用 requestAnimationFrame() 来让浏览器在可以安全地更新显示时通知我们。

function scheduleStatusRefresh() {
  if (!statusRefreshScheduled) {
    // 更新进度条,appendDom并且滚
    //动到最后,使用requestAnimationFrame是为
    //了优化渲染(不是一直
    //渲染,与浏览器刷新率保持一直就好了)
    requestAnimationFrame(updateDisplay)
    statusRefreshScheduled = true;
  }
}

更新显示

updateDisplay()函数负责绘制进度框的内容和记录。当 DOM 的状况安全,我们可以在下次渲染过程中申请改变时,浏览器会调用它。

// 更新页面显示
function updateDisplay() {
  let isScrolledToEnd = logElem.scrollHeight - logElem.clientHeight <= logElem.scrollTop + 1;
  if (totalTaskCount) {
    // 更新最大的进度值和count的值
    if (progressBarElem.max != totalTaskCount) {
        totalTaskCountElem.textContent = totalTaskCount;
        progressBarElem.max = totalTaskCount;
    }
    // 更新当前执行的任务数和进度
    if (progressBarElem.value != currentTaskNumber) {
        currentTaskNumberElem.textContent = currentTaskNumber;
        progressBarElem.value = currentTaskNumber;
    }
  }
  // 有未添加的日志,则添加到页面
  if (logFragment) {
    logElem.appendChild(logFragment);
    logFragment = null;
  }
  // 没有滚动到最先则滚动到最下面
  if (isScrolledToEnd) {
    logElem.scrollTop = logElem.scrollHeight - logElem.clientHeight;
  }

  statusRefreshScheduled = false;
}

向记录添加文本

log() 函数可以向记录中添加指定的文本。因为我们不知道调用 log() 的时候是否可以立即安全地更新 DOM,我们将缓存记录文本一直到可以安全更新。在上面,在 updateDisplay() 的代码中,你可以找到更新动画帧时,实际添加记录的代码。

function log(text) {
  if (!logFragment) {
      logFragment = document.createDocumentFragment();
  }

  let el = document.createElement("div");
  el.innerHTML = text;
  logFragment.appendChild(el);
}

首先,如果当前不存在一个名为logFragmentDocumentFragment 对象。该元素是伪 DOM,我们可以在其中插入元素,而无需立即更改主 DOM 本身。

然后我们创建一个新的元素,并将其内容设置为与输入文本匹配。接下来我们向logFragment中的伪 DOM 末尾添加一个新的元素。logFragment将会累积记录条目直到下次因 DOM 改变而调用updateDisplay()的时候。

任务处理器

logTaskHandler(),将是我们用来作为任务处理器的函数,也是用作任务对象handler属性的值。它是一个简单的为每个任务向记录输出大量内容的函数。

// 执行任务的处理函数
function logTaskHandler(data) {
  log("<strong>Running task #" + currentTaskNumber + "</strong>");

  for (let i=0; i<data.count; i+=1) {
      log((i+1).toString() + ". " + data.text);
  }
}

兼容性

兼容性 可以看大,Safari是全军覆没的,不过没有有关系,我们同样可以模拟实现一个requestIdleCallback

// 定义 requestIdleCallback 的兼容处理,不执行则用setTimeout模拟实现
    window.requestIdleCallback = window.requestIdleCallback || function(handler) {
        // 闭包,创建的时候记录一下时间
        let startTime = Date.now();
        return setTimeout(function() {
            handler({
                didTimeout: false,
                timeRemaining: function() {
                    // 理论上系统给你空闲的时间会低于50ms,所以你的任务最好不要超过50ms,否则还是会卡顿
                    return Math.max(0, 50.0 - (Date.now() - startTime));
                }
            });
        }, 1);
    };

上面的代码使用setTimeout模拟了一个requestIdleCallback,可以看出,当闭包startTime记录的时间和执行的时间大于50ms,则timeRemaining()返回0,则代表我们的回调还是要马上执行。不过值得注意的是它并不是完全的polyfill.它只是让你的代码延时执行来达到缓解浏览器压力的效果,它并不能准确的在浏览器空闲时执行我们的回调。

总结

使用requestIdleCallback优化我们的程序,不用像其他常规方法一样,比如说减少频繁append div。而是拼接起来,如:

let html = ''
for(let i = 0; i< len; i++){
  html+='<div>log</div>'
}
elm.append(html)

然后再一起append。这样可以减少浏览器的渲染次数,大大减少卡顿。而requestIdleCallback它会在适当的时候告诉你浏览器有空闲了,你可以执行你的任务了,但是如果你的任务有操作dom,你需要把它放到requestAnimationFrame中。

另一种优化方式是使用webworker来优化,但是webworker有很多局限,不能操作dom,不能直接与主进程通讯,需要通过事件的方式,详细的可以查看这篇文章

完整的代码我贴到下面了,比较长,关键的地方我都写了注释,可以直接拷贝到html文件运行: 在线运行

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        body{font-family:"Open Sans","Lucida Grande",Arial,sans-serif;font-size:16px}.task{display:inline-block;vertical-align:top}.logBox{margin-top:16px;width:400px;height:300px;border-radius:6px;border:1px solid #000;box-shadow:4px 4px 2px #000}.logHeader{margin:0;padding:0 6px 4px;height:22px;background-color:#add8e6;border-bottom:1px solid #000;border-radius:6px 6px 0 0}#log,#log1{font:12px Courier,monospace;padding:6px;overflow:auto;overflow-y:scroll;width:388px;height:260px}.container{width:400px;padding:6px;border-radius:6px;border:1px solid #000;box-shadow:4px 4px 2px #000;display:block;overflow:auto}.label{display:inline-block}.counter{text-align:right;padding-top:4px;float:right}.button{padding-top:2px;padding-bottom:4px;width:180px;display:inline-block;border:1px solid #000;cursor:pointer;text-align:center;margin-top:0;color:#fff;background-color:#006400}progress{width:100%;padding-top:6px}.animations-box{display:inline-block;width:100px;height:100px;animation:bounce 1s linear 0s infinite alternate;background-image:linear-gradient(45deg,#3023ae 0,#f09 100%)}@keyframes bounce{0%{border-radius:40% 60% 72% 28%/70% 77% 23% 30%}100%{border-radius:25% 25% 24% 76%/13% 15% 85% 87%}}
    </style>
</head>
<body>
<div class="head">
    <span>
        协作调度幕后任务 </a> 使用 <code>requestIdleCallback()</code>
        方法。
    </span>
    <div class="animations-box"></div>
</div>
<div class="button" style="margin-bottom: 20px;" id="createData">
    生成数据
</div>
<span id="taskNum"></span>
<div class="box">
    <div class="task task1">
        <div class="container">
            <div class="label">任务1:requestIdleCallback()优化实现,<span style="color: green">页面流畅</span></div>
            <progress id="progress" value="0"></progress>
            <div class="button" id="startButton">
                开始 <span id="taskTime1"></span>
            </div>
            <div class="label counter">
                <span id="currentTaskNumber">0</span> / <span id="totalTaskCount">0</span>
            </div>
        </div>

        <div class="logBox">
            <div class="logHeader">
                记录
            </div>
            <div id="log">
            </div>
        </div>
    </div>
    <div class="task other">
        <div class="container">
            <div class="label">任务2:无任何优化,直接appendDom,<span style="color: red">页面卡顿</span></div>
            <progress id="progress1" value="0"></progress>
            <div class="button" id="startButton1">
                开始 <span id="taskTime2"></span>
            </div>
            <div class="label counter">
                <span id="currentTaskNumber1">0</span> / <span id="totalTaskCount1">0</span>
            </div>
        </div>

        <div class="logBox">
            <div class="logHeader">
                记录
            </div>
            <div id="log1">
            </div>
        </div>
    </div>
</div>
</body>
<script>
    // 声明变量和标记
    let taskTime1 = 0
    let taskTime2 = 0
    let allData = []
    let taskList = [];
    let totalTaskCount = 0;
    let currentTaskNumber = 0;
    let taskHandle = null;
    // 获取需要操作的 DOM 元素
    let totalTaskCountElem = document.getElementById("totalTaskCount");
    let currentTaskNumberElem = document.getElementById("currentTaskNumber");
    let progressBarElem = document.getElementById("progress");
    let startButtonElem = document.getElementById("startButton");
    let logElem = document.getElementById("log");

    let logFragment = null;
    let statusRefreshScheduled = false;
    // 定义 requestIdleCallback 的兼容处理,不执行则用setTimeout模拟实现
    window.requestIdleCallback = window.requestIdleCallback || function(handler) {
        // 闭包,创建的时候记录一下时间
        let startTime = Date.now();
        return setTimeout(function() {
            handler({
                didTimeout: false,
                timeRemaining: function() {
                    // 理论上系统给你空闲的时间会低于50ms,所以你的任务最好不要超过50ms,否则还是会卡顿
                    return Math.max(0, 50.0 - (Date.now() - startTime));
                }
            });
        }, 1);
    };
    // 取消任务
    window.cancelIdleCallback = window.cancelIdleCallback || function(id) {
        clearTimeout(id);
    };
    // 将任务加入任务队列
    function enqueueTask(taskHandler, taskData) {
        // 构建任务list,放入回调和数据,这里的回调是执行写日志的操作,就是append dom
        taskList.push({
            handler: taskHandler,
            data: taskData
        });
        // 总任务数加1
        totalTaskCount++;

        if (!taskHandle) {
            // 第一次,则启动我们的`requestIdleCallback`,放入回调,每个任务超时时间1000ms,意味着操作1000ms还未执行,则强制执行回调
            taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000 });
        }

        scheduleStatusRefresh();
    }
    // 执行任务队列中的任务
    function runTaskQueue(deadline) {
        // 有空闲执行,空闲时间大于0,或者已经超时了,则立即执行
        while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && taskList.length) {
            // 有空闲,一直执行,直到超时或执行完毕
            // 拿出并移除一个任务
            let task = taskList.shift();
            // 下一个任务
            currentTaskNumber++;
            // 执行这个任务,其实就是append div 到log节点
            task.handler(task.data);
            scheduleStatusRefresh();
        }
        // 没有空闲了,还有任务,则继续调度
        if (taskList.length) {
            taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000} );
        } else {
            // 没有任务了,不再递归调用,while也停止了
            console.timeEnd('task1')
            document.getElementById('taskTime1').innerText = `任务耗时:${Date.now() - taskTime1}ms`
            taskHandle = 0;
        }
    }
    // 定时刷新任务状态
    function scheduleStatusRefresh() {
        if (!statusRefreshScheduled) {
            // 更新进度条,appendDom并且滚动到最后,使用requestAnimationFrame是为了优化渲染(不是一直渲染,与浏览器刷新率保持一直就好了)
            requestAnimationFrame(updateDisplay)
            statusRefreshScheduled = true;
        }
    }
    // 更新页面显示
    function updateDisplay() {
        let isScrolledToEnd = logElem.scrollHeight - logElem.clientHeight <= logElem.scrollTop + 1;

        if (totalTaskCount) {
            // 更新最大的进度值和count的值
            if (progressBarElem.max != totalTaskCount) {
                totalTaskCountElem.textContent = totalTaskCount;
                progressBarElem.max = totalTaskCount;
            }
            // 更新当前执行的任务数和进度
            if (progressBarElem.value != currentTaskNumber) {
                currentTaskNumberElem.textContent = currentTaskNumber;
                progressBarElem.value = currentTaskNumber;
            }
        }
        // 有未添加的日志,则添加到页面
        if (logFragment) {
            logElem.appendChild(logFragment);
            logFragment = null;
        }
        // 没有滚动到最先则滚动到最下面
        if (isScrolledToEnd) {
            logElem.scrollTop = logElem.scrollHeight - logElem.clientHeight;
        }

        statusRefreshScheduled = false;
    }
    // 在日志区域添加日志
    function log(text) {
        if (!logFragment) {
            logFragment = document.createDocumentFragment();
        }

        let el = document.createElement("div");
        el.innerHTML = text;
        logFragment.appendChild(el);
    }
    // 执行任务的处理函数
    function logTaskHandler(data) {
        log("<strong>Running task #" + currentTaskNumber + "</strong>");

        for (let i=0; i<data.count; i+=1) {
            log((i+1).toString() + ". " + data.text);
        }
    }
    // 生成随机整数
    function getRandomIntInclusive(min, max) {
        min = Math.ceil(min);
        max = Math.floor(max);
        return Math.floor(Math.random() * (max - min + 1)) + min;
    }


    function updateTask2() {
        if (!allData.length) {
            return alert('请先生成数据')
        }
        const start = Date.now()
        // 直接写dmo,并且更新进度
        let totalTaskCountElem = document.getElementById("totalTaskCount1");
        let currentTaskNumberElem = document.getElementById("currentTaskNumber1");
        let progressBarElem = document.getElementById("progress1");
        const count = allData.length
        progressBarElem.max = count
        totalTaskCountElem.textContent = count.toString()
        let logElem = document.getElementById("log1");
        for (let j = 0; j < count; j++) {
            let el = document.createElement("div");
            // 更新demo
            progressBarElem.value = j + 1
            // 更新demo
            currentTaskNumberElem.textContent = (j + 1).toString()
            el.innerHTML = "<strong>Running task #" + (j + 1) + "</strong>";
            // 更新demo
            logElem.appendChild(el)
            for (let i= 0; i < allData[j].count;i++) {
                let el = document.createElement("div");
                el.innerHTML = allData[j].text;
                // 更新demo
                logElem.appendChild(el);
                // 滚动到最底下,每append一个则滚动一次,如果在这里执行滚动,直接浏览器卡死
                // let isScrolledToEnd = logElem.scrollHeight - logElem.clientHeight <= logElem.scrollTop + 1;
                // if (!isScrolledToEnd) {
                //     logElem.scrollTop = logElem.scrollHeight - logElem.clientHeight;
                // }
            }
            let isScrolledToEnd = logElem.scrollHeight - logElem.clientHeight <= logElem.scrollTop + 1;
            if (!isScrolledToEnd) {
                logElem.scrollTop = logElem.scrollHeight - logElem.clientHeight;
            }
        }
        taskTime2 = Date.now() - start
        document.getElementById('taskTime2').innerText = `任务耗时:${taskTime2}ms`
    }

    function createData() {
        totalTaskCount = 0;
        currentTaskNumber = 0;
        document.getElementById('taskTime1').innerText = ''
        document.getElementById('taskTime2').innerText = ''
        updateDisplay();
        let n = getRandomIntInclusive(100, 300);
        allData = []
        let renderDomTotal = 0
        for (let i=0;i < n;i++) {
            const count = getRandomIntInclusive(75, 150)
            renderDomTotal += count
            let taskData = {
                count: count,
                text: "This text is from task number " + (i+1).toString() + " of " + n
            };
            allData.push(taskData)
        }
        // 计算需要渲染的dome
        document.getElementById('taskNum').innerText = `需渲染${renderDomTotal.toString()}个节点`
    }
    // 解码技术内容
    function decodeTechnoStuff() {
        if (!allData.length) {
            return alert('请先生成数据')
        }
        console.time('task1')
        taskTime1 = Date.now()
        for (let i = 0; i < allData.length; i++) {
            enqueueTask(logTaskHandler, allData[i]);
        }
    }
    // 绑定按钮点击事件
    document.getElementById("createData").addEventListener("click", createData, false);
    document.getElementById("startButton").addEventListener("click", decodeTechnoStuff, false);
    document.getElementById("startButton1").addEventListener("click", updateTask2, false);
</script>
</html>

如果你觉得这篇文章对你有帮助,可以关注我的公众号:程序员每日三问。每天向你推送面试题,算法及干货,期待你的点赞和关注。