虚拟列表入门篇——3分钟带你快速实现一个高性能列表组件

点赞再看,手留余香;本文首发于公众号前端小帅,欢迎关注~

虚拟列表------你需要知道的优雅处理大数据渲染的技巧。

2023年了,你还不会高性能渲染吗?1万?10万?条数据,根本不怂,虚拟列表高性能渲染实战!

前言

按照我们常规理解,数据量较大时一般都是通过分页进行解决处理,对于Table组件类这种列表展示我们应该很熟悉了。

分页可以实现最小化接口数据量,后端根据前端传入的参数,返回指定的范围数据。毕竟我给你1000条数据,你也很难用一屏把全部数据展示出来

就一般场景而言,以后端分页为主。但是,也不排除一些特殊业务场景,需要返回大量数据的情况

这里我们主要讨论长列表的场景,当存在在大量数据返回的情况下(如 Select 组件),如何优雅的高性能渲染

背景

结合我司实际使用场景为例:

有这样一个类似 Input + Select 组件的高级业务封装组件,可选列表数据根据输入内容动态变化,每一项嵌套有多个divspan标签以及img图标等元素,整个容器元素会频繁关闭、开启,触发重新渲染,需要渲染的字典数据接口是全部返回的,一般是600条以上。

简单分析下:

  1. 接口返回数据量较大,考虑到列表每一项存在多个嵌套标签元素,在创建节点、渲染数据的时候会比较消耗性能
  2. 列表数据动态变化、频繁开关,这意味着整个元素会频繁触发创建节点、渲染的流程

分析

为了更加清晰的表现出耗时瓶颈,这里我以1万条数据为例,分析创建一万个节点的消耗占比

示例代码:

控制台输出:

根据耗时结果,分析可得出:

  1. js运行时间为23ms,还是比较快的
  2. 总运行时间达到了739ms,耗时比较明显

这俩耗时差距明显,我们需要思考下发生了什么

众所周知,JS的运行是单线程的,浏览器为了能够使得JS内部taskDOM任务能够有序的执行,会在一个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>

计算可视区域的渲染数据:

  • 通过startend计算可视区域列表数据 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>

最终效果如下:

总结

本文旨在带领大家快速了解、学习什么是虚拟列表,以及如何实现一个简易的虚拟列表组件,使大家对该技术思路做到心中有数。

应付一些简单的需求场景想必也是够用了,但是对于一些技术细节,及其他场景,如快速滚动时数据加载更新渲染的问题,模糊搜索查询问题,高度不固定的问题等,应该如何处理优化呢?

欢迎点赞、关注我,后续会持续更新,感谢大家支持!

让我们下一篇文章见吧------《虚拟列表进阶篇------高性能列表组件实战》。

地址

相关推荐
小镇程序员14 分钟前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐17 分钟前
前端图像处理(一)
前端
程序猿阿伟24 分钟前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
疯狂的沙粒26 分钟前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
瑞雨溪34 分钟前
AJAX的基本使用
前端·javascript·ajax
力透键背37 分钟前
display: none和visibility: hidden的区别
开发语言·前端·javascript
程楠楠&M1 小时前
node.js第三方Express 框架
前端·javascript·node.js·express
weiabc1 小时前
学习electron
javascript·学习·electron
盛夏绽放1 小时前
Node.js 和 Socket.IO 实现实时通信
前端·后端·websocket·node.js
想自律的露西西★1 小时前
用el-scrollbar实现滚动条,拖动滚动条可以滚动,但是通过鼠标滑轮却无效
前端·javascript·css·vue.js·elementui·前端框架·html5