前言
本文主要介绍长列表
的一种优化方案:虚拟列表
。本文主要是对传统的虚拟列表方案进行更加详尽的刨析,以便我们能够更加深入理解虚拟列表的原理。
虚拟列表目录
- 1、为什么需要使用虚拟列表
- 2、什么是虚拟列表
- 与懒加载的区别(重要)
- 3、实现思路
- 4、通过节流的方式优化滚动事件
1、为什么需要使用虚拟列表
假设我们的长列表需要展示10000条记录,我们同时将10000条记录渲染到页面中,先来看看需要花费多长时间:
<button id="button">button</button><br>
<ul id="container"></ul>
document.getElementById('button').addEventListener('click',function(){
// 记录任务开始时间
let now = Date.now();
// 插入一万条数据
const total = 10000;
// 获取容器
let ul = document.getElementById('container');
// 将数据插入容器中
for (let i = 0; i < total; i++) {
let li = document.createElement('li');
li.innerText = ~~(Math.random() * total)
ul.appendChild(li);
}
console.log('JS运行时间:',Date.now() - now);
setTimeout(()=>{
console.log('总运行时间:',Date.now() - now);
},0)
// print JS运行时间: 38
// print 总运行时间: 957
})
当我们点击按钮,会同时向页面中加入一万条记录,通过控制台的输出,我们可以粗略的统计到,JS的运行时间为38ms,
但渲染完成后的总时间为957ms
。
简单说明一下,为何两次console.log
的结果时间差异巨大,并且是如何简单来统计JS运行时间
和总渲染时间
:
在 JS 的Event Loop
中,当JS引擎所管理的执行栈
中的事件以及所有微任务事件
全部执行完后,才会触发渲染线程对页面进行渲染
第一个console.log
的触发时间是在页面进行渲染之前
,此时得到的间隔时间为JS运行所需要的时间
第二个console.log
是放到 setTimeout
中的,它的触发时间是在渲染完成
,在下一次Event Loop中执行的
然后,我们通过Chrome
的Performance
工具来详细的分析这段代码的性能瓶颈在哪里:
从Performance
可以看出,代码从执行到渲染结束
,共消耗了960.8ms
,其中的主要时间消耗如下:
- Event(click) : 40.84ms
-Recalculate Style
: 105.08ms Layout
: 731.56ms- Update Layer Tree : 58.87ms
- Paint : 15.32ms
从这里我们可以看出,我们的代码的执行过程中,消耗时间最多的两个阶段是Recalculate Style
和Layout
。
Recalculate Style
:样式计算,浏览器根据css选择器计算哪些元素应该应用哪些规则,确定每个元素具体的样式。
Layout
:布局,知道元素应用哪些规则之后,浏览器开始计算它要占据的空间大小及其在屏幕的位置。
在实际的工作中,列表项必然不会像例子中仅仅只由一个li标签组成,必然是由复杂DOM节点组成的。
那么可以想象的是,当列表项数过多并且列表项结构复杂的时候,同时渲染时,会在Recalculate Style
和Layout
阶段消耗大量的时间。
而虚拟列表
就是解决这一问题的一种实现。
2、什么是虚拟列表
由上点可知,在传统的列表渲染
中,如果列表数据过多
,一次性渲染
所有数据将耗费大量的时间和内存
。当我们上下滚动时,性能低的浏览器或电脑都会感觉到非常的卡,这对用户的体验时是致命的。
于是我们会想到懒加载
,当资源到达可视窗口内时,继续向服务器发送请求获取接下来的资源,不过当获取的资源越来越多时,此时浏览器不断重绘与重排
,这样的开销也是要考虑的当数量多到一定程度时,页面也会出现卡顿
。
此时我们会想到虚拟列表
,虚拟列表只渲染当前可见的部分数据
,随着滚动条的滚动,只渲染当前可见的列表项,从而大大减少了渲染时间。同时支持无限滚动
,用户只需要不停地滚动页面,就可以看到所有的数据,从而提高了用户的体验
。
与懒加载的区别(重要)
虚拟列表其实也是一种按需加载
,那么有些人可能会问,那不是和懒加载
差不多吗?这里我们要简单说明一下懒加载,懒加载其实就是延迟加载
,当页面中的数据很多时,我们优先加载视口区域中的数据
,其余数据等滚动条滚到相应位置时再进行加载
。所以懒加载确实也是按需加载,但是区别在于,当你的滚动条滚动到靠下的位置,懒加载
会加载你当前位置以及上方滚动过区域的全部数据,而虚拟列表
只加载你当前可见区域中的数据。所以如果数据量很大的话,你滚动的位置越靠下,那么懒加载渲染的成本也就越高,但虚拟列表的渲染成本固定,他只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染,因此性能要比懒加载高很多。
3、实现思路
- 滚动容器元素:一般情况下,滚动容器元素是
window
对象。或是某个元素(div)能在内部产生横向或者纵向的滚动
的这个元素。 - 可滚动区域:滚动容器元素的
内部内容区域
。假设有 100 条数据,每个列表项的高度是 50,那么可滚动的区域的高度就是 100 * 50。 - 可视区域:滚动容器元素的
视觉可见区域
。一般容器元素是window
对象,可视区域就是浏览器的视口大小
;假设容器元素是某个div
,其高度是 500,那么可视区域就是设置高度为500的区域
。
虚拟列表的核心就在于通过计算出
startIndex
和endIndex
,只展示视口以内的元素,来提高渲染性能。
定义的dom 结构如下:
virtualListWrap
为固定高度容器,设置其高度 ,position:relative
placeholderDom
为占位DOM元素
contentList
为滚动区域(可视区域),设置position: absolute 并动态绑定style来调整top定位
itemClass
列表的每一项
4、通过节流的方式优化滚动事件
<template>
<!-- 虚拟列表容器,类似“窗口”,窗口的高度取决于一次展示几条数据
比如窗口只能看到10条数据,一条40像素,10条400像素
故,窗口的高度为400像素,注意要开定位和滚动条 -->
<div
class="virtualListWrap"
ref="virtualListWrap"
@scroll="handleScroll"
:style="{ height: itemHeight * count + 'px' }"
>
<!-- 占位dom元素,其高度为所有的数据的总高度 -->
<div
class="placeholderDom"
:style="{ height: allListData.length * itemHeight + 'px' }"
></div>
<!-- 内容区,展示10条数据,注意其定位的top值是变化的 -->
<div class="contentList" :style="{ top: topVal }">
<!-- 每一条(项)数据 -->
<div
v-for="(item, index) in showListData"
:key="index"
class="itemClass"
:style="{ height: itemHeight + 'px' }"
>
{{ item.name }}
</div>
</div>
<!-- 加载中部分 -->
<div class="loadingBox" v-show="loading">
<i class="el-icon-loading"></i>
<span>loading...</span>
</div>
</div>
</template>
<script>
function throttle(fn, wait) {
var pre = Date.now();
return function () {
var context = this;
var args = arguments;
var now = Date.now();
if (now - pre >= wait) {
fn.apply(context, args);
pre = Date.now();
}
};
}
import axios from "axios";
export default {
data() {
return {
allListData: [], // 所有的数据,比如这个数组存放了十万条数据
itemHeight: 40, // 每一条(项)的高度,比如40像素
count: 10, // 一屏展示几条数据
start: 0, // 开始位置的索引
end: 10, // 结束位置的索引
topVal: 0, // 父元素滚动条滚动,更改子元素对应top定位的值,确保联动
loading: false,
};
},
computed: {
// 从所有的数据allListData中截取需要展示的数据showListData
showListData: function () {
// console.log(this.allListData.slice(this.start, this.end))
return this.allListData.slice(this.start, this.end);
},
},
async created() {
this.loading = true;
const res = await axios.get("http://124.223.69.156:3300/bigData");
this.allListData = res.data.data;
this.loading = false;
},
methods: {
handleScroll() {
throttle(this.s(), 500);
},
s() {
/**
* 获取在垂直方向上,滚动条滚动了多少像素距离Element.scrollTop
*
* 滚动的距离除以每一项的高度,即为滚动到了多少项,当然,要取个整数
* 例:滚动4米,一步长0.8米,滚动到第几步,4/0.8 = 第5步(取整好计算)
*
* 又因为我们一次要展示10项,所以知道了起始位置项,再加上结束位置项,
* 就能得出区间了【起始位置, 起始位置 + size项数】==【起始位置, 结束位置】
* */
const scrollTop = this.$refs.virtualListWrap.scrollTop;
this.start = Math.floor(scrollTop / this.itemHeight);
this.end = this.start + this.count;
/**
* 动态更改定位的top值,确保联动,动态展示相应内容
* */
this.topVal = this.$refs.virtualListWrap.scrollTop + "px";
},
},
};
</script>
<style scoped lang="less">
// 虚拟列表容器盒子
.virtualListWrap {
box-sizing: border-box;
width: 240px;
border: solid 1px #000000;
// 开启滚动条
overflow-y: auto;
// 开启相对定位
position: relative;
.contentList {
width: 100%;
height: auto;
// 搭配使用绝对定位
position: absolute;
top: 0;
left: 0;
.itemClass {
box-sizing: border-box;
width: 100%;
height: 40px;
line-height: 40px;
text-align: center;
}
// 奇偶行改一个颜色
.itemClass:nth-child(even) {
background: #c7edcc;
}
.itemClass:nth-child(odd) {
background: pink;
}
}
.loadingBox {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.64);
color: green;
display: flex;
justify-content: center;
align-items: center;
}
}
</style>