vue性能优化之虚拟列表滚动
一、前言
前端的性能瓶颈那就是页面的卡顿,当然这种页面的卡顿包含了多种原因。
例如HTTP请求过多导致数据加载变慢,下载的静态文件非常大导致页面加载时间很长,js中一些算法响应的时间过长等。很多前端工程师都花费很多的精力在dom渲染上来优化页面加载。
二、浏览器渲染瓶颈
首先大家要明重绘和回流(重排)的概念:
-
重绘(repaint):当Render Tree 中的一些元素需要更新元素本身的属性,只影响外观样式和颜色等,不影响整个布局。
-
回流(reflow):当Render Tree 中的某些元素因为规模、尺寸、位置等改变时,会影响整个布局。
回流必定发生重绘,重绘不一定发生回流
所以大家可以知道,回流所造成的影响是比较大的,如果页面中频繁的触发回流的操作,那么最终造成页面卡顿也是肯定的。
造成回流和重绘的操作有以下类别:
- 页面初始化
- 添加或者删除页面上的可视区DOM元素
- 元素位置发生改变,定位和浮动,盒模型
- 页面文本内容发生变化,影响输入框的大小改变。
- 图片显示加载,如果没有加载图片又会被替换成相应提示文字信息。
- 浏览器窗口尺寸大小变化(回流是根据视口大小来计算页面元素的位置和大小)
其实对于这些需要考虑的因素,一些浏览器也是做出了相应的处理,因为每次回流可能会造成巨大的影响,浏览器本身会实现一个队列记录每次回流时操作,当存放的操作数量达到一定值或者达到一定时间后会对队列中的操作进行清空,并一次性进行一次回流,让多次回流操作压缩成一次回流操作执行,提高效率。
浏览器的瓶颈主要在于:
- 无法一次性渲染太多的DOM元素。
- 每次滚动事件将会让对应的DOM中所有元素重新渲染。
针对于浏览器的瓶颈问题,有三种解决办法:数据分页、无限滚动、虚拟滚动
三、数据分页
许多网页和应用程序都会用到这样的方,对需要展示的大量数据进行分割分页,后端已经做好了分页,前端只需要调用后端的接口传入相应的第几页的参数就能获取到,减少了一次性需要渲染的行数,但是如果查询的表列数非常多,还是可能会渲染很多元素,不是一个很稳定的方法。
四、无限滚动
优点很明显,不需要一次将数据请求完,当用户下拉到底部时,才使用ajax动态从服务器拉取接下来的数据。但是这又导致了一个问题,如果用户疯狂进行下拉呢,这就会导致浏览器创建多个多余的节点,出现冗余,并且你拥有多少个节点,vue就会diff多少个节点,这样的场景会带来多余的性能消耗和内存占用。
五、虚拟滚动
虚拟滚动其实就是综合数据分页和无限滚动的方法,在有限的视口中只渲染我们所能看到的数据,超出视口之外的数据就不进行渲染,可以通过计算可视范围内的但单元格,保证每一次滚动渲染的DOM元素都是可以控制的,不会担心像数据分页一样一次性渲染过多,也不会发生像无限滚动方案那样会存在数据堆积,是一种很好的解决办法。
六、虚拟滚动的原理
虚拟列表实际上就是使用少量的DOM节点显示长列表,即只创建并且显示我们视野中看到item节点,滚动过程中通过算法运算把视野中的节点更新成对应的节点。
7、实现思路
与懒加载不同,虚拟滚动需要一次性获取所有数据,但是只显示屏幕可见范围内的数据。
算了 不写这么多了
直接上代码吧 不难 看得懂 有注释
定义变量
const demo = ref(null) // 外框盒子
const showNumber = 5 // 当前视窗展示条数
const itemHeight = 40 // 每一条内容的高度
const data = createData(1000) // 实际数据
let startNum = ref(0) // 当前视窗范围内第一个元素下标
let positionTop = ref(0) // 当前视窗范围内第一个元素偏移量
let lastTime = ref(0) // 最新的时间
滚动
<div ref="demo" class="scroll-box" :style="`height: ${showNumber * itemHeight}px;`"> // 可视区
<div class="scroll-blank" :style="`height: ${data.length * itemHeight}px;`"> // 占位 使出现滚动
<div class="scroll-data" :style="`top: ${positionTop}px;`"> // 渲染区域
<div v-for="(item, index) in activeList" :key="item" class="scroll-item"> // 渲染的每一列
{{ item }}
</div>
</div>
</div>
</div>
<style>
.scroll-box {
margin: 200px auto;
position: relative;
overflow: auto;
width: 400px;
border: 1px solid rgb(0, 0, 0);
}
.scroll-data {
position: absolute;
width: 100%;
}
.scroll-item {
box-sizing: border-box;
border: 1px solid #fff;
height: 40px;
background: pink;
}
.scroll-item:hover {
background: rgb(104, 111, 211);
color: #fff;
}
</style>
虚拟
仅渲染当前视窗内的内容,而对于超出的部分则进行移除
// 计算当前视窗内实际要渲染的内容
const activeList = computed(() => {
const start = startNum.value
return data.slice(start, start + showNumber)
})
什么时候对渲染的数据进行替换?
对外框盒子添加 scroll 的监听事件,在滚动的时候获取scrollTop的值并计算当前视窗范围内第一个元素的下标
// 滚动的时候计算当前视窗范围内第一个元素下标
const scrollEvent = (event) => {
if (new Date().getTime() - lastTime > 10){
const { scrollTop } = event.target
startNum.value = parseInt(scrollTop / itemHeight)
console.log(scrollTop, startNum.value)
positionTop.value = scrollTop
// positionTop.value = scrollTop - ( scrollTop % itemHeight)
lastTime = new Date().getTime() //更新最新时间
}
}
onMounted(() => {
lastTime = new Date().getTime()
demo.value.addEventListener('scroll', scrollEvent)
})
onUnmounted(() => {
if (!demo.value) return
demo.value.removeEventListener('scroll', scrollEvent)
demo.value = null
})
完整代码
跑跑试试吧
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://unpkg.com/vue@3.2.27/dist/vue.global.js"></script>
<title>VirtualScroll</title>
</head>
<body>
<div id="app">
<div
ref="demo"
class="scroll-box"
:style="`height: ${showNumber * itemHeight}px;`"
>
<div class="scroll-blank" :style="`height: ${data.length * itemHeight}px;`">
<div class="scroll-data" :style="`top: ${positionTop}px;`">
<div v-for="(item, index) in activeList" :key="item" class="scroll-item">
{{ item }}
</div>
</div>
</div>
</div>
</div>
<script>
const { computed, onMounted, onUnmounted, ref } = Vue
const createData = (length) => {
return Object.keys(new Array(length).fill(''))
}
const App = {
setup() {
const demo = ref(null) // 外框盒子
const showNumber = 5 // 当前视窗展示条数
const itemHeight = 40 // 每一条内容的高度
const data = createData(1000) // 实际数据
let startNum = ref(0) // 当前视窗范围内第一个元素下标
let positionTop = ref(0) // 当前视窗范围内第一个元素偏移量
let lastTime = ref(0) // 最新的时间
// 计算当前视窗内实际要渲染的内容
const activeList = computed(() => {
const start = startNum.value
return data.slice(start, start + showNumber)
})
console.log(activeList.value)
// 滚动的时候计算当前视窗范围内第一个元素下标
const scrollEvent = (event) => {
if (new Date().getTime() - lastTime > 10){
const { scrollTop } = event.target
startNum.value = parseInt(scrollTop / itemHeight)
console.log(scrollTop, startNum.value)
positionTop.value = scrollTop
// positionTop.value = scrollTop - ( scrollTop % itemHeight)
lastTime = new Date().getTime() //更新最新时间
}
}
onMounted(() => {
lastTime = new Date().getTime()
demo.value.addEventListener('scroll', scrollEvent)
})
onUnmounted(() => {
if (!demo.value) return
demo.value.removeEventListener('scroll', scrollEvent)
demo.value = null
})
return {
showNumber,
itemHeight,
demo,
positionTop,
data,
activeList,
}
},
}
const app = Vue.createApp(App)
app.mount('#app')
</script>
<style>
.scroll-box {
margin: 200px auto;
position: relative;
overflow: auto;
width: 400px;
border: 1px solid rgb(0, 0, 0);
}
.scroll-data {
position: absolute;
width: 100%;
}
.scroll-item {
box-sizing: border-box;
border: 1px solid #fff;
height: 40px;
background: pink;
}
.scroll-item:hover {
background: rgb(104, 111, 211);
color: #fff;
}
</style>
</body>
</html>
再看另外一个demo
父组件
<template>
<div class="personSocial">
<ScrollComponent :data="dataList" :viewH="viewH" :itemH="itemH" />
</div>
</template>
export default {
data () {
return {
dataList: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33],
viewH: 200,
itemH: 40
}
},
}
子组件
<template>
<!-- 可视区盒子 -->
<div :style="`height:${viewH}px;overflow-y:scroll`" @scroll="handleScroll" class="container1">
<div :style="`height:${scrollH}px`" class="list">
<div class="item_box" :style="`transform:translateY(${offsetY}px);`">
<div class="item" :style="`height:${itemH}px`" v-for="(item,index) in list" :key="index">
{{ item }}
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ScrollComponent',
props: {
data: Array, // 列表总数据
viewH: Number, // 外部高度
itemH: Number, // 单项高度
},
data () {
return {
scrollH: '', // 整个滚动列表高度(总高度)
list: [], // 每次显示的数据
showNum: '', // 页面需要显示的数量
offsetY: '',// 动态偏移量- 外层的盒子进行滚动设置
lastTime: '', //最新的时间
}
},
mounted () {
// 初始化计算
this.scrollH = this.data.length * this.itemH
// 计算可视化高度中能存几个列表,可以略多余可视化高度能存放的列表数量避免滚动时被替换
this.showNum = Math.floor(this.viewH / this.itemH) + 1
console.log(this.showNum);
// 默认展示的几个数据
this.list = this.data.slice(0, this.showNum)
this.lastTime = new Date().getTime()
},
methods: {
// handleScroll 滚动时候触发回调
handleScroll (e) {
// 控制滚动时间间隔
if (new Date().getTime() - this.lastTime > 10) {
let scrollTop = e.target.scrollTop //滚动的高度
// 每一次滚动后 根据scrollTop值获取一个可以整除itemH结果进行偏移
// 例如:scrollTop = 1220,1220 % this.itemH = 20 offsetY = 1220-20 = 1200
this.offsetY = scrollTop - ( scrollTop % this.itemH )
console.log(scrollTop, this.offsetY);
console.log('卷入scrollTop值:', scrollTop, '卷入的行数:', Math.floor(scrollTop / this.itemH));
this.list = this.data.slice(
Math.floor(scrollTop / this.itemH), // 计算卷入了多少行
Math.floor(scrollTop / this.itemH) + this.showNum
)
console.log(this.list);
this.lastTime = new Date().getTime() //更新最新时间
}
}
}
}
</script>
<style scoped>
.container1 {
position: relative;
background: #f1fffe;
/* top: 50px; */
/* left: 500px; */
margin: 0 auto;
border: 1px solid #fff;
width: 500px;
}
.item {
border: 1px solid pink;
}
</style>
https://mp.weixin.qq.com/s/kyZvTZgGy5CXVvaiter6cA