前言
在移动端项目中,我们通常会使用懒加载来加载列表,优点很明显,不需要一次将数据请求完,当用户下拉到底部时,才使用ajax动态从服务器拉取接下来的数据。但是这又导致了一个问题,如果用户疯狂进行下拉呢,这就会导致浏览器创建多个多余的节点,出现冗余,并且你拥有多少个节点,vue就会diff多少个节点,这样的场景会带来多余的性能消耗和内存占用。试想一下,如果我们能只渲染用户可视区域的节点,便能很好的解决这个问题,这便是虚拟滚动的产生背景。
虚拟滚动的原理
首先我们要知道,虚拟滚动是用到Vue的v-for实现的,上面也解释了,虚拟滚动是只渲染可视区域,那么我们可视区域的节点内容必然会随着用户的滚动条的改变而改变,假设一个页面就只能显示n个节点,那么我们如何让这n个需要变动的节点跟着滚动条动呢?
使用CSS
transform:translateY()
,这样,我们只需要让这n个节点跟着滚动条移动,我们滚动到哪里,节点替换到哪里。
要实现虚拟滚动你只需要知道以下条件:
- 一个页面能显示多少个item?
- 应该从哪个节点开始渲染?
- 什么时候渲染?
- 如何渲染
一个页面能够显示多少个item?
页面容量=页面大小(clientHeight)/单个item大小
volume=Math.ceil(clientHeight/itemHeight)+4;
至于这里为什么要+4,后面会介绍
应该从哪个节点开始渲染?
我们假设现在滚动条滚动到了x的位置,我们是否就可以计算出这个x这个高度可以容纳多少个节点,进而得出应该从哪个节点开始渲染呢?答案是肯定的,js为我们提供了scrollTop
这个属性来获取滚动条卷入的高度。
getCurStart(scrollTop){
//卷去了多少个
return Math.floor(scrollTop/(itemHeight));
}
什么时候渲染?
渲染时机也很简单,列表中第一个节点被完全卷入的时候我们需要执行渲染,因为这个时候被完全卷去的节点已经完全看不到了,我们需要将它顶下来然后再将其渲染成下一项的数据,这么说可能有点难理解,放张图吧~
如上图,当1被卷去的时候(完全离开我们的可视区),我们就利用css的translateY将它顶下来,渲染成2,你会发现发现在可视区域外多出来了一个节点,为了保证滑动的连贯性,你可以多设置几个冗余节点。
如何渲染
这部分就是最核心的代码了。在这会出现一个问题,由于js并不是对每次都会对高频触发的回调进行响应,你如果不获取一个可以被itemHeight整除的偏移量,你极有可能拉回去的时候看到第一个节点的偏移量并不是0。
onScroll(){
//scrollTop常量记录当前滚动的高度
const scrollTop=this.$refs.list.scrollTop;
const start=this.getCurStart(scrollTop);
//对比上一次的开始节点 比较是否发生变化,发生变化后便重新渲染列表
if(this.start!=start){
//在这需要获得一个可以被itemHeight整除的数来作为item的偏移量
const offsetY = scrollTop - (scrollTop % this.itemHeight);
//使用slice拿到需要渲染的那一部分
this.renderList=this.list.slice(start,this.start+this.volume);
//这里的top用于设置translateY transform:translateY(${top}px)
this.top=offsetY;
}
this.start=start;
},
优化处理
onScroll属高频触发回调,为了节约性能消耗,我们需对其加以限制,让其最短50ms触发一次。以下是封装节流函数。
<template>
<div class="list" @scroll="scrollHandle" ref="list">
<div :style="`height: ${list.length * itemHeight}px;`">
<div class="item" v-for="(item, index) in renderList" :key="index" :style="`height:${itemHeight}px;line-height:${itemHeight}px;transform:translateY(${top}px)`">
{{ item }}
</div>
</div>
</div>
</template>
<script>
import { throttle } from 'lodash';
export default {
name: 'App',
data() {
return {
list: [], //完整列表
itemHeight: 60, //每一项的高度
renderList: [], //需要渲染的列表
start: 0, //开始渲染的位置
volume: 0, //页面的容积:能装下多少个节点
top: 0,
scroll //用于初始化节流
};
},
mounted() {
this.initList();
const cHeight = document.documentElement.clientHeight;
//计算页面能容纳下几个节点并且设置四个节点作为冗余
this.volume = Math.ceil(cHeight / this.itemHeight) + 4;
//设置要渲染的列表 设置成能够容纳下的最大元素个数
this.renderList = this.list.slice(0, this.volume);
//初始化节流函数 最短50毫秒触发一次
this.scroll = throttle(this.onScroll, 50);
},
methods: {
//初始化列表 ,循环渲染 500条
initList() {
for (let i = 0; i < 500; i++) {
this.list.push(i);
}
},
scrollHandle() {
this.scroll();
},
onScroll() {
//scrollTop常量记录当前滚动的高度
const scrollTop = this.$refs.list.scrollTop;
const start = this.getCurStart(scrollTop);
//对比上一次的开始节点 比较是否发生变化,发生变化后便重新渲染列表
if (this.start != start) {
//在这需要获得一个可以被itemHeight整除的数来作为item的偏移量
const offsetY = scrollTop - (scrollTop % this.itemHeight);
//使用slice拿到需要渲染的那一部分
this.renderList = this.list.slice(start, start + this.volume);
//这里的top用于设置translateY transform:translateY(${top}px)
this.top = offsetY;
this.start = start;
}
},
getCurStart(scrollTop) {
//卷去了多少个
return Math.floor(scrollTop / this.itemHeight);
}
}
};
</script>
<style>
* {
margin: 0;
padding: 0;
}
.list {
height: 100vh;
overflow: scroll;
}
.item {
text-align: center;
width: 100%;
box-sizing: border-box;
border-bottom: 1px solid lightgray;
}
</style>
<style>
*{
margin: 0;
padding: 0;
}
.list{
height: 100vh;
overflow: scroll;
}
.item{
text-align: center;
width: 100%;
box-sizing: border-box;
border-bottom: 1px solid lightgray;
}
</style>
结语
有错误欢迎指出,整篇下来纯手写,第一次写博客还请多担待。