一个长列表 Web 页面,如果需要展示成千上万条数据,那么页面中就会有数万甚至数十万的HTML节点,会巨大的消耗浏览器性能,进而给用户造成非常不友好的体验。
主要体现在以下几个方面:
页面等待时间极长,用户体验差;
CPU 计算能力不够,滑动会卡顿;
GPU 渲染能力不够,页面会跳屏;
RAM 内存容量不够,浏览器崩溃。
优化方案:
不把长列表数据一次性全部直接显示在页面上;
截取长列表一部分数据用来填充屏幕容器区域;
长列表数据不可视部分使用使用空白占位填充;
监听滚动事件根据滚动位置动态改变可视列表;
监听滚动事件根据滚动位置动态改变空白填充。
把上面的优化方案简称为虚拟滚动。
虚拟滚动,就是根据 「 容器可视区域 」 的 「 列表容积数量 」,监听用户滑动或者滚动事件,动态截取 「 长列表数据 」 中的 「 部分数据 」 渲染到页面上,动态使用空白占位填充容器 「上下滚动区域内容 」 ,模拟实现 「 原生滚动效果 」。
VirtualScroll.vue
文件
<template>
<!-- wrapper可视容器需要设置overflow-y:auto;才能监听滚动事件,在父组件使用该组件时,需要设置wrapper可视容器的区域范围 -->
<div class="wrapper" @scroll.passive="scrollHandler" ref="wrapper">
<!-- content填充要显示内容以及上下空白占位 -->
<div class="content" :style="blankFillStyle">
<div v-for="(item, index) in showDataList" :key="index">
<!-- 每条数据的内容结构通过插槽的方式让父组件调用该组件时填充进来 -->
<slot :row="item"></slot>
</div>
</div>
</div>
</template>
<script>
export default {
props:{
// 一条数据内容的高度
oneDataHeight: {
type: Number,
default: 0
},
// 源数据列表
sourceDataList: {
type: Array,
default: () => []
},
// 是否向外触发滚动至底事件(scroll-last)
scrollLastFlag: {
type: Boolean,
default: false
},
// 是否向外触发滚动事件(scroll),会传出滚动的位移
scrollFlag: {
type: Boolean,
default: false
}
},
name: 'VirtualScroll',
data(){
return{
// 可视屏幕容积数量
screenContainSize: 0,
// 当前可视数据起始位置索引
startIndex: 0,
// 滚动事件触发执行的函数
scrollFn: null,
// 保存滚动的位移
scrollY: 0
}
},
mounted(){
this.$nextTick(()=>{
// 挂载后,根据可视容器高度计算可视屏幕容积数量
this.myResize()
// 屏幕尺寸变化以及横屏,都要重新计算可视屏幕容积数量
window.onresize = this.myResize;
window.onorientationchange = this.myResize;
// 通过定时器节流处理生成的函数,用于处理滚动事件
// this.scrollFn = this.throttle(this.setStartIndex, 17);
})
},
methods:{
// 根据可视容器高度计算可视屏幕容积数量
myResize(){
// 两次取反可取整,上下有多余空间,因此需要加2条数据
this.screenContainSize = ~~(this.$refs.wrapper.offsetHeight / this.oneDataHeight) + 2;
},
// 定时器节流函数
throttle(fn, delay){
let timer = null;
return function(...args){
if(!timer){
timer = setTimeout(()=>{
fn.apply(this, args);
clearTimeout(timer);
timer = null;
}, delay);
}
}
},
// 滚动事件处理
scrollHandler(){
// 1.定时器节流方式
// 定时器节流,因为定时时间是设定死的,无法根据设备屏幕刷新率相匹配;
// 如果定时时间设置高了,对于高刷新率设备屏幕来说,当滚动速度很快时,这个定时节流就是个累赘,数据处理速率慢,很容易出现白屏现象
// this.scrollFn();
// 2.请求动画帧节流方式
// 请求动画帧函数是根据设备屏幕的刷新率来设置回调函数执行的时间间隔的,效果上比定时器节流要好很多
const fps = 30; //屏幕刷新率为30hz
const interval = parseInt(1000 / fps); //每次的时间间隔
let then = Date.now();
// 定义请求动画帧回调函数
const callback = () => {
const now = Date.now();
this.setStartIndex();
// 兼容低刷新率设备,如果屏幕刷新率低于30hz,递归执行回调函数
if(now - then >= interval){
then = now;
window.requestAnimationFrame(callback);
}
}
window.requestAnimationFrame(callback);
},
// 根据滚动的位移计算当前数据起始位置索引
setStartIndex(){
this.scrollY = this.$refs.wrapper.scrollTop;
if(this.scrollFlag){
this.$emit('scroll', this.scrollY);
}
let currentIndex = ~~(this.scrollY / this.oneDataHeight);
// 如果上一次的startIndex与现在的startIndex相等,直接返回,无须处理
if(currentIndex === this.startIndex) return;
this.startIndex = currentIndex;
// 滚动至底向外发布事件
if(this.endIndex >= this.sourceDataList.length && this.scrollLastFlag){
this.$emit('scroll-last');
}
},
// 设置滚动到具体的位置,delay:过渡时间
scrollTo(val, delay = 0){
if(delay === 0) {
this.$refs.wrapper.scrollTop = val;
return;
}
const ms = Math.ceil(delay / 5);
const timer = setInterval(() => {
const scrollTop = this.$refs.wrapper.scrollTop;
const speed = Math.ceil((scrollTop - val) / 5);
this.$refs.wrapper.scrollTop = scrollTop - speed <= val ? val : scrollTop - speed;
if(this.$refs.wrapper.scrollTop === val){
clearInterval(timer);
}
}, ms);
},
// 获取滚动位移
scrollOffset(){
return this.scrollY;
}
},
computed:{
// 当前可视数据结束位置索引
endIndex(){
// 屏幕下方加一屏缓冲数据,以消除因向上滚动过快而出现的白屏现象
// let endIndex = this.startIndex + this.screenContainSize;
let endIndex = this.startIndex + this.screenContainSize * 2;
// 如果endIndex位置索引数据不存在,则就等于源数据的长度
if(!this.sourceDataList[endIndex]){
endIndex = this.sourceDataList.length;
}
return endIndex;
},
// 当前在屏幕上要展示的数据列表
showDataList(){
// 屏幕上方留一屏缓冲数据,以消除因向下滚动过快而出现的白屏现象
let startIndex = this.startIndex;
if(startIndex < this.screenContainSize){
startIndex = 0;
}else{
startIndex = this.startIndex - this.screenContainSize;
}
// 截取要展示的数据
return this.sourceDataList.slice(startIndex, this.endIndex)
},
// 计算上下空白占位填充
blankFillStyle(){
// 上方因为留了一屏缓冲数据,因此滚动过一屏数据后才开始计算上方空白占位
let startIndex = this.startIndex;
if(startIndex < this.screenContainSize){
startIndex = 0;
}else{
startIndex = this.startIndex - this.screenContainSize;
}
return{
paddingTop: startIndex * this.oneDataHeight + 'px',
paddingBottom: (this.sourceDataList.length - this.endIndex) * this.oneDataHeight + 'px'
}
}
}
}
</script>
<style scoped>
.wrapper{
overflow-y: auto;
}
</style>
1.使用组件时需要给组件的根元素节点设置可视滚动区域范围,即css样式
2.只能用于垂直方向上的滚动
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
one-data-height | 一条数据内容的高度 | Number | 0 |
source-data-list | 所有数据列表 | Array | [],空数组 |
scroll-flag | 是否向外触发scroll事件 | Boolean | false |
scroll-last-flag | 是否向外触发scroll-last事件 | Boolean | false |
事件 | 说明 | 回调参数 |
---|---|---|
scroll | 滚动事件,由参数scroll-flag控制 | 滚动位移 |
scroll-last | 滚动至底事件,由参数scroll-last-flag控制 | —— |
方法 | 说明 | 参数 |
---|---|---|
scrollTo | 设置滚动的目标位置,参数val为要设置的滚动位置数值,参数delay为滚动到目标位置的过渡时间,默认为0,单位为ms | function(val, delay) |
scrollOffset | 获取滚动位移,方法返回滚动位移的数值 | —— |
slot名称 | 说明 |
---|---|
—— | 自定义每条数据的内容,参数为 {row} |
在vue项目的plugins插件目录下创建virtual-scroll目录,并将VirtualScroll.vue文件放入该目录下,然后创建index.js文件。
index.js
文件
import VirtualScroll from './VirtualScroll.vue'
function install(Vue){
Vue.component('VirtualScroll', VirtualScroll);
}
export default{
install
}
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import VirtualScroll from './plugins/virtual-scroll'
Vue.use(VirtualScroll)
Vue.config.productionTip = false
new Vue({
router,
render: h => h(App)
}).$mount('#app')
<template>
<div class="home">
<div class="header">导航栏</div>
<virtual-scroll
class="virtual-scroll"
:source-data-list="newsList"
:one-data-height="100"
@scroll-last="getNewsList"
@scroll="scrollHandler"
:scroll-last-flag="true"
:scroll-flag="true"
ref="vscroll">
<template v-slot:default="slotProps">
<news-item :news-item="slotProps.row"></news-item>
</template>
</virtual-scroll>
<div class="back-top" @click="backTopClick" v-show="isShow">
<img :src="backTopImg" alt="">
</div>
</div>
</template>
<script>
import NewsItem from '@/components/context/NewsItem.vue'
import backTopImg from '@/assets/img/backtop.png'
export default {
name: 'Home',
components: {
NewsItem
},
data(){
return {
// 新闻列表
newsList: [],
// 返回顶部图片
backTopImg,
// 返回顶部按钮是否显示
isShow: false,
// 保存滚动位移
scrollY: 0
}
},
created(){
this.getNewsList()
},
activated(){
// 返回本路由时跳转到之前保存的滚动位置
this.$refs.vscroll.scrollTo(this.scrollY);
},
deactivated(){
// 跳转别的路由前保存滚动位移
this.scrollY = this.$refs.vscroll.scrollOffset();
},
methods:{
// 获取新闻列表
getNewsList(){
// $http为加在Vue原型上的axios
this.$http('http://localhost:4000/news?num=30').then(res => {
this.newsList = [...this.newsList, ...res.data.list];
}).catch(err => {
console.log(err)
})
},
// 返回顶部事件处理
backTopClick(){
this.$refs.vscroll.scrollTo(0, 300);
},
// 滚动事件处理
scrollHandler(val){
this.isShow = val > 1000 ? true : false;
}
}
}
</script>
<style lang="less" scoped>
.home{
height: 100%;
display: flex;
flex-direction: column;
.header{
height: 45px;
line-height: 45px;
background-color:tomato;
text-align: center;
}
// 设置虚拟滚动可视区域范围
.virtual-scroll{
flex: 1;
}
.back-top{
position: fixed;
right: 15px;
bottom: 25px;
width: 30px;
height: 30px;
z-index: 9;
img{
width: 100%;
height: 100%;
}
}
}
</style>