跳到主要内容

长列表优化 vue 版

长列表在各种数据列场景下经常使用,一旦数据量非常大,就会出现卡段,先看下长列表一步一步进化

一次性加载

最开始使用长列表时,会这样写

<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 渲染是在宏任务之前执行,那么可以通过改进下

<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

  • 包含两个组件 VirtualListItem
  • 通过 mockjs 生产 mock 数据
<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 对象,显示值
<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 的高度,顶部,底部位置;每次渲染后,更新缓存为实际的高度,顶部,底部位置

  • variabletrue 时,利用二分查找算法找到滚动条的位置

  • 可视区域显示的实际 dom 数量应大于 remain 个数,防止出现空白

<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>