前言
本文主要介绍长列表
的一种优化方案:虚拟列表
。本文主要是对传统的虚拟列表方案进行更加详尽的刨析,以便我们能够更加深入理解虚拟列表的原理。
虚拟列表目录
- 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像素,注意要开定位和滚动条 --><divclass="virtualListWrap"ref="virtualListWrap"@scroll="handleScroll":style="{ height: itemHeight * count + 'px' }"><!-- 占位dom元素,其高度为所有的数据的总高度 --><divclass="placeholderDom":style="{ height: allListData.length * itemHeight + 'px' }"></div><!-- 内容区,展示10条数据,注意其定位的top值是变化的 --><div class="contentList" :style="{ top: topVal }"><!-- 每一条(项)数据 --><divv-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中截取需要展示的数据showListDatashowListData: 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>