前言
在工作中,有时会遇到需要一些不能使用分页方式来加载列表数据的业务情况,对于此,我们称这种列表叫做长列表。比如,在一些外汇交易系统中,前端会实时的展示用户的持仓情况(收益、亏损、手数等),此时对于用户的持仓列表一般是不能分页的。
在高性能渲染十万条数据(时间分片)一文中,提到了可以使用时间分片的方式来对长列表进行渲染,但这种方式更适用于列表项的DOM结构十分简单的情况。本文会介绍使用虚拟列表的方式,来同时加载大量数据。
为什么需要使用虚拟列表
在实际的工作中,列表项必然不会仅仅只由一个li标签组成,必然是由复杂DOM节点组成的。
那么可以想象的是,当列表项数过多并且列表项结构复杂的时候,同时渲染时,会在Recalculate Style和Layout阶段消耗大量的时间。
而虚拟列表就是解决这一问题的一种实现。
什么是虚拟列表
虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。
假设有1万条记录需要同时渲染,我们屏幕的可见区域的高度为500px,而列表项的高度为50px,则此时我们在屏幕中最多只能看到10个列表项,那么在首次渲染的时候,我们只需加载10条即可。

说完首次加载,再分析一下当滚动发生时,我们可以通过计算当前滚动值得知此时在屏幕可见区域应该显示的列表项。
假设滚动发生,滚动条距顶部的位置为150px,则我们可得知在可见区域内的列表项为第4项至`第13项。
实现
虚拟列表的实现,实际上就是在首屏加载的时候,只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。
计算当前可视区域起始数据索引(startIndex)
计算当前可视区域结束数据索引(endIndex)
计算当前可视区域的数据,并渲染到页面中
计算startIndex对应的数据在整个列表中的偏移位置startOffset并设置到列表上

由于只是对可视区域内的列表项进行渲染,所以为了保持列表容器的高度并可正常的触发滚动
下面是vue代码
app.vue
<template> <div id="app"> <div class="VirtualList" :style="{height: windowH +'px', width:windowW+'px'}"> <VirtualList :listData="data" :itemSize="60"/> </div> </div> </template> <script> import VirtualList from "./components/VirtualList.vue"; let d = []; for (let i = 0; i < 100000; i++) { d.push({ id: i, value: i }); } export default { name: 'app', data() { return { data: d }; }, computed:{ windowW(){ return document.documentElement.clientWidth }, windowH(){ return document.documentElement.clientHeight } }, components: { VirtualList } }; </script> <style> .VirtualList{ display: flex; justify-content: center; width: 80%; height: 90%; } #app { height: 100%; width: 100%; } </style>
VirtualList.vue
<template> <div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)"> <span>test {{screenHeight}}</span> <div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div> <div class="infinite-list" :style="{ transform: getTransform }"> <div ref="items" class="infinite-list-item" v-for="item in visibleData" :key="item.id" :style="{ height: itemSize + 'px',lineHeight: itemSize + 'px' }" >{{ item.value }}</div> </div> </div> </template> <script> export default { name:'VirtualList', 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)); } }, mounted() { this.screenHeight = this.$el.clientHeight; this.start = 0; this.end = this.start + this.visibleCount; console.log("listData",this.listData) console.log("listHeight",this.listHeight) }, data() { return { //可视区域高度 screenHeight:0, //偏移量 startOffset:0, //起始索引 start:0, //结束索引 end:null, }; }, methods: { scrollEvent() { //当前滚动位置 let scrollTop = this.$refs.list.scrollTop; //此时的开始索引 this.start = Math.floor(scrollTop / this.itemSize); //此时的结束索引 this.end = this.start + this.visibleCount; //此时的偏移量 this.startOffset = scrollTop - (scrollTop % this.itemSize); } } }; </script> <style scoped> .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; } </style>