长列表优化 vue 版
长列表在各种数据列场景下经常使用,一旦数据量非常大,就会出现卡段,先看下长列表一步一步进化
一次性加载
最开始使用长列表时,会这样写
js
<div id="container"></div>
<script>
let total = 100000
let timer = Date.now()
// 新版浏览器优化,当 js 执行完成后一并插入到页面
for (let i = 0; i < total; i++) {
let li = document.createElement('li')
li.innerHTML = i;
container.appendChild(li)
}
console.log(Date.now() - timer) // js 执行的时间 == 执行很快
setTimeout(() => {
console.log(Date.now() - timer) // 渲染完成用的时间 == 时间太长了
})
</script>
新版浏览器对数据 dom 插入做了优化,当 js 执行完成后一并插入到页面,这样会导致页面空白页很久
使用分片加载
我们知道 js 是单线程的,异步事件是基于 EventLoop
机制,执行顺序为
- 执行代码时,遇到宏任务(setTimeout,setInterval,Ajax,DOM 事件)或微任务(promise、async/await),都推入到相应的队列
- 当同步代码执行完,开始清空微任务队列
- 微任务执行完后,就会尝试进行 dom 渲染
- 以上完成,这时
EventLoop
开始工作,从宏任务队列取出一个宏任务(可能包含同步代码,promise 等)执行 - 然后继续循环下一次
从以上知道,可以 dom 渲染是在宏任务之前执行,那么可以通过改进下
js
<div id="container"></div>
<script>
let total = 100000
let timer = Date.now()
// 新版浏览器优化,当 js 执行完成后一并插入到页面
let index = 0 // 偏移量
let id = 0 // 递增的内容
function load() {
index += 50
if (index < total) {
// setTimeout 和 requestAnimationFrame 都是宏任务
// requestAnimationFrame 可以配合浏览器的刷新频率,效果可能好点
setTimeout(() => { // 分片渲染,因为定时器是一个宏任务,会等待 ui 渲染完成后执行
let fragment = document.createDocumentFragment() // ie 浏览器 需要使用文档碎片
// 先渲染 50 个,等待渲染完成后,再渲染 50 个
for (let i = 0; i < 50; i++) {
let li = document.createElement('li')
li.innerHTML = id++
fragment.appendChild(li)
}
container.appendChild(fragment)
load()
}, 0)
}
}
load()
</script>
使用 setTimeout 宏任务先渲染 50 个 dom,这样就能进行分片加载,页面快速显示内容,但依然存在页面卡顿的问题
使用虚拟列表
页面卡顿的根本原因,还是 dom 太多了,使用虚拟列表,只渲染当前的可视化区域,这里采用 vue 来实现
参考实现:https://github.com/tangbc/vue-virtual-scroll-list/tree/v1.4.7
文件 App.vue
- 包含两个组件
VirtualList
和Item
- 通过
mockjs
生产 mock 数据
html
<template>
<div id="app">
<!-- 只显示可视区域-->
<!-- 1、列表每一项多高,算出一个滚动条来-->
<!-- 2、variable 这个高度不一定多高了 -->
<VirtualList :size="100" :remain="8" :items="items" :variable="true">
<Item slot-scope="{item}" :item="item"></Item>
</VirtualList>
</div>
</template>
<script>
import VirtualList from './components/virtual-list';
import Item from './components/item';
import mock from 'mockjs';
let items = [];
for (let i = 0; i < 10000; i++) {
items.push({ id: i, value: mock.Random.sentence(5, 50) });
}
export default {
name: 'App',
components: {
VirtualList,
Item,
},
data() {
return { items };
},
};
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
</style>
组件 components/Item.vue
- 接收
item
对象,显示值
html
<template>
<div style="border: 1px solid red; padding: 20px 0">{{ item.value }}</div>
</template>
<script>
export default {
name: 'item',
props: {
item: Object,
},
};
</script>
最核心的 components/virtual-list.vue
接收四个参数
size
: 每一项的高度remain
: 可视区域显示多少个items
: 数据源variable
: 每项高度是否固定
html 结构:可视区域
ref="viewport"
, 滚动条ref="viewport"
和数据列表class="scroll-list"
加载完成后,调用
cacheList
方法先缓存每个 dom 的高度,顶部,底部位置;每次渲染后,更新缓存为实际的高度,顶部,底部位置当
variable
为true
时,利用二分查找算法找到滚动条的位置可视区域显示的实际 dom 数量应大于
remain
个数,防止出现空白
html
<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 class="scroll-list" :style="{top:`${offset}px`}">-->
<div
v-for="item in visibleData"
:vid="item.id"
:key="item.id"
ref="items"
>
<slot :item="item"></slot>
</div>
</div>
</div>
</template>
<script>
import throttle from 'lodash/throttle';
export default {
name: 'virtual-list',
props: {
size: Number, // 每一项的高度
remain: Number, // 可见多少个
items: Array,
variable: Boolean, //
},
data() {
return {
start: 0,
end: this.remain, // 默认显示 8 个
offset: 0,
};
},
created() {
this.scrollFn = throttle(this.handleScroll, 200, { leading: false });
},
computed: {
// 渲染三个屏幕
prevCount() {
// 前面预留几个
return Math.min(this.start, this.remain);
},
nextCount() {
// 后面预留几个
return Math.min(this.remain, this.items.length - this.end);
},
// 可见数据有哪些
visibleData() {
// 根据 start 和 end 截取
let start = this.start - this.prevCount;
let end = this.end + this.nextCount;
return this.items.slice(start, end);
},
},
mounted() {
// 可视区域高度
this.$refs.viewport.style.height = this.size * this.remain + 'px';
// 实际滚动条高度
this.$refs.scrollBar.style.height = this.items.length * this.size + 'px';
// 如果加载完毕,需要缓存每一项的高度
// 1、先记录好,等一会滚动的时候,去渲染页面是获取真实 dom 的高度,来更新缓存内容
// 2、再重新计算滚动条的高度
this.cacheList();
},
updated() {
// 页面渲染完成后,需要根据当前展示的数据,更新缓存区的内容
this.$nextTick(() => {
// 根据当前显示的,更新缓存中的 height bottom top,最终更新滚动条的高度
let nodes = this.$refs.items; // 获取真实的节点
if (!(nodes && nodes.length > 0)) {
return;
}
nodes.forEach((node) => {
let { height } = node.getBoundingClientRect(); // 真实的高度
// 更新缓存中老的高度
let id = node.getAttribute('vid') - 0;
let oldHeight = this.positions[id].height;
let val = oldHeight - height; // 计算当前的高度和之前的高度是否变化
if (val) {
// 没变化的话,就不用任何操作了
this.positions[id].height = height;
this.positions[id].bottom = this.positions[id].bottom - val; // 底部增加了
// 链表 将后续的所有人 都要向后移动
for (let i = id + 1; i < this.positions.length; i++) {
this.positions[i].top = this.positions[i - 1].bottom;
this.positions[i].bottom = this.positions[i].bottom - val;
}
}
});
// 只要更新过,会计算出滚动条的最新高度
this.$refs.scrollBar.style.height =
this.positions[this.positions.length - 1].bottom + 'px';
// 就是动态计算滚动条的高度
});
},
methods: {
getStartIndex(value) {
// 查找当前滚动的需要找到的值
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 == value) {
// 如果直接找到了,就返回当前的下一个人
return middleIndex + 1;
} else if (middleValue < value) {
// 当前要查找的人,在右边
start = middleIndex + 1;
} else if (middleValue > value) {
// 当前要查找的人,在左边
if (temp == null || temp > middleIndex) {
temp = middleIndex; // 找到范围
}
end = middleIndex - 1;
}
}
return temp;
},
cacheList() {
// 缓存当前项的高度和 top 值,还有 bottom
this.positions = this.items.map((item, index) => ({
height: this.size,
top: index * this.size,
bottom: (index + 1) * this.size,
}));
},
handleScroll() {
// 1、先算出当前滚动过去几个了,应该从第几个开始显示
let scrollTop = this.$refs.viewport.scrollTop;
if (this.variable) {
// 如果有 variable 使用二分查找找到对应的记录
// 二分查找: 在一个有序的数据列表,先分成两半,从中间开始,判断在哪个一半,然后继续分成两半查找
this.start = this.getStartIndex(scrollTop);
this.end = this.start + this.remain;
// 设置偏移量
this.offset = this.positions[this.start - this.prevCount]
? this.positions[this.start - this.prevCount].top
: 0;
} else {
// 2、获取当前应该从第几个开始渲染
this.start = Math.floor(scrollTop / this.size); // 已滚动的高度/每一项的高度 = 已滚动的个数, 需要取整
// 3、计算当前结尾的位置
this.end = this.start + this.remain; // 当前可渲染的区域
// 定义当前可视区域,让当前渲染的内容显示在当前 viewport 的可视区域里
// 如果有预留渲染,应该把这个位置再向上移动当前这么多 this.size * this.prevCount
this.offset = this.start * this.size - this.size * this.prevCount; // 让可视区域调整偏移位置
}
},
},
};
</script>
<style>
.viewport {
overflow-y: scroll;
position: relative;
}
.scroll-list {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
</style>