点赞
再看,手留余香;本文首发于公众号前端小帅,欢迎关注~
虚拟列表------你需要知道的优雅处理大数据渲染的技巧。
2023年了,你还不会高性能渲染吗?1万?10万?条数据,根本不怂,虚拟列表高性能渲染实战!
前言
按照我们常规理解,数据量较大时一般都是通过分页进行解决处理,对于Table
组件类这种列表展示我们应该很熟悉了。
分页可以实现最小化接口数据量,后端根据前端传入的参数,返回指定的范围数据。毕竟我给你1000条数据,你也很难用一屏把全部数据展示出来
就一般场景而言,以后端分页为主。但是,也不排除一些特殊业务场景,需要返回大量数据的情况
这里我们主要讨论长列表的场景,当存在在大量数据返回的情况下(如 Select
组件),如何优雅的高性能渲染
背景
结合我司实际使用场景为例:
有这样一个类似 Input
+ Select
组件的高级业务封装组件,可选列表数据根据输入内容动态变化,每一项嵌套有多个div
、span
标签以及img
图标等元素,整个容器元素会频繁关闭、开启,触发重新渲染,需要渲染的字典数据接口是全部返回的,一般是600条以上。
简单分析下:
- 接口返回数据量较大,考虑到列表每一项存在多个嵌套标签元素,在创建节点、渲染数据的时候会比较消耗性能
- 列表数据动态变化、频繁开关,这意味着整个元素会频繁触发创建节点、渲染的流程
分析
为了更加清晰的表现出耗时瓶颈,这里我以1万条数据为例,分析创建一万个节点的消耗占比
示例代码:
控制台输出:
根据耗时结果,分析可得出:
- js运行时间为23ms,还是比较快的
- 总运行时间达到了739ms,耗时比较明显
这俩耗时差距明显,我们需要思考下发生了什么
众所周知,JS的运行是单线程的,浏览器为了能够使得JS内部task
与DOM
任务能够有序的执行,会在一个task执行结束后,在下一个 task 执行开始前,对页面进行重新渲染,流程大致是这样🤓
task->渲染->task->...
如果存在微任务的话,则是这样子🧐
宏任务 -> 微任务 -> 渲染 -> 下一个任务...
具体细节不再赘述,详情参见我的这篇文章 从宏观层面理解------浏览器中JavaScript的运行机制
根据JavaScript的执行逻辑,结合Chrome performance工具可以进一步分析得出如下结果:
耗时主要集中在 Rendering
,为688ms,占比高达 93%。进一步查看performance ,可以发现Recalculate Style
(样式重新计算)与Layout
(布局)的耗时占比,可以确定性能瓶颈主要集中在 渲染 这一步。
这是没有经过任何优化处理的代码,在拿到10000条数据后,我们直接创建10000个DOM节点(这还是未考虑其他元素节点、属性、事件绑定的情况下的数据)。
假如弹窗的宽高是固定的,如果弹窗内只能展示20条数据,接口返回给了10000条数据,那么创建10000个DOM节点再全部渲染是完全没必要的,就像做图片懒加载一样,我们可以在页面滚动到可视区域的时候再渲染对应的图片。
以上即是虚拟列表的基本逻辑,虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。
因此,我们应该如何优化?
首先要考虑的应该是尽量做到最小开销的渲染。那么答案就很明显了,减少节点的创建,进而减少布局的渲染消耗。
思路
我们先从最基本的开始,以固定高度的容器进行逻辑拆解,分析实现思路。
这里我们主要关注容器的高度与每个子项的高度,假定容器高度为500px,每一项的item高度为50px,那么理论上来说,容器中是可以容纳10个item
当然,如果我的数据是多于10条
的,那么初次加载时,可视区域外的部分是不需要渲染显示的。
当滚动开始的时候,动态计算可视区域内的列表项,同时删除可视区域外的列表项。例如,当我滚动了100px
时,此时滚动条距离顶部的距离为100px,通过计算可以得知可视区域内的列表项为第3项至第12项
。
那么,应该如何计算列表项呢,滚动条的高度如何控制呢?
考虑到可视区域内的列表项是存在动态删减的,在设计的时候,我们还需要提供一个高度容器专门用于计算列表的真实高度,以此来撑起容器进而得到真实的滚动条,以此,我们定义以下DOM结构:
html
<template>
<!-- 外层容器 -->
<div class="container">
<!-- 高度容器:列表总高度 -->
<div class="infinite-list-height"></div>
<!-- 列表容器:实际显示的列表项 -->
<div class="infinite-list"></div>
</div>
</template>
可视容器的高度我们以变量(screenHeight
)的形式进行定义,方便后期拓展,目前是已知的为500px,此外,还需要定义2个变量,可视区域列表项的开始索引(start
)和结束索引(end
)的取值,同时定义一个滚动偏移量(startOffset
)用于配合滚动高度,计算列表项容器位置的偏移值
代码结构如下:
ts
const data = reactive<{
screenHeight: number; // 可视区域高度
startOffset: number; // 偏移量
start: number; // 起始索引
end: number; // 结束索引
}>({
screenHeight: 0,
startOffset: 0,
start: 0,
end: 0,
});
图示:
实现
组件提供props用于控制数据传入和子项高度定义
ts
const props = defineProps<{
listData: Array<{ id: number; value: string }>; // 列表数据
itemSize: number; // 列表项高度
}>();
计算列表真实高度 listHeight = props.listData.length * props.itemSize
vue
<template>
<!-- 外层容器 -->
<div class="container">
<!-- 高度容器:列表总高度 -->
<div class="infinite-list-height" :style="{ height: `${listHeight}px` }"></div>
<!-- 列表容器:实际显示的列表项 -->
<div class="infinite-list"></div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
// 列表总高度
const listHeight = computed(() => props.listData.length * props.itemSize);
</script>
计算可视区域的渲染数据:
- 通过
start
与end
计算可视区域列表数据visibleData = props.listData.slice(data.start, data.end)
end
最大取值不应该超过总列表项数量:Math.min(data.end, props.listData.length)
vue
<script setup lang="ts">
import { computed, ref } from "vue";
const refContainer = ref();
// 可显示的列表项
const visibleCount = computed(() => data.screenHeight / props.itemSize);
// 实际显示的数据
const visibleData = computed(() =>
props.listData.slice(data.start, Math.min(data.end, props.listData.length))
);
onMounted(() => {
data.screenHeight = refContainer.value.clientHeight;
data.end = data.start + visibleCount.value;
});
</script>
外层容器绑定滚动事件 scroll
- 开始索引
start
通过滚动高度偏移量计算得出:Math.floor(scrollTop / props.itemSize)
- 结束索引
end
,为开始索引加上可视区域列表项数量:data.start + visibleCount.value
- 偏移量
startOffset
取值scrollTop
,为了优化显示,减去取余:scrollTop % props.itemSize
- 列表项容器偏移位置
transFormOffset
取值 :transform: translate3d(0,${data.startOffset}px,0)
html
<div ref="refContainer" class="container" @scroll="handleScroll">
js
const handleScroll = () => {
// 当前偏移量
const scrollTop = refContainer.value.scrollTop;
// 开始索引
data.start = Math.floor(scrollTop / props.itemSize);
// 结束索引
data.end = data.start + visibleCount.value;
// 此时的偏移量
data.startOffset = scrollTop - (scrollTop % props.itemSize);
};
基础版完整代码
vue
<template>
<div ref="refContainer" class="container" @scroll="handleScroll">
<div class="infinite-list-height" :style="{ height: `${listHeight}px` }"></div>
<div class="infinite-list" :style="{ transform: transFormOffset }">
<div class="infinite-list-item" v-for="item in visibleData" :key="item.id"
:style="{ height: `${itemSize}px`, lineHeight: `${itemSize}px` }">
{{ item.value }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
const refContainer = ref();
const props = defineProps<{
listData: Array<{ id: number; value: string }>;
itemSize: number;
}>();
const data = reactive<{
screenHeight: number;
startOffset: number;
start: number;
end: number;
}>({
// 可视区域高度
screenHeight: 0,
// 偏移量
startOffset: 0,
// 起始索引
start: 0,
// 结束索引
end: 0,
});
// 列表总高度
const listHeight = computed(() => props.listData.length * props.itemSize);
// 可显示的列表项
const visibleCount = computed(() => data.screenHeight / props.itemSize);
// 偏移量
const transFormOffset = computed(
() => `translate3d(0,${data.startOffset}px,0)`
);
// 实际显示的数据
const visibleData = computed(() =>
props.listData.slice(data.start, Math.min(data.end, props.listData.length))
);
onMounted(() => {
data.screenHeight = refContainer.value.clientHeight;
data.end = data.start + visibleCount.value;
});
const handleScroll = () => {
// 当前偏移量
const scrollTop = refContainer.value.scrollTop;
// 开始索引
data.start = Math.floor(scrollTop / props.itemSize);
//结束索引
data.end = data.start + visibleCount.value;
// 此时的偏移量
data.startOffset = scrollTop - (scrollTop % props.itemSize);
};
</script>
父组件如下:
vue
<template>
<InfiniteList :list-data="listData" :item-size="itemSize" />
</template>
<script setup lang="ts">
import { ref } from "vue";
import InfiniteList from "./infiniteList/index.vue";
const mock = Array.from(new Array(200)).map((_, index) => ({
id: index + 1,
value: `a-${index + 1}`,
}));
const listData = ref(mock);
const itemSize = ref(50);
</script>
最终效果如下:
总结
本文旨在带领大家快速了解、学习什么是虚拟列表,以及如何实现一个简易的虚拟列表组件,使大家对该技术思路做到心中有数。
应付一些简单的需求场景想必也是够用了,但是对于一些技术细节,及其他场景,如快速滚动时数据加载更新渲染的问题,模糊搜索查询问题,高度不固定的问题等,应该如何处理优化呢?
欢迎点赞、关注我,后续会持续更新,感谢大家支持!
让我们下一篇文章见吧------《虚拟列表进阶篇------高性能列表组件实战》。
地址
- 🎃demo用例项目仓库:
- ☕更多文章欢迎点击查看我的前端咖啡馆: