Vue3+Vite+VueRouter+Pinia+Axios+ElementPlus
前言:
在移动端项目中,我们通常会使用懒加载来加载列表,优点很明显,不需要一次将数据请求完,当用户下拉到底部时,才使用ajax动态从服务器拉取接下来的数据。但是这又导致了一个问题,如果用户疯狂进行下拉呢,这就会导致浏览器创建多个多余的节点,出现冗余,并且你拥有多少个节点,vue就会diff多少个节点,这样的场景会带来多余的性能消耗和内存占用。试想一下,如果我们能只渲染用户可视区域的节点,便能很好的解决这个问题,这便是虚拟滚动的产生背景。
虚拟滚动原理:
首先我们要知道,虚拟滚动是用到 Vue 的 v-for 实现的,上面也解释了,虚拟滚动是只渲染可视区域,那么我们可视区域的节点内容必然会随着用户的滚动条的改变而改变,假设一个页面就只能显示 n 个节点,那么我们如何让这n个需要变动的节点跟着滚动条动呢?
使用CSStransform:translateY(),这样,我们只需要让这n个节点跟着滚动条移动,我们滚动到哪里,节点替换到哪里。
要实现虚拟滚动你只需要知道以下条件:
- 1. 一个页面能显示多少个item?
页面容量 = 页面大小(clientHeight)/单个item大小
showNum = Math.floor(viewH / itemH) + 4 # 这里多设置几个防止滚动时候直接替换,所以 +4
- 2. 应该从哪个节点开始渲染?
我们假设现在滚动条滚动到了x的位置,我们是否就可以计算出这个x这个高度可以容纳多少个节点,进而得出应该从哪个节点开始渲染呢?答案是肯定的,js为我们提供了scrollTop这个属性来获取滚动条卷入的高度。
getCurStart(scrollTop){ // 卷去了多少个 return Math.floor(scrollTop/(itemHeight)); }
- 3. 什么时候渲染?
渲染时机也很简单,列表中第一个节点被完全卷入的时候我们需要执行渲染,因为这个时候被完全卷去的节点已经完全看不到了,我们需要将它顶下来然后再将其渲染成下一项的数据,这么说可能有点难理解,放张图吧~
如上图,当1被卷去的时候(完全离开我们的可视区),我们就利用css的translateY将它顶下来,渲染成2,你会发现发现在可视区域外多出来了一个节点,为了保证滑动的连贯性,你可以多设置几个冗余节点。
- 4. 如何渲染
这部分就是最核心的代码了。在这会出现一个问题,由于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.showNum); //这里的top用于设置translateY transform:translateY(${top}px) this.top=offsetY; } this.start=start; }
- 5. 优化处理
onScroll属高频触发回调,为了节约性能消耗,我们需对其加以限制,让其最短50ms触发一次。以下是封装节流函数。
# throttle.js export default function(fn, delay) { let lock = false; return (...args) => { if (lock) return; //进入加锁 lock = true; setTimeout(() => { fn.apply(this, args); //执行完毕解锁 lock = false; }, delay); } }
页面中滚动完整代码(Vue2 Options API):
<template> <div class="list" @scroll="scrollHandle" ref="list"> <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> </template> <script> import throttle from '@/utils/throttle'; export default { name: 'App', data() { return { list:[],//完整列表 itemHeight:60,//每一项的高度 renderList:[],//需要渲染的列表 start:0,//开始渲染的位置 showNum:0,//页面的容积:能装下多少个节点 top:0, scroll,//用于初始化节流 } }, mounted() { this.initList(); const cHeight=document.documentElement.clientHeight //计算页面能容纳下几个节点并且设置四个节点作为冗余 this.showNum=Math.floor(cHeight/this.itemHeight)+4; //设置要渲染的列表 设置成能够容纳下的最大元素个数 this.renderList=this.list.slice(0,this.showNum); //初始化节流函数 最短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,this.start+this.showNum); //这里的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>
容器中滚动代码(Vue3 Composition API)
<script setup> /** * 项目列表数据越来越多(上万条),正常列表可以分页,但是像下拉框之类组件就不能分页。每次都要加载所有的(很慢),性能不好的浏览器特别卡顿。虚拟滚动的技术完美解决。 * 主要用于无法使用分页功能的长列表首屏加载速度慢问题,DOM加载过多“无用”元素。 * 核心: * 1. 元素监听scroll事件 * 2. 计算可视化高度一次能装几个列表,然后从总数据中进行slice截取 * 3. 每一次滚动后根据scrollTop值获取一个可以整除itemH结果进行偏移 */ import { onMounted, reactive, ref } from 'vue'; const listEle = ref() // 上万条总数居 // const list = reactive(Array.apply(null, { length: 100000 }).map((v, i) => i)) const list = reactive(Array.from({ length: 100000 }).map((_, i) => { return { key: i, value: i + 1 } })) // 页面高度 const viewH = 800 // 单项高度 let itemH = 200 // 整个滚动列表高度 let scrollH = itemH * list.length // 可视化高度一次可装列表数量(多设置几个防止滚动时候直接替换) let showNum = Math.floor(viewH / itemH) + 4 // 页面上展示的数据 let showList = reactive(list.slice(0, showNum)) // 动态偏移量 let offsetY = ref(0) // 时间戳 let latestTime = new Date().getTime() onMounted(() => { }) let timer = ref(null) const handleScroll = (e) => { if (new Date().getTime() - latestTime > 10) { clearInterval(timer.value) timer.value = setTimeout(() => { // 获取卷去的高度 let scrollTop = e.target.scrollTop // 每一次滚动后,根据卷去高度 scrollTop 值,获取一个可以整除单项高度 itemH 的结果进行偏移 // 例如: 卷去的 scrllTop = 1020 1020 % itemH = 20 offsetY = 1000 offsetY.value = scrollTop - (scrollTop % itemH) // 更新数据:被卷去几条数据,就要往下增加几条数据 showList = list.slice(Math.floor(offsetY.value / itemH), Math.floor(offsetY.value / itemH) + showNum) // 更新时间戳 latestTime = new Date().getTime() console.log(showList) }, 300); } } </script> <template> <div :style="`height:${viewH}px;overflow-y:scroll;`" @scroll="handleScroll"> <ul ref="listEle"> <li v-for="item in showList" :key="item.key" :style="{ 'transform': `translateY(${offsetY}px)`, 'height': `${itemH}px`, 'line-height': `${itemH}px` }">{{ item.value }}</li> </ul> </div> </template> <style scoped> li { /* height: 200px; line-height: 200px; */ color: #fff; font-size: 50px; font-weight: bold; text-align: center; background-color: orange; } li:nth-of-type(odd) { background-color: blue; } </style>