长列表的渲染问题
假如我们想要渲染一个列表,其中有很多数据,比如用户的购买记录,我们立马可以想到直接用 v-for 或者遍历数组返回 JSX 的形式来展示
这种做法没问题,但是我们需要考虑一个渲染速度的问题,假如用户有 1000 条记录甚至更多,我们直接进行渲染的话要构建许多 dom 节点,在首次渲染时会卡顿,而且如果页面发生回流,过多的 dom 节点也会造成交互上的问题,众所周知,原生 JS 操作并不慢,框架优化的是渲染的效率,所以会导致 JS 计算完成,但是页面不可交互的问题
优化的思路就是只渲染用户可视区域内的部分,其他部分暂时不渲染,先看代码,这里以 Vue 举例
<template>
<div class="container" @scroll="onScroll">
<div ref="panel">
<div v-for="item in showList" class="item" :key="item">
{{ item }}
</div>
</div>
</div>
</template>
<script setup>
import {computed, onMounted, reactive, ref} from "vue";
const list = reactive(new Array(1000000).fill(0).map((v, i) => `item-${i}`))
const start = ref(0)
const end = ref(10)
const totalHeight = ref(list.length * 1000000)
const panel = ref(null)
const count = 10
const showList = computed(() => list.slice(start.value, end.value))
onMounted(() => {
panel.value.style.height = totalHeight.value + 'px'
end.value = count
})
const onScroll = (e) => {
start.value = Math.floor(e.target.scrollTop / 60)
end.value = start.value + count
panel.value.style.paddingTop = start.value * 60 + 'px'
}
</script>
<style scoped>
.container {
height: 600px;
overflow: scroll;
}
.item {
height: 60px;
}
</style>
我们的思路是用 2 个指针来决定数组的哪一段被进行渲染,也就是 showList,count 为我们想要一次给用户展现几条数据,当发生滚动时,我们拿到 scrollTop,它代表滚动条卷去的高度,初始为 0,用它可以判断 start 指针应该在的位置,然后我们就可以确定 end 指针的位置,从而每次滚动时修改 showList,从而控制数据量
使用 paddingTop 来代表滚动条卷去的高度,假如我们滚动到第三条数据,其实此时前两条数据的位置是没有数据的,只有我们设置的 paddingTop,这是核心
看上去滚动也很丝滑,但是还有一些问题,比如滚动过快导致多次渲染和短暂的白屏,我们可以为滚动事件做防抖处理,因为如果用户快速滚动的话,也是想得到最后的结果,而短暂的白屏的问题,可以对数据进行预加载
我们之前的思路是每次只选数组的 10 条数据来加载,这次我们扩大范围,改成正负 10 以内,代码如下
<template>
<div class="container" @scroll="onScroll">
<div ref="panel">
<div v-for="item in showList" class="item" :key="item">
{{ item }}
</div>
</div>
</div>
</template>
<script setup>
import {computed, onMounted, reactive, ref} from "vue";
const list = reactive(new Array(10000).fill(0).map((v, i) => `item-${i}`))
const start = ref(0)
const end = ref(1)
const count = 10
const totalHeight = ref(list.length * 1000000)
const panel = ref(null)
const buffTop = 10, buffBottom = 10; //新增范围
const showList = computed(() => list.slice(start.value, end.value))
onMounted(() => {
panel.value.style.height = totalHeight.value + 'px'
end.value = count + buffBottom
})
const onScroll = (e) => {
let startValue = Math.floor(e.target.scrollTop / 60)
start.value = startValue > buffTop ? startValue-buffTop: startValue
// 如果刚刚开始滚动,那么开始指针就是卷去的元素的下标,
如果已经滚动了超过 10 个元素,
用当前元素下标减去 10,得到用户可视区域的元素的上 10 个元素的下标,
也就是得到了开始指针的位置
end.value = start.value + count + buffBottom
panel.value.style.paddingTop = start.value * 60 + 'px'
}
</script>
<style scoped>
.container {
height: 600px;
overflow: scroll;
}
.item {
height: 60px;
}
</style>
这样一来,我们一次性渲染超出用户可视区域能盛放的节点,在下拉时,也就是 dom diff 的时候,vue 会复用之前的 dom 节点,测试时观察元素的渲染情况,滚动速度一般时会有大量的节点复用,而且用户即将看见的节点早已加载好了 10 个,所以体验会更好
总结:
- 长列表优化,充分使用 paddingTop 来把元素向下挤压,而不是直接渲染
- 提高加载范围,也就是预加载,能有更流畅的体验