一、 当需要一次性向页面中插入大量数据时,怎么样才能不卡主页面的情况下渲染数据?一般有两种做法
二、 粗暴法
首先使用最粗暴的做法,一次性将大量数据插入页面中
/**记录任务开始的时间*/
let now = Date.now();
/**插入十万条数据*/
const total = 100000;
/**获取容器*/
const ul = document.getElementById("container");
/**将数据插入容器中*/
for (let i = 0; i < total; i++) {
let li = document.createElement("li");
li.innerHTML = ~~(Math.random() * total);
ul.appendChild(li);
}
console.log("JS运行时间:", Date.now() - now); /**JS运行时间: 375*/
setTimeout(() => {
console.log("总时间", Date.now() - now); /**总时间 3267*/
}, 0);
如何两次console.log
的结果时间差异巨大,并且是如何简单统计JS运行时间和总渲染时间
-
在JS的Event Loop中,当JS引擎所管理的执行栈中的事件以及所有微任务事件全部执行完毕后,才会触发渲染线程对页面进行渲染
-
第一个console.log的触发时间是在页面进行渲染之前,此时得到的间隔时间为js运行所需要的时间
-
第二个console.log是放到setTimeout中,它的触发时间是在渲染完成,在下一次Event Loop中执行
-
结论:对于大量数据的渲染,JS运算并不是性能的瓶颈,性能的瓶颈主要在于渲染阶段
三、 时间分片
- 使用定时器
页面卡顿是由于同事渲染大量DOM所引起的,所以考虑将渲染过程分批进行
/**获取容器*/
const ul = document.getElementById("container");
/**插入十万条数据*/
const total = 100000;
/**一次插入20条*/
const once = 20;
/**每条记录的索引*/
let idx = 0;
/**总页数*/
let page = total / once;
/**循环加载数据*/
function loop(curTotal, curIdx) {
if (curTotal <= 0) return false;
/**每页多少条*/
let pageCount = Math.min(curTotal, once);
setTimeout(() => {
/**将数据插入容器中*/
for (let i = 0; i < pageCount; i++) {
let li = document.createElement("li");
li.innerText = curIdx + i + " : " + ~~(Math.random() * total);
ul.appendChild(li);
}
loop(curTotal - pageCount, curIdx + pageCount);
}, 0);
}
loop(total, idx);
页面加载的时间已经非常快了,每次刷新可以很快看到第一屏的所有数据,但是当我们快速滚动页面的时候,发现页面出现闪屏或者白屏的现象。
- 为什么会出现闪屏现象呢
需要明白的一些概念
- FPS: 每秒钟画面更新的次数。我们平时所看到的连续画面都是由一幅幅静止画面组成的,每幅画面称为一帧,FPS是描述帧变化速度的物理量。
- 大多数电脑显示器的刷新频率是60Hz,大概相当于每秒钟重绘60次,FPS为60frame/s,为这个值的设定受屏幕分辨率、屏幕尺寸和显卡的影响。
- 因此,当你对着电脑屏幕什么也不做的情况下,大多显示器也会以每秒60次的频率正在不断的更新屏幕上的图像。
为什么你感觉不到这个变化?
- 那是因为人的眼睛有视觉停留效应,即前一副画面留在大脑的印象还没消失,紧接着后一副画面就跟上来了, 这中间只间隔了16.7ms(1000/60≈16.7),所以会让你误以为屏幕上的图像是静止不动的。
- 而屏幕给你的这种感觉是对的,试想一下,如果刷新频率变成1次/秒,屏幕上的图像就会出现严重的闪烁, 这样就很容易引起眼睛疲劳、酸痛和头晕目眩等症状。
- 大多数浏览器都会对重绘操作加以限制,不超过显示器的重绘频率,因为即使超过那个频率用户体验也不会有提升。 因此,最平滑动画的最佳循环间隔是1000ms/60,约等于16.6ms。
直观感受,不同帧率的体验:
- 帧率能够达到 50 ~ 60 FPS 的动画将会相当流畅,让人倍感舒适;
- 帧率在 30 ~ 50 FPS 之间的动画,因各人敏感程度不同,舒适度因人而异;
- 帧率在 30 FPS 以下的动画,让人感觉到明显的卡顿和不适感;
- 帧率波动很大的动画,亦会使人感觉到卡顿。
- 简单聊一下 setTimeout 和闪屏现象为什么会出现闪屏现象呢
- setTimeout的执行时间并不是确定的。在JS中,setTimeout任务被放进事件队列中,只有主线程执行完才会去检查事件队列中的任务是否需要执行,因此setTimeout的实际执行时间可能会比其设定的时间晚一些。
- 刷新频率受屏幕分辨率和屏幕尺寸的影响,因此不同设备的刷新频率可能会不同,而setTimeout只能设置一个固定时间间隔,这个时间不一定和屏幕的刷新时间相同。
以上两种情况都会导致setTimeout的执行步调和屏幕的刷新步调不一致。
在setTimeout中对dom进行操作,必须要等到屏幕下次绘制时才能更新到屏幕上,如果两者步调不一致,就可能导致中间某一帧的操作被跨越过去,而直接更新下一帧的元素,从而导致丢帧现象。
- 使用 requestAnimationFrame
requestAnimationFrame
最大的优势是由系统来决定回调函数的执行时机
如果屏幕刷新率是60Hz,那么回调函数就每16.7ms被执行一次
如果屏幕刷新率是75Hz,那么这个时间间隔就变成了1000/75=13.3ms
换句话说就是requestAnimationFrame
的步伐跟着系统的刷新步伐走。
他能保证回调函数在屏幕每一次的刷新间隔只被执行一次,这样就不会引起丢帧现象
/**获取容器*/
const ul = document.getElementById("container");
/**插入十万条数据*/
const total = 100000;
/**一次插入20条*/
const once = 20;
/**每条记录的索引*/
let idx = 0;
/**总页数*/
let page = total / once;
/**循环加载数据*/
function loop(curTotal, curIdx) {
if (curTotal <= 0) return false;
/**每页多少条*/
let pageCount = Math.min(curTotal, once);
window.requestAnimationFrame(() => {
/**将数据插入容器中*/
for (let i = 0; i < pageCount; i++) {
let li = document.createElement("li");
li.innerText = curIdx + i + " : " + ~~(Math.random() * total);
ul.appendChild(li);
}
loop(curTotal - pageCount, curIdx + pageCount);
}, 0);
}
loop(total, idx);
- 使用 DocumentFragment
DocumentFragment,文档片段接口,表示一个没有父级文件的最小文档对象。它被作为一个轻量版的Document使用,用于存储已排好版的或尚未打理好格式的XML片段。最大的区别是因为DocumentFragment不是真实DOM树的一部分,它的变化不会触发DOM树的(重新渲染),且不会导致性能等问题。
可以使用document.createDocumentFragment方法或者构造函数来创建一个空的DocumentFragment
从MDN的说明中,我们得知DocumentFragment是DOM节点,但并不是DOM树的一部分,可以认为是存在内存中的,所以将子元素插入到文档片段时不会引起页面回流
当append元素到document中时,被append进去的元素的样式表的计算是同步发生的,此时调用get ComputedStyle可以得到样式的计算值。
而append元素到documentFragment中时,是不会计算元素的样式表,所以documentFragment性能更优。当然现在浏览器的优化已经做的很好了,当append元素到document中后,没有访问getComputedStyle之类的方法时,现代浏览器也可以把样式表的计算推迟到脚本执行之后。
/**获取容器*/
const ul = document.getElementById("container");
/**插入十万条数据*/
const total = 100000;
/**一次插入20条*/
const once = 20;
/**每条记录的索引*/
let idx = 0;
/**总页数*/
let page = total / once;
/**循环加载数据*/
function loop(curTotal, curIdx) {
if (curTotal <= 0) return false;
/**每页多少条*/
let pageCount = Math.min(curTotal, once);
window.requestAnimationFrame(() => {
const fragment = document.createDocumentFragment();
/**将数据插入容器中*/
for (let i = 0; i < pageCount; i++) {
let li = document.createElement("li");
li.innerText = curIdx + i + " : " + ~~(Math.random() * total);
fragment.appendChild(li);
}
ul.appendChild(fragment);
loop(curTotal - pageCount, curIdx + pageCount);
}, 0);
}
loop(total, idx);
通过时间分片的方式来同时加载大量简单DOM。
四、 虚拟列表
- 定义
虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域的数据不渲染或部分渲染的技术,从而达到极高的渲染性能
假设有一万条记录同时渲染,我们屏幕的可见区域的高度为500px,而列表项的高度为50px,则此时我们在屏幕中最多只能看到10个列表项,那么在首次渲染的时候,我们只需要加载10条即可。
说完首次加载,再分析一下当滚动发生时,我们可以通过计算当前滚动值得知此时在屏幕可见区域应该显示的列表项
假设滚动发生,滚动条距顶部的位置为150px,则我们可得知可见区域内的列表项为第4项至第13项。
- 实现
在首屏加载的时候,只加载可视区域内需要的列表项,当滚动发生时,动态计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。
- 计算当前可视区域起始索引(startIdx)
- 计算当前可视区域结束索引(endIdx)
- 计算当前可视区域数据,并渲染到页面中
- 计算startIdx对应的数据在整个列表中的偏移位置startOffset并设置到列表上
由于只是对可视区域内的列表项进行渲染,所以为了保持列表容器的高度并可以正常的处罚滚动,将html结构设计成如下结构
<div class="infinite-list-container">
<div class="infinite-list-phantom"></div>
<div class="infinite-list">
<!-- item-1 -->
<!-- item-2 -->
<!-- ...... -->
<!-- item-n -->
</div>
</div>
- infinite-list-container 为可视区域的容器
- infinite-list-phantom 为容器的占位,高度为总列表高度,用于形成滚动条
- infinite-list 为列表项的渲染区域
接着监听infinite-list-container的scroll事件,获取滚动条的scrollTop
- 假定可视区域高度固定,称之为screenHeight
- 假定列表项目高度固定,称之为itemSize
- 假定列表数据称之为listData
- 假定当前滚动位置称之为scrollTop
则可推算出
- 列表总高度 listHeight = listData.length * itemSize
- 可显示的列表项数 visibleCount = Math.ceil(screenHeight / itemSize)
- 数据的起始索引 startIdx = Math.floor(scrollTop / itemSize)
- 数据的结束索引 endIdx = startIdx + visibleCount
- 列表显示数据为 visibleData = listData.slice(startIdx, endIdx)
当滚动后,由于渲染区域相对于可视区域已经发生了偏移,此时需要获取一个偏移量startOffset,通过样式控制将渲染区域偏移到可视区域中
- 偏移量 startOffset = scrollTop - ( scrollTop % itemSize)
- 列表项高度固定代码实现
虚拟列表component部分
<template>
<div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)">
<div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }" />
<div class="infinite-list" :style="{ transform: getTransform }">
<div
ref="items"
v-for="item in visibleData"
:key="item.id"
:style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }"
>
{{ item.value }}
</div>
</div>
</div>
</template>
export default {
name: "Infinite",
props: {
/**所有列表数据 */
listData: {
type: Array,
default: () => [],
},
/**每项的高度 */
itemSize: {
type: Number,
default: 200,
},
},
computed: {
/**列表总高度 */
listHeight() {
return this.listData.length * this.itemSize;
},
/**可显示的列表项目 */
visibleCount() {
return Math.ceil(this.screenHeight / this.itemSize);
},
/**偏移量对应的style */
getTransform() {
return `translate3d(0, ${this.startOffset}px, 0)`;
},
/**获取真实显示列表数据 */
visibleData() {
return this.listData.slice(
this.start,
Math.min(this.end, this.listData.length)
);
},
},
data() {
return {
/**可视区域高度 */
screenHeight: 0,
/**偏移量 */
startOffset: 0,
/**起始索引 */
start: 0,
/**结束索引 */
end: null,
};
},
mounted() {
this.screenHeight = this.$el.clientHeight;
this.start = 0;
this.end = this.start + this.visibleCount;
},
methods: {
scrollEvent() {
/**当前滚动位置 */
const scrollTop = this.$refs.list.scrollTop;
/**此时的开始索引 */
this.start = Math.floor(scrollTop / this.itemSize);
/**此时的结束索引 */
this.end = this.start + this.visibleCount;
/**此时的偏移量 */
this.startOffset = scrollTop - (scrollTop % this.itemSize);
},
},
};
.infinite-list-container {
height: 100%;
overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch;
}
.infinite-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.infinite-list {
left: 0;
right: 0;
top: 0;
position: absolute;
text-align: center;
}
.infinite-list-item {
padding: 10px;
color: #555;
box-sizing: border-box;
border-bottom: 1px solid #999;
}
父组件
<template>
<div id="app">
<Infinite :listData="data" :itemSize="100" />
</div>
</template>
import Infinite from "./components/Infinite";
let d = [];
for (let i = 0; i < 1000; i++) {
d.push({ id: i, value: i });
}
export default {
name: "App",
components: {
Infinite,
},
data() {
return {
data: d,
};
},
};
html {
height: 100%;
}
body {
height: 100%;
margin: 0;
}
#app {
height: 100%;
}
- 列表项动态高度代码
实际应用的时候,当列表中包含文本之类的可变内容,会导致列表项的高度并不相同
在虚拟列表中应用动态高度的解决方案一般有如下三种
- 对组件数学itemSize进行扩展,支持传递类型为数字、数组、函数
- 可以是一个固定值,如100,此时列表项是固定的
- 可以是一个包含所有列表项高度的数据,如[50, 20, 100, 80, ...]
- 可以是一个根据列表项索引返回其高度的函数,(index: number): number
这种方式虽然有比较好的灵活度,但仅适用于可以预先知道或通过计算得知列表项高度的情况,依然无法解决列表项高度由内容撑开的情况。
- 将列表项渲染到屏幕外,对其高度进行测量并缓存,然后再将其渲染至可视区域内
由于预先渲染至屏幕外,在渲染至屏幕内,这导致渲染成本增加一倍,这对于数百万用户在低端移动端设备上使用的产品来说是不切实际的
- 以预估高度先行渲染,然后获取真实高度并缓存
定义组件属性estimatedItemSize, 用来接收预告高度
props:{
// 预告高度
estimatedItemSize:{
type: Number
}
}
定义positions,用于列表项渲染后存储每一项的高度以及位置信息
this.positions = [
top: 0,
bottom: 100,
height: 100
]
并在初始化根据estimatedItemSize对positions进行初始化
initPositions(){
this.positions = this.listData.map((item, index) => {
return {
index,
height: this.estimatedItemSize,
top: index * this.estimatedItemSize,
bottom: (index + 1) * this.estimatedItemSize
}
})
}
由于列表项高度不定,并且我们维护了positions,用于记录每一项的位置,而列表高度实际就等于列表中最后一项的底部距离列表顶部的位置
// 列表总高度
listHeight(){
return this.positions[this.positions.length - 1].bottom
}
由于需要在渲染完成后,获取列表每项的位置信息并缓存,所以使用钩子函数updated来实现
updated(){
let nodes = this.$refs.items;
nodes.forEach(node => {
let rect = node.getBoundingClientRect();
let height = rect.height;
let index = +node.id.slice(1);
let oldHeight = this.positions[index].height;
let dValue = oldHeight - height;
// 存在差值
if(dValue){
this.positions[index].bottom = this.positions[index].bottom - dValue;
this.positions[index].height = height;
for(let k = index + 1; k < this.positions.length; k++){
this.positions[k].top = this.positons[k-1].bottom;
this.positions[k].bottom = this.positions[k-1].bottom - dValue;
}
}
})
}
滚动后获取列表开始索引的方法修改为通过缓存获取】
// 获取列表起始索引
getStartIndex(scrollTop = 0){
let item = this.positions.find(i => i && i.bottom > scrollTop);
return item.index
}
由于我们的缓存数据,本身就是有顺序的,所以获取开始索引的方法可以考虑通过二分查找的方式来降低检索次数
// 获取列表起始索引
getStartIndex(scrollTop = 0){
// 二分查找
return this.binarySearch(this.positions, scrollTop)
}
// 二分法查找
binarySearch(list, value){
let start = 0;
let end = list.length - 1;
let tempIndex = null;
while(start <= end){
let midIndex = parsetInt((start + end) / 2);
let midValue = list[midIndex].bottom;
if(midValue === value){
return midIndex + 1;
}else if(midValue < value){
start = midIndex + 1;
}else if(midValue > value){
if(tempIndex === null || tempIndex > midIndex) tempIndex = midIndex;
end = end -1;
}
}
return tempIndex
}
滚动后将偏移量的获取方式变更
scrollEvent(){
// ... 省略
if(this.start >= 1) this.startOffset = this.positions[this.start - 1].bottom
else this.startOffset = 0
}
通过faker.js来创建一些随机数据
let data = [];
for(let i = 0; i < 100000;i++){
data.push({
id: i,
value: faker.lorem.sentences(); // 长文本
})
}
为了使页面平滑滚动,我们还需要在可见区域的上方和下方渲染额外的项目,在滚动时给予一些缓存,所以将屏幕分为三个区域
- 可视区域上方 above
- 可视区域 screen
- 可视区域下方 below
定义组件属性bufferScale, 用于接收缓存区数据与可视区数据的比例
props:{
// 缓存区比例
bufferScale:{
type: Number;
default: 1
}
}
可视区上方渲染条数 aboveCount 获取方式如下
aboveCount(){
return Math.min(this.start, this.bufferScale * this.visibleCount)
}
可视区下方渲染条数 belowCount 获取方式如下
belowCount(){
return Math.min(this.listData.length - this.end, this.bufferScale * this.visibleCount)
}
真实渲染数据visibleData获取方式如下
visibleData(){
let start = this.start - this.abvoeCount;
let end = this.end + this.belowCount;
return this._listData.slice(start, end);
}
完整代码
虚拟列表组件
<template>
<div
ref="list"
:style="{ height }"
class="infinite-list-container"
@scroll="scrollEvent($event)"
>
<div ref="phantom" class="infinite-list-phantom" />
<div ref="content" class="infinite-list">
<div
class="infinite-list-item"
ref="items"
:id="item._index"
:key="item._index"
v-for="item in visibleData"
>
<slot ref="slot" :item="item.item" />
</div>
</div>
</div>
</template>
export default {
name: "VirtualList",
props: {
/**所有列表数据 */
listData: {
type: Array,
default: () => [],
},
/**预估高度 */
estimatedItemSize: {
type: Number,
required: true,
},
/**缓存区比例 */
bufferScale: {
type: Number,
default: 1,
},
/**容器高度 100px or 50vh */
height: {
type: String,
default: "100%",
},
},
data() {
return {
/**可视区域高度 */
screenHeight: 0,
/**开始索引 */
start: 0,
/**结束索引 */
end: 0,
};
},
computed: {
_listData() {
return this.listData.map((item, index) => {
return {
_index: `_${index}`,
item,
};
});
},
visibleCount() {
return Math.ceil(this.screenHeight / this.estimatedItemSize);
},
aboveCount() {
return Math.min(this.start, this.bufferScale * this.visibleCount);
},
belowCount() {
return Math.min(
this.listData.length - this.end,
this.bufferScale * this.visibleCount
);
},
visibleData() {
let start = this.start - this.aboveCount;
let end = this.end + this.belowCount;
return this._listData.slice(start, end);
},
},
created() {
this.initPositions();
window.vm = this;
},
mounted() {
this.screenHeight = this.$el.clientHeight;
this.start = 0;
this.end = this.start + this.visibleCount;
},
updated() {
this.$nextTick(function () {
if (!this.$refs.items || !this.$refs.items.length) return;
/**获取真实元素大小, 修改对应的尺寸缓存 */
this.updateItemSize();
/**更逊真实元素大小,修改对应的尺寸缓存 */
let height = this.positions[this.positions.length - 1].bottom;
this.$refs.phantom.style.height = height + "px";
/**更新真实偏移量 */
this.setStartOffset();
});
},
methods: {
initPositions() {
this.positions = this.listData.map((d, index) => ({
index,
height: this.estimatedItemSize,
top: index * this.estimatedItemSize,
bottom: (index + 1) * this.estimatedItemSize,
}));
},
/**获取列表起始索引 */
getStartIndex(scrollTop = 0) {
/**二分查找 */
return this.binarySearch(this.positions, scrollTop);
},
binarySearch(list, value) {
let start = 0,
end = list.length - 1,
tempIndex = null;
while (start <= end) {
let midIndex = parseInt((start + end) / 2);
let mindValue = list[midIndex].bottom;
if (mindValue === value) return midIndex + 1;
else if (mindValue < value) start = midIndex + 1;
else if (mindValue > value) {
if (tempIndex === null || tempIndex > midIndex) tempIndex = midIndex;
end = end - 1;
}
}
return tempIndex;
},
/**获取列表项的当前尺寸 */
updateItemSize() {
let nodes = this.$refs.items;
nodes.forEach((node) => {
let rect = node.getBoundingClientRect();
let height = rect.height;
let index = +node.id.slice(1);
let oldHeight = this.positions[index].height;
let dValue = oldHeight - height;
/**存在差值 */
if (dValue) {
this.positions[index].bottom = this.positions[index].bottom - dValue;
this.positions[index].height = height;
for (let k = index + 1; k < this.positions.length; k++) {
this.positions[k].top = this.positions[k - 1].bottom;
this.positions[k].bottom = this.positions[k].bottom - dValue;
}
}
});
},
/**获取当前的偏移量 */
setStartOffset() {
let startOffset = 0;
if (this.start >= 1) {
let size =
this.positions[this.start].top -
(this.positions[this.start - this.aboveCount]
? this.positions[this.start - this.aboveCount].top
: 0);
startOffset = this.positions[this.start - 1].bottom - size;
} else {
startOffset = 0;
}
this.$refs.content.style.transform = `translate3d(0,${startOffset}px,0)`;
},
/**滚动事件 */
scrollEvent() {
/**当前滚动位置 */
let scrollTop = this.$refs.list.scrollTop;
/**此时的开始索引 */
this.start = this.getStartIndex(scrollTop);
/**此时的结束索引 */
this.end = this.start + this.visibleCount;
/**此时的偏移量 */
this.setStartOffset();
},
},
};
<style scoped>
.infinite-list-container {
overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch;
}
.infinite-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.infinite-list {
left: 0;
right: 0;
top: 0;
position: absolute;
}
.infinite-list-item {
padding: 5px;
color: #555;
box-sizing: border-box;
border-bottom: 1px solid #999;
/* height:200px; */
}
</style>
Item组件
<template>
<p>
<span style="color: red">{{ item.id }}</span
>{{ item.value }}
</p>
</template>
export default {
props: {
item: {
type: Object,
default: () => {},
},
},
};
父组件
<template>
<div id="app">
<VirtualList :listData="data" :estimatedItemSize="100" v-slot="slotProps">
<Item :item="slotProps.item" />
</VirtualList>
</div>
</template>
import Item from "./components/Item";
import VirtualList from "./components/VirtualList.vue";
import faker from "faker";
let d = [];
for (let i = 0; i < 1000; i++) {
d.push({ id: i, value: faker.lorem.sentences() });
}
export default {
name: "App",
components: {
VirtualList,
Item,
},
data() {
return {
data: d,
};
},
};