前言
最近在工作中遇到这样一个场景:
相同的产品模块,其他账号登录进来用户体验上一切正常,但有一个客户账号进入后,总会有 3 ~ 5 S 的卡顿,也就是说这段时间页面点击是没有任何响应的。这对于用户体验肯定无法接受。
经过排查定位分析后,发现造成这一情况的根本原因是这里接入了产品的 IM 系统,而这个用户的花名册 List 非常之多,导致业务代码中根据花名册 List 在做 DOM 操作时耗时非常长(同步操作 - 阻塞)。
这就导致 JS 代码占据了浏览器执行线程,从而出现页面卡顿、操作无响应等情况。
阅读过 React
源码的同学相信都了解过 requestIdleCallback
,笔者也是基于它作为解决方案。
requestIdleCallback
在了解 requestIdleCallback
前需要先铺垫一些前置知识。
-
屏幕刷新率: 大多数液晶屏设备的频率是 60 次/秒,每一次代表了一帧的绘制,每一帧分到的时间是 1000/60 ≈ 16 ms,当每秒绘制的帧数(FPS)达到 60 时,页面是流畅的,小于这个值时,用户会感觉到卡顿,所以在写代码时力求不让一帧的工作量超过 16 ms。
-
帧: 一个帧(16.6 ms)的工作流程包含:
- 输入事件(如:阻塞 - touch、非阻塞 - click)--> 2) 处理 JS 定时器 --> 3) 处理开始帧对应的事件(如:window.resize、scroll)--> 4) rAF回调(请求动画渲染) --> 5) 页面布局(样式计算、更新布局) --> 6) 进行绘制。
六个阶段完成之后进入空闲阶段(requestIdleCallback)。
现在我们知道:requestIdleCallback
会在渲染每一帧的剩余空闲时间执行,它能够根据浏览器是否空闲来执行一些操作。
假如,如果这一帧的 6 个任务(优先级最高)执行完毕后,用了 11 ms,浏览器会将剩余的 5 ms 控制权交由 requestIdleCallback 执行回调任务。
如果第一个回调任务执行完毕后还有剩余时间,会继续执行下一个;如果这一个回调任务执行时间要比 5 ms 长,等这个任务执行完毕后归还给浏览器控制权,并申请下一个时间片,浏览器也继续执行下一帧的任务。
这种调度方式叫做合作式调度,是让浏览器相信用户写的代码,如果客户端或者说用户写代码的时候,执行时间超过给的剩余时间,浏览器没有办法,只能卡死在那里等待执行完成。
下面,我们通过一个 Demo 来感受一下 requestIdleCallback
所带来的体验。
1、示例
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<button id="btn1">按钮1</button>
<button id="btn2">按钮2</button>
<script>
const dataLength = 4000;
const start = Date.now();
// 同步阻塞方式
for (let i = 0; i < dataLength; i ++) {
for (let j = 0; j < dataLength; j ++) {
// DOM 操作严重影响程序执行效率
const btn1Attr = document.getElementById('btn1').attributes;
const btn2Attr = document.getElementById('btn2').attributes;
const btn3Attr = document.getElementById('btn1').attributes;
const btn4Attr = document.getElementById('btn2').attributes;
}
}
console.log('循环结束用时:', Date.now() - start);
</script>
</body>
</html>
复制代码
页面上有两个 button 按钮,脚本中会进行两层遍历来模拟代码的执行时长。其中,对 DOM 的操作是非常耗时的,这里我们加入操作 DOM 逻辑用于延长执行时间。
此时打开页面,你会发现页面很卡且 button 按钮都无法点击。大约等待 3s 后 JS 代码执行完毕,页面才能够正常操作。
2、空闲调度
下面,我们通过 requestIdleCallback
对其优化(或许可以封装一个 SDK 方法)。
实现核心:将一段非常耗时的执行程序,拆分成多个片段等待空闲调度执行。
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<button id="btn1">按钮1</button>
<button id="btn2">按钮2</button>
<script>
const dataLength = 4000;
const start = Date.now();
// 异步空闲调度方式
function idleSchedule({
scheduleTotal,
callback,
onComplete,
name,
logs = false,
}) {
const logName = name ? `「${name}」` : '';
const start = Date.now();
let workIndex = 0;
function handleWork() {
callback(workIndex);
workIndex ++;
if (workIndex >= scheduleTotal) {
logs && console.log(`${logName}任务调度完成,用时:`, Date.now() - start, 'ms!');
onComplete();
}
}
function workLoop(deadline) {
while (deadline.timeRemaining() > 0 && workIndex < scheduleTotal) {
handleWork();
}
if (workIndex < scheduleTotal) {
window.requestIdleCallback(workLoop);
}
}
if (workIndex < scheduleTotal) {
logs && console.log(`${logName}开始在空闲时间调度任务!`);
window.requestIdleCallback(workLoop);
} else {
logs && console.log(`${logName}无可调度任务!`);
onComplete();
}
}
function callback(workIndex) {
for (let j = 0; j < dataLength; j ++) {
// DOM 操作严重影响程序执行效率
const btn1Attr = document.getElementById('btn1').attributes;
const btn2Attr = document.getElementById('btn2').attributes;
const btn3Attr = document.getElementById('btn1').attributes;
const btn4Attr = document.getElementById('btn2').attributes;
}
}
idleSchedule({
scheduleTotal: dataLength,
callback,
onComplete: () => console.log('后续逻辑处理!'),
logs: true,
});
</script>
</body>
</html>
复制代码
此时,你会在控制台看到输出:
开始在空闲时间调度任务!
任务调度完成,用时: 3037 ms!
后续逻辑处理!
复制代码
若在这期间你不断去操作页面按钮时,你会发现任务的完成时间会相应延长。这就体现了 requestIdleCallback
空闲调度的特性。
requestAnimationFrame
还有一个长得很像(容易搞混)的方法是 requestAnimationFrame
,它也运行在每一帧的流程中。
不过,它在每一帧的周期中只会执行一次,且发生在页面绘制之前,一般可以用来操作 DOM 处理动画。
<!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>
<div id="progress-bar" style="background: lightblue; width: 0; height: 20px;"></div>
<button id="btn">开始</button>
<script>
// 在页面上绘制一个0%-100%的进度条
let btn = document.getElementById('btn');
let oDiv = document.getElementById('progress-bar');
let start;
function progress() {
oDiv.style.width = oDiv.offsetWidth + 1 + 'px';
oDiv.innerHTML = (oDiv.offsetWidth) + '%';
if (oDiv.offsetWidth < 100) {
let current = Date.now();
console.log(current - start); // 上一帧与当前这一帧执行rAF函数之间的毫秒数,平均 16.6ms
start = current;
requestAnimationFrame(progress);
}
}
btn.addEventListener('click', () => {
oDiv.style.width = 0; // 每次点击将宽度清零
start = Date.now(); // 获取当前时间的毫秒数
requestAnimationFrame(progress);
});
</script>
</body>
</html>
复制代码
最后
感谢阅读。