Code前端首页关于Code前端联系我们

Vue3实现虚拟滚动列表用啥方案?性能调优怎么落地?

terry 2小时前 阅读数 26 #Vue

很多前端开发接触到长列表场景时,比如电商的全品类商品、社交平台的热门评论瀑布流、企业级后台的万级数据表格预览,第一反应都会想到虚拟列表——毕竟直接渲染10万条DOM,哪怕是再新的浏览器都会卡成PPT,Vue3出来也有好几年了,生态里的虚拟列表实现方案挺多的,但很多新手容易踩坑,要么选了不适合自己项目的,要么照搬代码后还是有滚动卡顿、白屏闪烁的问题,今天就结合我自己做过的几个电商后台、直播弹幕系统的项目,聊聊Vue3虚拟列表的那些事。

先搞懂虚拟列表的底层逻辑:为啥它能救长列表的命?

很多人只知道虚拟列表“只渲染可视区域的DOM”,但不知道具体是怎么算的,这样遇到问题的时候根本不知道从哪改,其实不管是手写还是用第三方库,虚拟列表的核心原理都是相通的,总结下来就是三步: 第一步,获取容器和列表项的尺寸信息,首先得拿到外层可视容器的高度(如果是横向虚拟列表就是宽度),然后得知道单个列表项的高度——不过这里的高度分两种,一种是固定高度,这种最简单,另一种是动态高度,比如电商商品卡片里有的有视频有的只有图片,评论有的有emoji图有的纯文字,这种就麻烦一点。 第二步,计算当前可视区域应该渲染哪些列表项,比如可视容器高度是500px,单个固定项高度是50px,那最多同时渲染10个左右(一般会多渲染上下各2-3个作为缓冲,防止快速滚动时白屏),接下来还要监听容器的滚动事件,拿到滚动条的scrollTop,然后用scrollTop除以单个项的高度(动态的话是累加高度),算出第一个可视项的索引,最后取索引范围里的那10+个数据来渲染。 第三步,让渲染出来的列表项“看起来”在原来的位置,因为只渲染了中间那一小部分,直接放的话肯定会顶到容器顶部,所以要在外层可视容器里加一个“占位容器”,这个占位容器的高度等于所有列表项的总高度(动态的话就是动态计算累加),然后用CSS的transform或者margin-top/margin-left把实际渲染的列表项容器“推”到和第一个可视项对应的位置,这样滚动起来就和真实渲染所有项的效果一模一样了。

对了,这里要提一个细节:很多新手监听滚动事件的时候,直接在回调函数里做计算和渲染更新,这样会导致触发频率太高(比如滚轮滚动一下可能触发几十次),反而会卡顿,所以不管用啥方案,监听滚动事件的时候都要加防抖或者节流?不对,防抖是延迟执行,滚动的时候用节流更合适,节流可以限制每隔一定时间(比如16ms,也就是60fps的帧率要求)才执行一次回调,或者更高级一点,用IntersectionObserver?不过IntersectionObserver更适合检测单个元素是否进入视口,虚拟列表因为要频繁更新可视范围,还是监听scroll事件加requestAnimationFrame更靠谱,requestAnimationFrame可以把回调函数放到浏览器的重绘/重排之前执行,和浏览器的渲染节奏同步,不会掉帧。

Vue3虚拟列表的主流实现方案,分别适合什么场景?

现在Vue3生态里的虚拟列表方案,主要分三种:手写极简版、用VueUse的useVirtualList、用成熟的第三方组件库比如element-plus的el-table-v2或者vxe-table,接下来我会逐个分析它们的优缺点和适用场景,你可以根据自己的项目情况选。

手写极简版虚拟列表

手写的优点就是代码完全可控,没有第三方库的冗余代码,体积很小,适合对性能要求极高、或者有特殊定制需求的项目,比如直播弹幕这种不仅要虚拟滚动还要动态插入数据、调整项顺序的场景,缺点就是实现动态高度比较麻烦,而且需要自己处理很多边缘情况,比如滚动到底部加载更多、项尺寸变化后的重新计算、快速滚动时的白屏优化、选中项自动滚动到可视区域这些。

我之前做过一个极简版的固定高度虚拟列表,大概只有100多行代码,这里可以给你说一下核心的实现思路(注意是Vue3的组合式API哦,不是选项式): 在setup里定义几个核心的响应式数据:

  • containerRef:外层可视容器的引用
  • bufferCount:缓冲项的数量,我一般设2-5,设太多会增加DOM数量,设太少会快速滚动白屏
  • visibleData:当前可视区域渲染的数据
  • placeholderHeight:占位容器的高度
  • scrollTop:滚动条的位置,用ref存 计算属性:
  • itemSize:单个项的高度,固定的话直接设50就行
  • totalCount:总数据的长度
  • maxVisibleCount:Math.ceil(containerHeight.value / itemSize) + bufferCount * 2,containerHeight是用onMounted或者ResizeObserver拿到的可视容器高度
  • startIndex:Math.max(0, Math.floor(scrollTop.value / itemSize) - bufferCount)
  • endIndex:Math.min(totalCount.value, startIndex + maxVisibleCount)
  • offset:startIndex * itemSize,用来推实际渲染的列表项的 监听scroll事件,把scrollTop更新进去,但是要用throttle或者requestAnimationFrame包一下:
    // 这里用requestAnimationFrame实现节流
    let ticking = false
    const handleScroll = () => {
    if (!ticking) {
      window.requestAnimationFrame(() => {
        scrollTop.value = containerRef.value.scrollTop
        ticking = false
      })
      ticking = true
    }
    }

    模板部分:

    <div ref="containerRef" class="virtual-container" @scroll="handleScroll">
    <div class="virtual-placeholder" :style="{ height: placeholderHeight + 'px' }"></div>
    <div class="virtual-list" :style="{ transform: `translateY(${offset}px)` }">
      <div class="virtual-item" v-for="(item, index) in visibleData" :key="item.id">
        {{ item.content }}
      </div>
    </div>
    </div>

    用watch监听startIndex和endIndex的变化,更新visibleData,还要用watch监听totalCount的变化,更新placeholderHeight(placeholderHeight = totalCount.value * itemSize)。

如果要做动态高度的手写版,核心难点就是怎么提前知道每个项的高度,因为动态高度的项只有渲染出来才能拿到实际高度,所以手写动态高度的时候,需要加一个cache数组,用来存每个项的高度和偏移量(offsetTop),初始的时候可以给每个项设一个预估高度,然后当项渲染出来之后,用ResizeObserver或者nextTick拿到实际高度,更新cache数组,然后重新计算占位容器的高度和offset,这样滚动到之前的位置的时候,就不用再重新渲染了,直接用cache里的数据就行。

用VueUse的useVirtualList

VueUse大家应该都很熟悉,就是Vue3的工具库,里面封装了很多常用的组合式API,useVirtualList就是其中之一,这个方案的优点是简单易用,体积比成熟的第三方组件库小很多,而且支持固定高度、动态高度、横向滚动、选中项自动滚动、无限加载这些常用功能,完全可以满足大部分中小项目的需求,缺点就是没有现成的UI组件,需要自己写模板和样式,而且如果有非常特殊的定制需求,可能还是得自己修改源码或者手写。

我之前做过一个电商后台的“商品分类选择”弹窗,里面有大概5000个分类项,用的就是useVirtualList,实现起来非常快,大概20分钟就搞定了,这里可以给你说一下大概的步骤: 安装VueUse:

npm i @vueuse/core

在setup里引入useVirtualList,传入总数据和配置项:

import { useVirtualList } from '@vueuse/core'
import { ref } from 'vue'
const categories = ref([...]) // 总分类数据,大概5000条
const containerRef = ref()
const { list, containerProps, wrapperProps, scrollToIndex } = useVirtualList(categories, {
  itemHeight: 40, // 单个项的高度,固定的话直接设,动态的话可以传一个函数或者设为0,用预估高度
  overscan: 3, // 缓冲项的数量,相当于我之前说的bufferCount
})

模板部分直接用它返回的containerProps和wrapperProps就行,不用自己写scroll事件监听、不用自己算offset和占位高度:

<div ref="containerRef" class="category-container" v-bind="containerProps">
  <div class="category-wrapper" v-bind="wrapperProps">
    <div class="category-item" v-for="(item, index) in list" :key="item.id" @click="selectCategory(item, index)">
      {{ item.name }}
    </div>
  </div>
</div>

对了,useVirtualList还支持scrollToIndex方法,可以用来实现选中项自动滚动到可视区域的功能,比如刚才的商品分类选择,当你在搜索框里输入某个分类的名字,找到对应的索引之后,直接调用scrollToIndex(index, { align: 'center' })就行,非常方便。

如果要做动态高度的话,只需要把itemHeight设为0,然后给每个项加一个ref,用nextTick或者ResizeObserver拿到实际高度之后,调用useVirtualList返回的setItemHeight方法更新就行,VueUse会自动处理cache数组、重新计算占位高度和offset这些细节,不用自己操心。

用成熟的第三方组件库

成熟的第三方组件库比如element-plus的el-table-v2、vxe-table、ant-design-vue的VirtualList,这些方案的优点是开箱即用,有非常完善的UI组件和功能,比如el-table-v2支持虚拟滚动的表格、排序、筛选、分页、固定列、树形表格这些,vxe-table的功能更强大,甚至支持虚拟滚动的导出Excel,缺点就是体积比较大,如果你的项目里只需要一个简单的虚拟列表,没必要引入这么大的组件库,而且定制性相对差一点,如果有非常特殊的UI或者功能需求,可能需要自己写插槽或者修改CSS变量,甚至修改源码。

我之前做过一个企业级后台的“订单管理”页面,里面有大概10万条订单数据,需要支持虚拟滚动、排序、筛选、固定前两列(订单号和下单时间)、树形展开查看订单详情这些功能,手写的话太麻烦了,用el-table-v2大概1个小时就搞定了,这里可以给你说一下大概的步骤: 安装element-plus:

npm i element-plus

在main.js里全局注册el-table-v2,或者在组件里局部引入:

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import { ElTableV2 } from 'element-plus'
const app = createApp(App)
app.use(ElementPlus)
app.component('ElTableV2', ElTableV2)
app.mount('#app')

在组件里使用:

<template>
  <el-table-v2
    :columns="columns"
    :data="orders"
    :width="1200"
    :height="600"
    :row-key="(row) => row.id"
    :fixed-data-left-width="300"
    :virtualized="true"
  ></el-table-v2>
</template>
<script setup>
import { ref } from 'vue'
const columns = ref([
  { key: 'id', title: '订单号', width: 150, fixed: 'left' },
  { key: 'createTime', title: '下单时间', width: 150, fixed: 'left' },
  { key: 'productName', title: '商品名称', width: 200 },
  { key: 'price', title: '价格', width: 100 },
  { key: 'quantity', title: '数量', width: 100 },
  { key: 'totalPrice', title: '总价', width: 100 },
  { key: 'status', title: '状态', width: 100 },
  { key: 'address', title: '收货地址', width: 300 },
])
const orders = ref([...]) // 总订单数据,大概10万条
</script>

你看,是不是非常简单?只需要把virtualized设为true,然后设置一下列宽、表格宽高、固定列这些就行,其他的细节element-plus都帮你处理好了。

不管用啥方案,Vue3虚拟列表的性能调优都要注意这几点

很多人以为只要用了虚拟列表,长列表的性能就没问题了,但实际上如果不注意调优,还是会出现滚动卡顿、白屏闪烁的问题,接下来我会结合我自己踩过的坑,给你说一下Vue3虚拟列表的几个核心性能调优点:

合理设置缓冲项的数量(overscan)

缓冲项的数量不能太多也不能太少,设太多会增加DOM数量,导致重绘/重排的时间变长,反而会卡顿;设太少会快速滚动时白屏,我一般设2-5个,具体可以根据自己的项目情况调整,比如如果是移动端,滚动速度比较快,可以设3-5个;如果是PC端,滚动速度相对慢一点,可以设2-3个。

给每个列表项设置唯一的key

这个是Vue3的基本要求,但很多新手还是会犯错误,比如用index当key,如果用index当key,当列表项的顺序发生变化或者插入/删除数据的时候,Vue3会复用错误的DOM节点,导致渲染错误或者性能下降,所以一定要用每个项的唯一标识当key,比如数据库的id、UUID这些。

避免在列表项里放太重的组件

比如不要在每个列表项里放一个完整的echarts图表、或者一个复杂的视频播放器,这样哪怕只渲染10个列表项,性能也会很差,如果必须要放的话,可以用懒加载,比如当列表项进入可视区域之后,再加载echarts图表或者视频播放器;或者用轻量化的替代方案,比如用图片代替视频播放器的封面,点击之后再打开弹窗播放视频。

优化滚动事件的监听

刚才已经提过了,监听滚动事件的时候不要直接在回调函数里做计算和渲染更新,要用requestAnimationFrame包一下,和浏览器的渲染节奏同步,不会掉帧,如果你用的是VueUse或者第三方组件库,它们内部已经帮你处理好了,不用自己再包。

动态高度的项要合理设置预估高度

如果是动态高度的虚拟列表,初始的时候要给每个项设一个合理的预估高度,预估高度越接近实际高度,滚动条的跳动就越小,用户体验就越好,如果预估高度和实际高度相差太大,比如预估100px,实际只有50px,那滚动到底部的时候会发现滚动条突然变短了,而且快速滚动的时候白屏的概率也会变大,怎么设置合理的预估高度呢?可以先随机取100-200条数据,渲染出来,拿到它们的实际高度的平均值,然后把这个平均值设为预估高度。

用ResizeObserver监听项尺寸的变化

如果是动态高度的虚拟列表,或者项的尺寸会因为用户的操作而变化(比如点击展开评论详情、修改字体大小),一定要用ResizeObserver监听项尺寸的变化,然后更新cache数组、重新计算占位高度和offset,VueUse的useVirtualList内部已经支持ResizeObserver了,不用自己再写;手写的话需要自己实现。

无限加载的时候要控制总数据的数量

很多虚拟列表都会配合无限加载使用,比如滚动到底部的时候加载更多数据,但如果总数据的数量太多,比如加载到100万条,哪怕只渲染可视区域的DOM,内存占用也会很大,导致浏览器变慢甚至崩溃,所以无限加载的时候要控制总数据的数量,比如可以用“分页缓存”的方式,只保留当前可视区域附近的几页数据,其他页的数据删掉,滚动回去的时候再重新加载。

避免在列表项里用v-if

v-if会销毁和重建DOM节点,比v-show耗性能,如果只是控制列表项的显示和隐藏,尽量用v-show;如果必须要用v-if,比如项的内容完全不同,可以把不同的内容封装成不同的组件,然后用动态组件,配合keep-alive缓存已渲染的组件,这样可以减少销毁和重建DOM节点的次数。

写在最后

虚拟列表确实是解决长列表性能问题的利器,但也不是万能的,要根据自己的项目情况选择合适的方案,还要注意性能调优,如果你是新手,或者项目需求比较简单,建议先用VueUse的useVirtualList;如果项目需求比较复杂,比如需要虚拟滚动的表格、排序、筛选这些,建议用成熟的第三方组件库;如果项目对性能要求极高,或者有特殊定制需求,再考虑手写。

还要提醒大家一点:虚拟列表的核心是“只渲染可视区域的DOM”,所以不管用啥方案,都要尽量减少可视区域DOM节点的数量和复杂度,这样才能真正发挥虚拟列表的优势。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门