一、前言
大家好,我是story,一名react开发者,今天通过这篇文章来,我们来聊聊大数据渲染场景下的解决方案;
在正式讲解主题之前,我还是希望来通过聊聊react的背景来慢慢引出主题,在从事react开发的过程中,我们也许都有这样一个共识: React在进入16版本之后将自身升级到了Fiber架构,几乎是重写了原来15版本的虚拟DOM架构;
那为什么会这样呢?
二、初识fps
这里我们需要聊一个很重要的基础知识:关于浏览器的帧率,然后揭晓答案!
大家现在应该正在使用浏览器观看我现在这篇文章,但是我需要跟各位分享的是,其实在过去的每一秒钟,我们的浏览器都会刷新很多次,那到底是多少次呢?
根据我掌握的资料,每个浏览器都不太一样,最少可能是30多次,有的50多次,根据我自己在chrome上的查看(command + shift + p)后输入fps,似乎每时每刻还不一样,但是大体都接近60次每秒;所以,为了方便研究,一般的,我们也可以认为对于现代浏览器而言,浏览器的刷新频率,我们称之为帧率为60fps; 在这样快速的频率下,我们人眼才会感知到流畅的视觉体验,如动画、视频、交互...
总结:每16.6ms我们的浏览器都会绘制/刷新一次;
那有什么样的情况会使得这样顺畅的体验会被打破呢?
答案就是js引擎;在浏览器的世界里,有两个独立的线程,一个是JS引擎线程,一个是GUI绘制线程,而上述我们所讲述的绘制和fps概念的产生就是由GUI线程去做的;但是一山不容二虎,在浏览器的世界里,他们两个关系是互斥的;
因此在同一时刻,JS引擎在做事情的时候,GUI就不能做事情,GUI在在绘制的过程中,JS引擎就不能做事情;
我们来通过下面这个例子来证明这一点:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
.box {
width: 100px;
height: 100px;
background: red;
animation: normal-animate 5s linear infinite;
margin: 50px 0;
position: absolute;
}
@keyframes normal-animate {
0% {
left: 0px;
}
50% {
left: 100px;
}
100% {
left: 0px;
}
}
</style>
</head>
<body>
<h1>时间切片案例</h1>
<p>
<span>input事件测试</span>
<input />
</p>
<button onclick="sync()">同步</button>
<div class="box"></div>
<script>
let index = 0;
const sync = () => {
let now = performance.now(); /* 开始时间*/
while (performance.now() - now <= 5000) {
// 必须执行5秒种,且是同步
index++;
}
};
</script>
</body>
</html>
通过上面的例子,我们可以看到他们的确是互斥的,当我们调用一个同步函数时,浏览器仿佛被冻住了一般,对事件都失去了敏感,因此我们可以慢慢开始回答上面提出的问题,为什么React重写了原来的方案,改为fiber架构;
那是因为对于一个庞大的项目来说由于节点太多,如果采用原来15版本的方案,使用递归的话,则等价于一个庞大的同步任务,那就相当于上面例子中的sync函数一样,无疑会阻塞GUI的绘制,这个时候就会导致卡顿的问题;因为有这个问题,所以react需要重新思考新的方案,而fiber架构就是新的方案;
三、纤细的时光
聊完fps和react重写的原因,我来引出一个重要的问题;上面我们提到了每一个16.6ms的时间段我们称之为一帧,那在这一帧中,浏览器做了哪些事情呢?
我们先来看一张图!
这张图详细的展示了每一帧中浏览器做了哪些事情;我总结一下,他们分别是;
- 事件处理/event
- js脚本执行
- scroll/resize 事件
- requestAnimationFrame
- 布局/绘制
对于每一帧而言他们都会做至少以上5件事情,包括这个叫做requestAnimationFrame的api,我们可以验证一下:
温馨提示:在接下来的案例中,为了节省空间,保持更好阅读体验,永远都以第一个案例中的单页html为蓝本,只展示增量代码,不展示全量代码
let index = 0;
const RAF = () => {
let now = performance.now(); /* 开始时间*/
const task = (handle) => {
console.log("执行")
index++
if (performance.now() - now <= 5000) {
requestAnimationFrame(task)
}else{
console.log(`在5秒内执行了${index}次,本次实验帧率为${ index/5 }fps`);
}
};
requestAnimationFrame(task)
};
演示:
通过上面的例子我们可以论证一帧的帧率基本都是接近60fps的,包括使用requestAnimationFrame,由于它是在绘制之前执行的,通过使用它,相较于setTimeout,我们可以得到更好的动画效果,这个内容由于并非今天的核心内容,我们之后的文章聊;
我们接下来聊一个更重要的话题,在一帧的16.6ms过程中,我们都知道需要执行一段js脚本,但是如果执行js我们就绕不开一个问题,那就是事件循环,我们都知道在js执行的过程中,可能会产生无数个事件循环;如下图所示:
不知道聪明的你有没有这样的一个疑问:对于每一帧(16.6ms)而言,这里面的js脚本执行是执行确定的一个loop呢?还是多个loop呢?如果是多个loop,那到底是多少个呢?可以确定么?
别急,我们慢慢来聊!
其实我们可以通过一个实验来验证,我们知道有一个创建宏任务的经典api叫做setTimeout,在这里面注册的回调都是宏任务,因此我们可以对比一下,使用setTimeout和requestAnimationFrame在执行同样的时间的情况下,谁消费的函数帧更多一些;于是我们写出下面的代码:
let index = 0;
const setTimeFn = () => {
let now = performance.now(); /* 开始时间*/
const task = (handle) => {
console.log("执行")
index++
if (performance.now() - now <= 5000) {
setTimeout(task)
}else{
console.log(`在5秒内执行了${index}次,比RAF的300次要${ index > 300 ? "多":"少" }`);
}
};
setTimeout(task)
};
// 只是单纯的替换了宏任务,其他逻辑不变;
演示
我这边测试的结果是setTimeout消费的函数次数是requestAnimationFrame的3倍还多一点;因此我们可以分析一下:
由于requestAnimationFrame是确定的每一帧会执行一次,因此根据上述测试结果,我们可以大胆的推断,在一个帧当中,eventLoop一定是执行了多次,因为只有这样,才可以得到上述实验的结果。
而这个多次到底是几次也并不是确定的,浏览器会根据单次宏任务的时长自己去进行调度和调配;
总结:在每一帧的js脚本执行过程中,时间循环会执行不确定的n次,而不是一次,这段时间我们可以亲切的称其为 纤细的时光
四、渐进式
好了,前面铺垫了这么多,该点一下题了,否则各位可能以为是标题党了,我们继续聊!
本文要讨论的就是如果我们有一个页面,需要一下渲染很多很多的内容,这个内容可能是海量的DOM,可能是长列表,可能是大型的计算,我们如何保证这个过程不会影响影响浏览器的帧率,并且始终保持对点击、鼠标、聚焦等事件的敏感;
本文总结一种方案:渐进式渲染;
渐进是达尔文进化论的一个基本概念,物种的进化并不是一蹴而就,而是一点点的产生变异,通过适应环境,经过长时间的进化才一点点成就了它现在的样子;
我的理解是:不要一口吃个大胖子,慢慢来;
大数据渲染也是这样,不要使用同步任务一下渲染所有的东西,而是慢慢来,在保证浏览器有时间绘制的情况下,一点点的执行JS。
五、案例实践
假如我们有一个需求:在一个页面中,需要渲染10万个随机小球,我们如何设计?
对于这样的问题,我们直接一上来开写:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
.circle {
width: 10px;
height: 10px;
border-radius: 50%;
position: absolute;
}
</style>
</head>
<body>
<button onclick="render()">render</button>
<div id="root"></div>
<script>
const factorArr = [0,1,2,3,4,5,6,7,8,9,'a','b','c','d','e','f']
/* 颜色 */
function getColor() {
const r = factorArr[Math.floor(Math.random() * 16)];
const g = factorArr[Math.floor(Math.random() * 16)];
const b = factorArr[Math.floor(Math.random() * 16)];
return `#${r}${g}${b}`;
}
/* 位置 */
function getPostion(position) {
const { width, height } = position;
return {
left: Math.ceil(Math.random() * width) + "px",
top: Math.ceil(Math.random() * height) + "px",
};
}
/* 色块组件 */
function Circle(position) {
return {
background: getColor(),
...getPostion(position),
};
}
const root = document.getElementById("root");
function renderRoot(num) {
const fragment = document.createElement("fragment");
for (let index = 0; index < num; index++) {
const div = document.createElement("div");
const { left, top, background } = Circle({
width: 1000,
height: 600,
});
div.className = "circle";
div.style.left = left;
div.style.top = top;
div.style.backgroundColor = background;
fragment.appendChild(div);
}
root.appendChild(fragment);
}
const render = () => {
renderRoot(100000);
};
</script>
</body>
</html>
效果如下:
通过上面的演示,我们能够明显感受到卡顿,原因我相信在座的各位都知道了,大量的js计算阻塞了DOM的绘制,导致我们能够感知的卡顿;如果我们将渲染的数量调的更大一下,效果会更加明显;
产品经理坐不住了,去,赶紧给我去优化!
五、帧渲染
任何思路都来源于我们对于基础知识的认识,分析原因我们得知,卡顿的原因是因为同步JS过长,那我们需要构建一种运行机制,使得浏览器在保证绘制的情况下,渐进的完成我们的大任务;因此我们可以从浏览器每一帧做的事情开始考虑,我们让浏览器正常刷新,然后每一帧塞一个小任务(同步执行1000个div的渲染),由于这个小任务足够小,所以根本就谈不上阻塞,能塞进去的接口有哪些呢?
首先想到了是requestAnimationFrame;
于是写一个渲染器:
// 提示:在上面html中需要新增一个按钮调用renderSlice
const scheduler = (size, scale, index) => {
let start = performance.now(); /* 开始时间*/
const handleTask = () => {
renderRoot(size);
console.log(index, scale);
if (index < scale) {
index++;
requestAnimationFrame(handleTask);
}else {
let end = performance.now(); /* 结束时间*/
console.log(`${(end - start) / 1000}s`);
}
};
requestAnimationFrame(handleTask);
};
const renderSlice = () => {
const size = 2000;
const scale = 500000 / size;
scheduler(size, scale, 0);
};
看看效果:
可以观察到,这样渲染效果会好很多,没有刚开始卡顿的感觉,但是整体的渲染时间,确实会拉长一些,慢着,既然是帧间渲染我们其实也可以用requestIdleCallback,这是chorme浏览器提供的一个api,能够在浏览器每一帧有盈余的时间的时候,做一些事情。我们只需将上面的requestAnimationFrame替换为requestIdleCallback,便能够看到效果;
// 提示:在上面html中需要新增一个按钮调用renderSlice
const scheduler = (size, scale, index) => {
let start = performance.now(); /* 开始时间*/
const handleTask = () => {
renderRoot(size);
console.log(index, scale);
if (index < scale) {
index++;
requestIdleCallback(handleTask);
}else {
let end = performance.now(); /* 结束时间*/
console.log(`${(end - start) / 1000}s`);
}
};
requestIdleCallback(handleTask);
};
const renderSlice = () => {
const size = 1000;
const scale = 500000 / size;
scheduler(size, scale, 0);
};
看效果几乎都是一样的,因为他们都是每一帧执行一次;
但是有一个问题,上面这个每一帧只能做一次任务,总感觉效率不够高,所以整个大任务渲染完毕的周期就会拉的特别长,我们能不能尽可能让一帧执行多次任务呢?
六、宏渲染
聪明的你可能已经想到了宏任务,在铺垫环节我们已经讲到了,宏任务在一帧钟可以执行多次,因此可以极大的提升渲染效率;等不及了,赶紧先用setTimeout试一下;
const scheduler = (size, scale, index) => {
let start = performance.now(); /* 开始时间*/
const handleTask = () => {
renderRoot(size);
console.log(index, scale);
if (index < scale) {
index++;
setTimeout(handleTask, 0);
}else {
let end = performance.now(); /* 结束时间*/
console.log(`${(end - start) / 1000}s`);
}
};
setTimeout(handleTask, 0);
};
const renderSlice = () => {
const size = 1000;
const scale = 500000 / size;
scheduler(size, scale, 0);
};
奇怪,感觉好想也没有好多少;多做几次的实验结果是平均就少了1s,虽然是优化了一些,但是远低于我们的预期,这是为什么呢?
原来setTimeout本身就是有性能损耗的,每一个setTimeout宏任务都会有4ms-5ms的误差,我们以为我们写的是立马执行的定时器,但其实内部他们给我们偷一下懒,偏偏给我们慢个5ms左右,本来一帧就只有16.6ms,它还给你慢个5ms,那可不耽误事么?
所以我们选择放弃setTimeout!转而使用MessageChannel,关于它的介绍,可查阅文档。我们直接改进我们的渲染器;
const async = (size, scale, index) => {
let start = performance.now(); /* 开始时间*/
const { port1, port2 } = new MessageChannel();
port2.onmessage = (e) => {
renderRoot(size);
if (index < scale) {
index++;
port1.postMessage("去给我执行任务去");
}else {
let end = performance.now(); /* 结束时间*/
console.log(`${(end - start) / 1000}s`);
}
};
port1.postMessage("去给我执行任务去");
};
const renderSlice = () => {
const size = 1000;
const scale = 100000 / size;
async(size, scale, 0);
};
可以看到,快了一倍不止,所以我们可以总结,MessageChannel,拥有更高的效率,兼容性也好,并且属于一个宏任务,可以作为我们目前这个阶段的最佳方案;
关于使用微任务,可能会有同学想,既然帧渲染,宏渲染都可以,那我可不可以使用微任务渲染呢?目前来说还是不行的,因为微任务如果不断的堆积,那么第一个loop就永远结束不了,事件循环是以宏任务为单位的,因此微任务的堆积从效果上来看等价于同步任务,因为这种方案我们不采取;
七、结合react
可能对于react熟悉的朋友已经发现了,我整篇文章的逻辑正式按照react中scheduler模版的迭代历史去写的,在react现在版本中,正式使用MessageChanel作为调度器的核心api的,如果浏览器不支持这个api则使用setTimeout作为垫片,因此我想通过这样一篇文章,结合实践来帮助各位更好的认识react;
最后贡献一道面试题:
const { port1, port2 } = new MessageChannel();
port2.onmessage = (e) => {
console.log(`MessageChannel`);
};
setTimeout(() => {
console.log("setTimeout");
});
port1.postMessage("");
requestAnimationFrame(() => {
console.log("requestAnimationFrame");
});
Promise.resolve().then(() => {
console.log("Promise");
});
// 输出的顺序
上面面试题:欢迎评论区留下答案,如果可以,分享一下您的分析;
八、总结
本文通过多个例子,简单阐述了一个核心思想,那就是渐进式渲染,它的代价是能够始终使得浏览器保持敏感,且最终可以完成大任务,缺点就是整体的耗时会更长一些,但从用户体验的角度,这是值得的。
如果有觉得错误或者不理解的地方,欢迎私信或者评论区我们一起讨论;
如果对您有帮助,还希望点赞支持一下?万分感谢!持续输出高质量文章,我们一起加油!