Vue虚拟列表
虚拟列表
什么是进程?什么是线程?
进程是系统进行资源分配和调度的一个独立单位,一个进程包含多个线程
渲染进程
GUI
渲染线程(页面渲染)JS
引擎线程(执行 js 脚本)- 事件触发线程(EventLoop 轮询处理线程)
- 事件(onClick)、定时器(setTimeout)、ajax(xhr)(独立线程)
浏览器 EventLoop
JS 引擎线程(执行栈、微任务、宏任务)
⬇
清空微任务队列(Promise.then、MutataionObserver)
⬇
GUI 渲染
⬇
取出宏任务执行(ajax、setTimeout、event)
requestAnimationFrame 在重新渲染屏幕之前执行
超长列表渲染性能优化
- 分片渲染(通过浏览器事件环机制,分割渲染时间)
- 不腻列表(只渲染可视区域)
计算长列表渲染时间
假设需要渲染100000
条数据,计算渲染完成的时间,根据浏览器事件环的渲染机制可知,等到 GUI 渲染完成后,才会执行宏任务里面的队列,所以真正的渲染完成时间需要在setTimeout
中获取。
<div id="container"></div>
<script>
let total = 100000
let timer = Date.now()
for (let i = 0; i < total; i++) {
let li = document.createElement('li')
li.innerHTML = i
container.appendChild(li)
}
// 脚本执行完成时间
console.log(Date.now() - timer)
setTimeout(() => {
// 页面渲染完成时间(在GUI渲染完成后执行)
console.log(Date.now() - timer)
})
</script>
分片渲染
假设需要渲染100000
条数据,将数据分片渲染,每次先渲染50
条,直到全部渲染完成。
let index = 0
let total = 100000
const RENDER_NUM = 50
const el = document.getElementById('container')
function load() {
index += RENDER_NUM
if (index < total) {
setTimeout(() => {
for (let i = 0; i < RENDER_NUM; i++) {
let li = document.createElement('li')
li.innerHTML = i++
el.appendChild(li)
}
load()
})
}
}
load()
requestAnimationFrame
可以配合浏览器刷新频率,重新渲染屏幕之前执行,更适合游戏或者频繁的渲染操作:
let total = 100000
let index = 0
const RENDER_NUM = 50
function load() {
index += RENDER_NUM
if (index < total) {
requestAnimationFrame(() => {
const fragment = document.createDocumentFragment()
for (let i = 0; i < RENDER_NUM; i++) {
let li = document.createElement('li')
li.innerHTML = i++
fragment.appendChild(li)
}
container.appendChild(fragment)
load()
})
}
}
load()
分片渲染缺点
分片加载会导致页面 dom 元素过多,造成页面卡顿
虚拟列表
虚拟列表,只渲染当前的可视区域,参考案例:vue-virtual-scroll-list
实现原理
实现虚拟列表,首先需要明确下面几点:
- 计算虚拟列表总高度
- 计算可视区域高度
- 监听列表滚动事件
总结下来是:监听列表滚动事件,动态渲染可视区域的列表
虚拟列表
scroll-bar
滚动条(列表总高)scroll-list
可视区域列表
组件参数
虚拟列表组件参数:
- size: Number 列表每项高度
- remain: Number 可视区域列表数量
- items: Array 列表数据
子组件 slot 预留作用域参数 item,组件结构如下:
<template>
<VirtualList :size="40" :remain="8" :items="items">
<Item slot-scope="{item}" :item="item" />
</VirtualList>
</template>
Item组件:
<template>
<div class="item">
{{item.value}}
</div>
</template>
<script>
export default {
props:{
item:Object
}
}
</script>
<style>
.item{
height: 40px;
border-bottom: 1px solid #ddd;
}
</style>
VirtualList
组件结构
首先明确VirtualList
组件结构:
- 滚动条
- 虚拟列表
- 滚动事件监听
template 结构如下:
<div class="viewport" ref="viewport" @scroll="handleScroll">
<div class="scroll-bar" ref="scrollbar"></div>
<div class="scroll-list" :style="{transform: `translate3d(0,${offset}px,0)`}">
<div v-for="item in visibleData" :data-index="item.id" :key="item.id">
<slot :item="item"></slot>
</div>
</div>
</div>
计算高度和样式布局
- 首先需要计算父元素
.viewport
的高度,让子元素处于可滚动状态。 - 计算
.scroll-bar
滚动条高度(列表总高度) .scroll-list
相对父元素的绝对定位
计算高度:
viewport = 每项高度 - 可视区域显示个数
scrollbar = 列表总数 - 每项高度
mounted(){
this.$refs.viewport.style.height = this.size * this.remain + "px";
this.$refs.scrollbar.style.height = this.items.length * this.size + "px";
}
样式布局:
.viewport {
overflow-y: scroll;
position: relative;
}
.scroll-list {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
visibleData
visibleData 是可视区域的渲染数据,通过计算可视区域的列表起始值start
和结束值end
获取:
data(){
return {
start:0,
end: this.remain, // 初始值 end 是可视区域列表数量
}
},
computed:{
visibleData(){
return this.items.slice(this.start, this.end)
},
// 距离列表顶部的滚动偏移量
offset() {
return this.start * this.size
},
}
监听滚动事件
监听列表的滚动事件,通过滚动高度计算start
和end
的变化:
methods:{
handleScroll(){
const scrollTop = this.$refs.viewport.scrollTop;
this.start = Math.floor(scrollTop/this.size);
this.end = this.start + this.remain;
}
}
解决滚动白屏问题
如果用户滚动过快,导致计算渲染未能跟上用户的滚动速度,会导致白屏的出现,解决方式如下:
- 渲染三屏,可视区域的列表,可视区域前面预渲染的列表,可视区域前面后面预渲染的列表
computed: {
// 前面预留几个列表项
prevCount() {
return Math.min(this.start, this.remain);
},
// 后面预留几个列表项
nextCount() {
return Math.min(this.remain, this.items.length - this.end);
},
offset() {
// 偏移量需要再减去前面的预渲染的几个列表项
return this.start * this.size - this.size * this.prevCount;
},
visibleData() {
const start = this.start - this.prevCount;
const end = this.end + this.nextCount;
return this.items.slice(start, end);
},
},
不确定的列表项高度
有时候列表每项的高度也是不确定的,列表项的高度随着内容高度的变化而变化。
解决途径:
- 新增
variable
参数,判断是否需要动态计算每项高度 - 初始化缓存每项高度
- 滚动时,获取列表每项真实高度 更新缓存高度
- 重新计算滚动条高度
<template>
<div class="viewport" ref="viewport" @scroll="scrollFn">
<div class="scroll-bar" ref="scrollbar"></div>
<div
class="scroll-list"
:style="{ transform: `translate3d(0,${offset}px,0)` }"
>
<div
v-for="item in visibleData"
:data-index="item.id"
:key="item.id"
ref="items"
>
<slot :item="item" />
</div>
</div>
</div>
</template>
<script>
import throttle from "lodash/throttle";
export default {
props: {
size: Number,
remain: Number,
items: Array,
variable: Boolean,
},
data() {
return {
start: 0,
end: this.remain,
};
},
updated() {
this.$nextTick(() => {
let nodes = this.$refs.items;
if (!(nodes && nodes.length)) return;
nodes.forEach((node) => {
let { height } = node.getBoundingClientRect();
let index = Number(node.dataset.index);
let oldHeight = this.positions[index].height;
let val = oldHeight - height; // 比较新旧高度是否有变化
if (val) {
this.positions[index].height = height;
this.positions[index].bottom = this.positions[index].bottom - val;
// 将后面的值向后移动
for (let i = index + 1; i < this.positions.length; i++) {
this.positions[i].top = this.positions[i - 1].bottom;
this.positions[i].bottom = this.positions[i].bottom - val;
}
}
});
const lastItem = this.positions[this.positions.length - 1]
this.$refs.scrollbar.style.height = lastItem.bottom + "px";
});
},
computed: {
prevCount() {
return Math.min(this.start, this.remain);
},
nextCount() {
return Math.min(this.remain, this.items.length - this.end);
},
offset() {
if (this.variable && this.positions) {
let item = this.positions[this.start - this.prevCount];
return item ? item.top : 0;
} else {
return this.start * this.size - this.size * this.prevCount;
}
},
visibleData() {
const start = this.start - this.prevCount;
const end = this.end + this.nextCount;
return this.items.slice(start, end);
},
},
created(){
this.scrollFn = throttle(this.handleScroll, 200, {leading: false})
},
mounted() {
this.$refs.viewport.style.height = this.size * this.remain + "px";
this.$refs.scrollbar.style.height = this.items.length * this.size + "px";
// 如果加载完毕,需要缓存每一项的高度
this.cacheList();
},
methods: {
cacheList() {
// 缓存当前项的高度和top值还有bottom值
this.positions = this.items.map((item, index) => ({
height: this.size,
top: index * this.size,
bottom: (index + 1) * this.size,
}));
},
getStartIndex(scrollTop) {
let start = 0;
let end = this.positions.length - 1;
let temp = null;
while (start < end) {
let middleIndex = parseInt((start + end) / 2);
let middleValue = this.positions[middleIndex].bottom;
if (middleValue === scrollTop) {
return middleIndex + 1;
} else if (middleValue < scrollTop) {
start = middleIndex + 1;
} else if (middleValue > scrollTop) {
if (temp === null || temp > middleIndex) {
temp = middleIndex;
}
end = middleIndex - 1;
}
}
return temp;
},
handleScroll() {
const scrollTop = this.$refs.viewport.scrollTop;
if (this.variable) {
this.start = this.getStartIndex(scrollTop);
this.end = this.start + this.remain;
} else {
this.start = Math.floor(scrollTop / this.size);
this.end = this.start + this.remain;
}
},
},
};
</script>
<style>
.viewport {
overflow-y: scroll;
position: relative;
}
.scroll-list {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
</style>