你知道的越多,你不知道的越多
点赞
再看,手留余香,与有荣焉javascript
在工做中,有时会遇到须要一些不能使用分页方式来加载列表数据的业务状况,对于此,咱们称这种列表叫作长列表
。好比,在一些外汇交易系统中,前端会实时的展现用户的持仓状况(收益、亏损、手数等),此时对于用户的持仓列表通常是不能分页的。css
在高性能渲染十万条数据(时间分片)一文中,提到了可使用时间分片
的方式来对长列表进行渲染,但这种方式更适用于列表项的DOM结构十分简单的状况。本文会介绍使用虚拟列表
的方式,来同时加载大量数据。html
假设咱们的长列表须要展现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
。vue
简单说明一下,为什么两次console.log
的结果时间差别巨大,而且是如何简单来统计JS运行时间
和总渲染时间
:java
Event Loop
中,当JS引擎所管理的执行栈中的事件以及全部微任务事件所有执行完后,才会触发渲染线程对页面进行渲染console.log
的触发时间是在页面进行渲染以前,此时获得的间隔时间为JS运行所须要的时间console.log
是放到 setTimeout 中的,它的触发时间是在渲染完成,在下一次Event Loop
中执行的关于Event Loop的详细内容请参见这篇文章-->node
而后,咱们经过Chrome
的Performance
工具来详细的分析这段代码的性能瓶颈在哪里:react
从Performance
能够看出,代码从执行到渲染结束,共消耗了960.8ms
,其中的主要时间消耗以下:git
40.84ms
105.08ms
731.56ms
58.87ms
15.32ms
从这里咱们能够看出,咱们的代码的执行过程当中,消耗时间最多的两个阶段是Recalculate Style
和Layout
。github
Recalculate Style
:样式计算,浏览器根据css选择器计算哪些元素应该应用哪些规则,肯定每一个元素具体的样式。Layout
:布局,知道元素应用哪些规则以后,浏览器开始计算它要占据的空间大小及其在屏幕的位置。在实际的工做中,列表项必然不会像例子中仅仅只由一个li标签组成,必然是由复杂DOM节点组成的。
那么能够想象的是,当列表项数过多而且列表项结构复杂的时候,同时渲染时,会在Recalculate Style
和Layout
阶段消耗大量的时间。
而虚拟列表
就是解决这一问题的一种实现。
虚拟列表
实际上是按需显示的一种实现,即只对可见区域
进行渲染,对非可见区域
中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。
假设有1万条记录须要同时渲染,咱们屏幕的可见区域
的高度为500px
,而列表项的高度为50px
,则此时咱们在屏幕中最多只能看到10个列表项,那么在首次渲染的时候,咱们只需加载10条便可。
说完首次加载,再分析一下当滚动发生时,咱们能够经过计算当前滚动值得知此时在屏幕可见区域
应该显示的列表项。
假设滚动发生,滚动条距顶部的位置为150px
,则咱们可得知在可见区域
内的列表项为第4项
至`第13项。
虚拟列表的实现,实际上就是在首屏加载的时候,只加载可视区域
内须要的列表项,当滚动发生时,动态经过计算得到可视区域
内的列表项,并将非可视区域
内存在的列表项删除。
可视区域
起始数据索引(startIndex
)可视区域
结束数据索引(endIndex
)可视区域的
数据,并渲染到页面中startIndex
对应的数据在整个列表中的偏移位置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 * itemSizevisibleCount
= Math.ceil(screenHeight / itemSize)startIndex
= Math.floor(scrollTop / itemSize)endIndex
= startIndex + visibleCountvisibleData
= listData.slice(startIndex,endIndex)当滚动后,因为渲染区域
相对于可视区域
已经发生了偏移,此时我须要获取一个偏移量startOffset
,经过样式控制将渲染区域
偏移至可视区域
中。
startOffset
= scrollTop - (scrollTop % itemSize);最终的简易代码
以下:
<template> <div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)"> <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> 复制代码
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; }, 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); } } }; 复制代码
最终效果以下:
在以前的实现中,列表项的高度是固定的,由于高度固定,因此能够很轻易的获取列表项的总体高度以及滚动时的显示数据与对应的偏移量。而实际应用的时候,当列表中包含文本之类的可变内容,会致使列表项的高度并不相同。
好比这种状况:
在虚拟列表中应用动态高度的解决方案通常有以下三种:
1.对组件属性
itemSize
进行扩展,支持传递类型为数字
、数组
、函数
这种方式虽然有比较好的灵活度,但仅适用于能够预先知道或能够经过计算得知列表项高度的状况,依然没法解决列表项高度由内容撑开的状况。
2.将列表项
渲染到屏幕外
,对其高度进行测量并缓存,而后再将其渲染至可视区域内。
因为预先渲染至屏幕外,再渲染至屏幕内,这致使渲染成本增长一倍,这对于数百万用户在低端移动设备上使用的产品来讲是不切实际的。
3.以
预估高度
先行渲染,而后获取真实高度并缓存。
这是我选择的实现方式,能够避免前两种方案的不足。
接下来,来看如何简易的实现:
定义组件属性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.positions[k-1].bottom; this.positions[k].bottom = this.positions[k].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 = parseInt((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 id = 0; id < 10000; id++) { data.push({ id, 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.aboveCount; let end = this.end + this.belowCount; return this._listData.slice(start, end); } 复制代码
最终效果以下:
基于这个方案,我的开发了一个基于Vue2.x的虚拟列表组件:vue-virtual-listview,可点击查看完整代码。
在前文中咱们使用监听scroll事件
的方式来触发可视区域中数据的更新,当滚动发生后,scroll事件会频繁触发,不少时候会形成重复计算
的问题,从性能上来讲无疑存在浪费的状况。
可使用IntersectionObserver替换监听scroll事件,IntersectionObserver
能够监听目标元素是否出如今可视区域内,在监听的回调事件中执行可视区域数据的更新,而且IntersectionObserver
的监听回调是异步触发,不随着目标元素的滚动而触发,性能消耗极低。
咱们虽然实现了根据列表项动态高度下的虚拟列表,但若是列表项中包含图片,而且列表高度由图片撑开,因为图片会发送网络请求,此时没法保证咱们在获取列表项真实高度时图片是否已经加载完成,从而形成计算不许确的状况。
这种状况下,若是咱们能监听列表项的大小变化就能获取其真正的高度了。咱们可使用ResizeObserver来监听列表项内容区域的高度改变,从而实时获取每一列表项的高度。
不过遗憾的是,在撰写本文的时候,仅有少数浏览器支持ResizeObserver
。
欢迎关注微信公众号
【前端小黑屋】
,每周1-3篇精品优质文章推送,助你走上进阶之旅
同时欢迎加我好友,回复
加群
,拉你入群,和我一块儿学前端~