前端需要展示大量的数据,数据在页面的渲染或滚动时出现明显的卡顿,或在渲染时出现的性能问题,这种情况下,你就应该做优化了
- 是否可以分页
- 是否可以限制展示数量
- 懒加载(实际懒加载在加载项渲染多了后,也会有性能问题)
术语
- 大小:一下内容的大小均指“宽”【水平方向滚动】或“高”【垂直方向】
- View: 当前元素视口的大小
- Buffer:视口边界外已加载的 DOM(前后)
- Section: 所有已加载的 DOM(View + Buffer * 2)
- Virtual: 虚拟元素
元素
列表元素的情况分为两种
我们先来想下 🤔
💬 一个列表,将要展示 1000 个元素
💬 在高度固定的情况下
- 每个元素的高度都是
30px
- 准确:如果这 1000 个元素都渲染出来,那滚动条的高度应该是
1000 * 30px
💬 在高度动态的情况下
- 每个元素的高度是不固定的,但我们可以有一个预估均值
μ = ∑x / 30
这个30是我们取的一个片段(Section)
- 猜测:如果这 1000 个元素都渲染出来,那滚动条的高度大概
1000 * μpx
🤔 既然要虚拟,我们的目的就是让用户直接看到,或马上要看到的元素挂载在DOM上(全挂载的话大量DOM会出现卡顿等性能问题),未挂载的元素(虚拟元素)我们要计算(动态情况下可估算)总高度,来用高度撑起整个列表(有了高度,滚动条才会正常)。这一切都是为了让列表在看起来,用起来(交互)状态下和正常渲染(所有DOM直接挂载)是一样的,特殊的是虚拟列表性能表现更好(几乎没有的卡顿🤪)
核心就是
- 让 List 撑起来(滚动条要接近正常渲染的高度,我们才成正常监听滚动事件)
- 监听滚动事件,来调整 Section 的“起始”,“结尾”
- 根据 Section 来调整“前置填充”,“后置填充”
Coding
HTML 结构
首先来确定 HTML 要如何嵌套(我们要实现列表滚动)
<div
ref="wrapRef"
class="overflow-y-auto"
:style="{ height: wrapHeight }"
@scroll.passive="handleScroll"
>
<div class="InnerList" :style="paddingStyle">
<template v-for="item in areaList" :key="item.id">
<slot name="item" v-bind="item" />
</template>
</div>
</div>
- Wrapper: 我们需要限制大小(高度),也需要设置
overflow-y: auto
,当内部元素溢出容器时,自动以滚动的方式展示
- InnerList: 实现元素的包装,我们主要是要在这个元素上设置
Padding
, 来模拟虚拟元素撑起的高度,这样才能让 Wrapper 的滚动条有足够的滚动空间
- Item:要渲染的元素(这里还不完整,后面会调整 - 实际上我们需要一个 Item 的包裹元素,用来随时获取元素的高度)
初次加载
我们要选择一部分元素来展示,这里我们先用一个常量 30 表示,代表在创建 DOM 时我们仅挂载 30 个元素,其他的均为虚拟的。
const LIST_ITEMS_LEN = 1000
const FIRST_KEEPS = 30
let start = 0
let end = start + FIRST_KEEPS
- 列表的元素实际为 1000 个
- 第一次仅挂载 30 个真实的 DOM
- 这 30 个真实的 DOM 是这 1000 个中 0 ~ 29
固定大小列表
当元素是固定大小的情况,就很简单了, 我们先定一个常量,来表示具体固定的高度
<template v-for="item in areaList" :key="item.id">
<div class="item">
<slot name="item" v-bind="item" />
</div>
</template>
- First Keeps Height:
30 * 30px
- Virtual Height:
(1000 - 30) * 30px
- Wrapper Scroll Height:
1000 * 30px
<script>
import { toRefs, computed } from "vue";
export default {
name: "VirtualList",
props: {
list: {
type: Array,
default: () => [],
},
},
setup(props) {
const { list } = toRefs(props);
const FIRST_KEEPS = 30;
const ITEM_SIZE = 30;
let start = 0;
let end = start + FIRST_KEEPS;
const listLen = computed(() => list.value.length);
const areaList = computed(() => list.value.slice(start, end));
const itemPx = computed(() => ITEM_SIZE + "px");
const paddingStyle = computed(() => {
return {
paddingBottom: (listLen.value - FIRST_KEEPS) * ITEM_SIZE + 'px',
};
});
return {
paddingStyle,
areaList,
itemPx,
};
},
};
</script>
<template>
<div class="wrapper">
<div class="inner-list" :style="paddingStyle">
<template v-for="item in areaList" :key="item">
<div class="item">
<slot name="item" :item="item" />
</div>
</template>
</div>
</div>
</template>
<style>
.wrapper {
height: 400px;
overflow-y: auto;
}
.wrapper {
border: 2px dashed;
background-color: white;
}
.item {
height: v-bind("itemPx");
line-height: v-bind("itemPx");
padding: 0 15px;
background-color: #8f36ff;
}
.item:hover {
background-color: skyblue;
}
</style>
动态高度
动态高度有两种情况
- 开发时,可以估算出整个列表元素的平均高度
- 开发时无法估算,取决于元素内容,不同的内容元素的高度也不同
- First Keeps Height:
∑ItemSize
- First Keeps Average Height:
μ = ∑ItemSize / 30
- Virtual Height:
(1000 - 30) * μpx
- Wrapper Scroll Height:
1000 * μpx
这里我们的 ItemSize 就需要用到我们元素的包裹容器的高度了, 也就是 class="item"
元素,有了元素高度,先把它存起来,再使用。
<template v-for="item in areaList" :key="item.id">
<div class="item">
<slot name="item" v-bind="item" />
</div>
</template>
<script>
import { ref, toRefs, computed, provide, reactive, onMounted } from "vue";
import VirtualItem from "./VirtualItem.vue";
export const VIRTUAL_CTX = Symbol("VIRTUAL");
export default {
name: "VirtualList",
components: {
VirtualItem,
},
props: {
list: {
type: Array,
default: () => [],
},
},
setup(props) {
const { list } = toRefs(props);
const FIRST_KEEPS = 30;
const itemSizeMap = new Map();
let itemSize = ref();
let start = 0;
let end = start + FIRST_KEEPS;
const listLen = computed(() => list.value.length);
const areaList = computed(() => list.value.slice(start, end));
const paddingStyle = computed(() => {
if (!itemSize.value) return;
return {
paddingBottom: (listLen.value - FIRST_KEEPS) * itemSize.value + 'px',
};
});
const handleChangeItemSize = (key, size) => {
itemSizeMap.set(key, size);
};
provide(
VIRTUAL_CTX,
reactive({
setSize: handleChangeItemSize,
})
);
const fistKeepsAverageHeight = () => {
let totalSize = 0;
for (let i = start; i < end; i++) {
totalSize += itemSizeMap.get(i) || 0;
}
itemSize.value = totalSize / FIRST_KEEPS;
};
onMounted(() => {
fistKeepsAverageHeight();
});
return {
paddingStyle,
areaList,
};
},
};
</script>
<template>
<div class="wrapper">
<div class="inner-list" :style="paddingStyle">
<template v-for="item in areaList" :key="item">
<VirtualItem :id="item.id">
<slot name="item" :item="item" />
</VirtualItem>
</template>
</div>
</div>
</template>
<style scoped>
.wrapper {
height: 400px;
overflow-y: auto;
}
.wrapper {
border: 2px dashed;
background-color: white;
}
</style>
滚动监听
我们完成了首次的加载,滚动条也有可用虚拟空间(虽然动态高度的虚拟空间可能不准确,这个我们可以在滚动期间,不断加载元素来调正具体高度),接下来就是监听滚动条的滚动,根据滚动位置,不断调整我们的 Section,以及前后 Padding