长数据列表虚拟滚动
- 光谱数据包含大量列表项,纯渲染的话,对于浏览器性能将会是个极大的挑战,会造成滚动卡顿,整体体验非常不好,主要有以下问题:
- 页面等待时间极长,用户体验差
- CPU计算能力不够,滑动会卡顿
- GPU渲染能力不够,页面会跳屏
- RAM内存容量不够,浏览器崩溃
- 传统的懒加载方法,
随着加载数据越来越多,浏览器的回流和重绘的开销将会越来越大
核心思想
在处理用户滚动时,只改变列表在可视区域的渲染部分
由于光谱数据列表不包含图片之类的可变内容,故采用定高虚拟列表实现海量数据在同一页面中的高效渲染
使用express + Mock.js模拟光谱数据列表API接口
- 创建mymock本地服务器
js
// 使用mock构建本地服务器输出数据结果
const express = require('express');
const Mock = require("mockjs")
const app = express()
// 根据输入的参数num生成num条模拟的数据列表
function generatorList(num){
return Mock.mock({
[`list|${num}`]: [
{
// 属性 id 是一个自增数,起始值为 1,每次增 1
"id|+1": 1,
// 模拟标题,中文字符串长度为15到25
title: "@ctitle(15,25)",
// 模拟图片索引,自然数从0到15
image: "@natural(0,15)",
reads: "@natural(0,99999)",
from: "@ctitle(3,10)",
date: "@date('yyyy-MM-dd')",
},
],
});
}
// 允许跨域请求返回数据
app.all('*',function(req,res,next){
res.header("Access-Control-Allow-Origin","*")
res.header("Access-Control-Allow-Methods", "PUT,GET,POST,DELETE.OPTIONS");
res.header("Access-Control-Allow-Headers", "X-Ruquest-With");
res.header("Access-Control-Allow-Headers", "Content-Type");
next()
})
app.get('/data',function(req,res){
const {num } = req.query
return res.send(generatorList(num))
})
const server = app.listen(4000,()=>{
console.log('本地mock服务启动,接口地址为:http://localhost:4000/data?num=请求列表的数量');
})
- 获取到模拟的数据
搭建基本架构
js
<template>
<el-scrollbar height="800px">
<el-card>
<el-table :data="tableList" border style="width: 100%">
<!-- 索引 -->
<el-table-column prop="id" label="id"> </el-table-column>
<el-table-column prop="title" label="title"> </el-table-column>
<el-table-column prop="image" label="image"> </el-table-column>
<el-table-column prop="reads" label="reads"> </el-table-column>
<el-table-column prop="from" label="from"> </el-table-column>
<el-table-column prop="date" label="date"> </el-table-column>
</el-table>
</el-card>
</el-scrollbar>
</template>
<script setup>
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { ref } from 'vue'
const tableList = ref([])
const num = ref(20)
const isRequestStatus = ref(true)
const getMockData = (num) => {
isRequestStatus.value = true
// ElMessage('正在请求中。。。')
axios
.get('http://localhost:4000/data?num=' + num.value)
.then((res) => {
console.log(res.data.list)
tableList.value = res.data.list
isRequestStatus.value = false
// ElMessage('请求结束')
})
.catch((err) => {
window.console.log(err)
ElMessage('网络出错')
})
}
getMockData(num)
</script>
计算容器最大容积数量
- 简单来说,就是我们必须要知道在可视区域内最多能够容纳多少个列表项,这是我们在截取内容数据渲染到页面之前关键的步骤之一
- 获取滚动区域的高度值
srollHeight
(800px-20)- 获取单条列表的数据高度
oneHeight
(48px)- 获取可渲染的列表个数:
~~(srollHeight/oneHeight)
:~~
代替Math.floor
js
// 获取可渲染的列表项数:计算容器的最大容积
// 表格可视区的高度
const srollHeight = ref(800)
// 单个列表项的高度
const oneHeight = ref(48)
const cotainSize = ref(0)
const getContainSize = () => {
cotainSize.value = ~~(srollHeight.value / oneHeight.value)
}
监听滚动事件动态截取数据
- 设置滚动条滚动事件
handelScroll
js
<el-scrollbar ref="scrollbarRef" height="800px" @scroll="handelScroll">
- 计算当前滚动的第一个列表元素的索引
JavaScript
const startIndex = ref(0)
const handelScroll = ({ scrollTop }) => {
startIndex.value = ~~(scrollTop / oneHeight.value)
}
- 通过计算属性获取列表最后一个元素的索引,以及动态显示的数组列表
js
// 通过计算属性获取列表最后一个元素的索引
const getEndIndex = computed(() => {
let endIndex = startIndex.value + containSize.value
if (!tableList.value[endIndex]) {
endIndex = tableList.value.length - 1
}
return endIndex
})
// 定义可以动态显示的数组列表元素
const showDataList = computed(() => {
console.log(startIndex.value, getEndIndex.value)
return tableList.value.slice(startIndex.value, getEndIndex.value)
})
- 更改渲染的数据
js
<el-table :data="showDataList" border style="width: 100%">
- 此时重新刷新页面,可以从元素布局中得知初始渲染只渲染了16条而非20条(全部数据)
动态设置上下空白占位
思考:我们设置了根据容器滚动位移动态截取
ShowDataList
数据,现在我们滚动一下发现滚动2条列表数据后,就无法滚动了,这个原因是什么呢?
问题在容器滚动过程中,因为动态移除、添加数据节点丢失,进而强制清除了顶部列表元素DOM节点,导致滚动条定位向上移位一个列表元素高度,进而出现了死循环
解决方法
- 根据
startIndex
和endIndex
的位置,使用计算属性,动态的计算并设置上下空白填充的高度样式blankFillStyle
,使用padding
或者margin
进行空白占位都是可以的 - 定义上空白的高度以及下空白填充的高度
js
// 定义上空白的高度以及下空白填充的高度
const topBlankFill = computed(() => startIndex.value * oneHeight.value)
const bottomBlankFill = computed(() => (tableList.value.length - getEndIndex.value) * oneHeight.value)
- 设置空白区域,通过一个DIV盒子包裹住列表区域,设置好padding样式,此时可实现基础的虚拟列表效果,滑动闪动问题消失
- 简化写法,使用一个计算属性定义空白填充的样式
js
const blankFillStyle = computed(() => {
return {
paddingTop: startIndex.value * oneHeight.value + 'px',
paddingBottom:
(tableList.value.length - getEndIndex.value) * oneHeight.value + 'px'
}
})
[优化]下拉置地自动请求加载数据
- 首先判断滚动条触底的条件,触底后追加请求新的数据
js
const handelScroll = ({ scrollTop }) => {
startIndex.value = ~~(scrollTop / oneHeight.value)
if (startIndex.value + containSize.value > tableList.value.length - 1) {
console.log('滚动条到底了')
//追加请求新的数据
}
}
- 改写
getMockData
方法为getNewList
,原方法会直接覆盖掉初始请求数据,需要改写成异步函数
js
const getNewList = (num) => {
...
return axios
...
.then((res) => {
return res.data.list
})
...
}
// 初始时异步获取列表数据
const initTableList = async (num) => {
const newList = await getNewList(num)
if (!newList) return
tableList.value = newList
}
onMounted(() => {
initTableList(num)
getContainSize()
})
- 同理,追加数据也为异步【追加数据失败 原因待找】
js
const handelScroll = async ({ scrollTop }) => {
startIndex.value = ~~(scrollTop / oneHeight.value)
if (startIndex.value + containSize.value > tableList.value.length - 1) {
// console.log('滚动条到底了')
// 追加请求新的数据
const addList = await getNewList(20)
if (!addList) return
tableList.value = [...tableList.value, ...addList]
}
}
此处将固定请求数进行更改即可追加数据
const addList = await getNewList(num)
- 出现问题,数据还没请求过来,需要判断滚动状态,如果仍然还在请求数据,就不用追加新数据
js
if (startIndex.value + containSize.value > tableList.value.length - 1 && !isRequestStatus.value) {
...
}
- 再进行优化,如果滚动条滚动,但是范围仅在单个列表中细微滚动的话,不需要进行任何操作,设置当前滚动的顶部索引
currentIndex
js
const handelScroll = async ({ scrollTop }) => {
const currentIndex = ~~(scrollTop / oneHeight.value)
if (startIndex.value === currentIndex) return
startIndex.value = currentIndex
...
}
[优化]滚动事件截流定时器优化
思考:监听滚动事件触发对于函数方法的频率是极高的,该如何做好页面节流优化呢?
在滚动事件中打印日志发现触发事件频率很高
解决方法
- 记录当前滚动有效的状态
isScrollStatus
js
// 记录当前滚动有效的状态
const isScrollStatus = ref(true)
- 若状态有效,才进行滚动事件执行,并且设置一个定时器,1s后改变滚动状态,即1s内才触发滚动事件
js
const handelScroll = async ({ scrollTop }) => {
if (isScrollStatus.value) {
isScrollStatus.value = false
// 设置一个定时器,1s钟后,才允许进行下一次的scroll滚动事件行为
const timer = setTimeout(() => {
isScrollStatus.value = true
// 清空定时器,为了避免定时器冗余
clearTimeout(timer)
}, 1000)
const currentIndex = ~~(scrollTop / oneHeight.value)
...
}
}
- 将设置数据的相关任务,即滚动事件的具体行为包装成函数
js
// 将设置数据的相关任务,即滚动事件的具体行为包装成函数
const setDataStartIndex = async (scrollTop) => {
const currentIndex = ~~(scrollTop / oneHeight.value)
if (startIndex.value === currentIndex) return
startIndex.value = currentIndex
if (
startIndex.value + containSize.value > tableList.value.length - 1 &&
!isRequestStatus.value
) {
// console.log('滚动条到底了')
// 追加请求新的数据
const addList = await getNewList(num)
// console.log(addList)
if (!addList) return
tableList.value = [...tableList.value, ...addList]
}
}
问题
此时却出现了滑动白屏现象,主要原因是之前设置的空白区域,在定时器时长还未结束就显示出来了,即定时器时长设置不合理
- 注意到一般屏幕的响应时间是60hz,1s的话,每次屏幕闪烁时间差不多是13.3ms,至少应该高于这个值,一般设置为30ms左右能达到整体的一个稳定效果
js
const timer = setTimeout(() => {
isScrollStatus.value = true
clearTimeout(timer)
}, 30)
[优化]使用动画帧对滚动事件节流优化
使用定时器的缺点就是,它不管你界面是否渲染出来,都会计时 上述问题有没有更好使节流效果达到最优的解决方法呢?可以使用动画帧
requestAnimationFrame
,能够确保每一次回调前页面都已经渲染完成
- 首先判断浏览器的兼容性,对requestAnimationFrame进行兼容性处理
js
const handelScroll = ({ scrollTop }) => {
const requestAnimationFrame =
window.requestAnimationFrame ||
window.webkitrequestAnimationFrame ||
window.mozrequestAnimationFrame ||
window.msrequestAnimationFrame
...
}
- 计算时间间隔以及时间戳,递归调用动画帧函数,确保上一帧页面已经渲染到页面上。
js
const fps = 30
const interval = 1000 / 30
let then = Date.now()
requestAnimationFrame(() => {
const now = Date.now()
setDataStartIndex()
if (now - then >= interval) {
then = now
requestAnimationFrame(arguments.callee)
}
})
问题【待解决】:
[优化]设置上下滚动缓冲消除快速滚动白屏
快速向上滑动会出现白屏问题(视频中出现了,我影响不大),电脑系统好,所以问题不大,但是要考虑其他性能问题。
出现问题的原因是,当快速滚动时,上下的空白区域会先显示,然后才会将数据填充到可视区域。而之前的方法采用都是懒加载的思想,而我们同时也要考虑预加载问题,即将上下区域的部分数据预加载。
- 待显示的数组列表元素中重新设置开始列表的索引
js
const showDataList = computed(() => {
let preStartIndex = 0
if (startIndex.value <= containSize.value) {
preStartIndex = 0
} else {
preStartIndex = startIndex.value - containSize.value
}
return tableList.value.slice(preStartIndex, getEndIndex.value)
})
- 同时将计算结尾属性的值更改
js
const getEndIndex = computed(() => {
let endIndex = startIndex.value + containSize.value * 2
...
return endIndex
})
- 更新空白区域的显示
js
const blankFillStyle = computed(() => {
let preStartIndex = 0
if (startIndex.value <= containSize.value) {
preStartIndex = 0
} else {
preStartIndex = startIndex.value - containSize.value
}
...
})
- 此时,去观察可以发现数据请求变多了
路由切换定位列表滚动位置
切换路由时,返回列表页面仍然是在上次浏览的位置
- 在主页面包裹
keep-alive
,并没有生效
js
<div class="role">
<keep-alive>
<tableList></tableList>
</keep-alive>
</div>
- 利用生命周期钩子,此时需要记录当前滚动条距离顶部的位移,当激活时,将滚动条的距离赋值重新赋值,该操作为DOM操作,为了确保DOM渲染完才执行,需要在
nextTick
中执行
js
const setDataStartIndex = async (scrollTop) => {
currentScrollTop.value = scrollTop
...
}
const scrollbarRef = ref(null)
onActivated(() => {
nextTick(() => {
console.log(currentScrollTop.value)
scrollbarRef.value.setScrollTop(currentScrollTop.value)
})
})
在路由中设置页面是否要使用缓存
js
meta: { keepAlive: true //设置页面是否需要使用缓存 },