vue-virtual-scroller-虚拟滚动列表:渲染不定高度长列表+可控跳转

一、背景

在一个页面中需要渲染长列表,且每一项高度不固定。若一次性将数据渲染到 DOM 中,会带来以下问题:

  • 首次加载卡顿:大量 DOM 节点创建耗时;
  • 交互卡顿:新增、编辑等操作触发频繁重排(reflow)与重绘(repaint);
  • 内存占用高:浏览器需维护大量未可视的节点;
  • 跳转困难:用户需快速定位到某个特定表格(如通过左侧树节点点击),但目标区域可能尚未渲染。

因此,我们需要一种既能减少初始渲染量 ,又能支持高效滚动与精准跳转的方案。通常大家都会想到分页、懒加载、虚拟滚动。这里分页并不适用项目场景,所以对比下懒加载与虚拟滚动。


二、方案对比:懒加载 vs 虚拟滚动

特性 懒加载(Lazy Loading) 虚拟滚动(Virtual Scrolling)
核心目标 延迟加载资源,减少首屏时间 仅渲染可视区域,提升滚动性能
优化对象 图片、数据、组件等资源 DOM 节点数量(列表项)
适用场景 图片瀑布流、路由懒加载、分页列表 超长列表、动态高度表格、聊天记录等
DOM 节点数 最终仍可能渲染全部节点(只是延迟) 始终保持少量节点,动态复用
滚动性能 无直接优化,滚动仍可能卡顿 显著提升流畅度,避免回流重绘
实现复杂度 简单(如 Intersection Observer 较高(需管理滚动、高度测量、复用逻辑)

结论

我们不仅需要减少初始加载压力,更需保障滚动流畅性高频 DOM 操作(如编辑)的响应速度
虚拟滚动通过限制 DOM 节点数量,从根本上降低了重排重绘成本,是更优解。


三、解决方案:使用 vue-virtual-scroller(Vue 3)

1、安装与注册

TypeScript 复制代码
# vue2中
npm i vue-virtual-scroller

# Vue 3 项目需安装 @next 版本
npm install vue-virtual-scroller@next
TypeScript 复制代码
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'

const app = createApp(App)
app.use(VueVirtualScroller)
app.mount('#app')

2、核心组件:DynamicScroller + DynamicScrollerItem

适用于不定高度的列表项(如动态行数的表格)。

✅ 模板代码

html 复制代码
<template>
  <DynamicScroller
    ref="dynamicScrollerRef"
    class="scroller"<!-- 必须有固定高度比如100vh,如果没有设置高度,滚动容器无法工作 -->
    :items="renderedList"<!-- 要渲染的数据列表 -->
    :min-item-size="200"        <!-- 列表项预估高度(px) -->
    key-field="processId"       <!-- 唯一标识字段 -->
    v-slot="{ item, index, active }"
  >
    <DynamicScrollerItem
      :item="item"<!--  列表项  -->
      :active="active"<!--  当前是否处于活跃状态  -->
      :size-dependencies="[item.children.length"<!--  声明影响 item 高度的依赖项 -->
      :data-index="index"<!--  用于定位快速  -->
    >
      <!--  自定义列表项  -->
      <a-table
        :columns="columns"
        :key="`table-${item.processId}`"
        :data-source="item.children"
        style="margin-top: 10px;"
        :pagination="false"
        bordered
        :data-process-id="item.processId"
      >
        <template #title>
          <!-- 表格标题内容 -->
        </template>
        <template #bodyCell="{ column, text, record }">
          <!-- 单元格自定义渲染 -->
        </template>
      </a-table>
    </DynamicScrollerItem>
  </DynamicScroller>
</template>

✅ 逻辑

TypeScript 复制代码
<script setup lang="ts">
import { ref, nextTick } from 'vue'

interface ChildItem {
  index: number;
  product: string;
  test1: string;
  // ...其他字段
}

interface ParentItem {
  processId: string;
  processName: string;
  children: ChildItem[];
  // ...其他字段
}

const dynamicScrollerRef = ref()
const renderedList = ref<ParentItem[]>([])

const columns = [
  { title: '序号', dataIndex: 'index', width: 50 },
  { title: '产品', dataIndex: 'product', width: 100 },
  // ...其他列
]

// 模拟数据加载
const fetchData = async () => {
  const res = await api.getProcessData()
  renderedList.value = res.data
}

// 跳转到指定 processId 的表格
const scrollToProcess = (operationId: string) => {
  const idx = renderedList.value.findIndex(p => p.processId === operationId)
  if (idx >= 0) {
    nextTick(() => {
      dynamicScrollerRef.value?.scrollToItem(idx, { align: 'center' })
    })
  }
}
</script>

✅ 样式要求

css 复制代码
.scroller {
  height: calc(100vh - 100px); /* 必须设置固定高度 */
}

✅ 关键属性详解

属性 说明
:min-item-size 初始布局占位高度(单位 px)。建议设为非全屏状态下的平均表格高度。
key-field 数据项的唯一 ID 字段,用于稳定追踪 item 身份。
:size-dependencies 极其重要! 声明影响高度的依赖项。例如: [item.children.length, fullScreenTableId === item.processId] 当这些值变化时,组件会自动重新测量高度。
scrollToItem(index, { align }) 官方提供的跳转方法,支持 'start''center''end' 对齐方式,即使目标未渲染也能精准跳转

✅ 效果


3、附加需求:精准跳转到指定表格

早期尝试使用 document.querySelector().scrollIntoView() 失败,

原因在于:❌ 未渲染的 DOM 不存在 → 查询返回 null → 跳转失效。

正确做法✅ :使用 DynamicScroller 内置的 scrollToItem 方法

scrollToItem(index, { align = 'start' | 'center' | 'end' })

TypeScript 复制代码
const scrollToProcess = (operationId: string) => {
  const idx = renderedList.value.findIndex(p => p.processId === operationId)
  if (idx !== -1) {
    nextTick(() => {
      dynamicScrollerRef.value?.scrollToItem(idx, { align: 'center' })
    })
  }
}

💡 优势

  • 无需等待 DOM 渲染;
  • 自动触发目标项的渲染与测量;
  • 基于真实布局计算滚动位置,精准可靠。

四、总结

  • 虚拟滚动 是处理大量动态高度列表/表格的最佳实践
  • vue-virtual-scroller@next 提供了完善的 DynamicScroller 组件,支持:
    • 不定高度自动测量;
    • 高效 DOM 复用;
    • 内置 scrollToItem 跳转能力;
  • 避免误区 :不要依赖原生 scrollIntoView,应使用组件提供的跳转 API。

🌟 经验之谈

曾经以为"跳转"需要手动实现,写了一大堆滚动计算和预加载逻辑,结果发现库本身已完美支持。善用官方 API,少造轮子,多查文档!



没用vue-virtual-scroller之前,使用手动实现,代码直接多了两三倍...

手动实现

1、模板部分

html 复制代码
<div @scroll="handleScroll" :style="{ height: 'calc(100vh - 100px)', overflowY: 'auto' }" >
  <a-table :columns="columns" v-for="(item) in renderedProcessList" :key="`table-${item.processId}`" v-memo="[item, fullScreenTableId]" :data-source="item.children"
    style="margin-top: 10px;" :pagination="false" :data-process-id="item.processId">
    <!-- ........省略 -->
  </a-table>
</div>

2、逻辑部分

总体思路:实现一个动态缓冲区 的虚拟列表滚动。核心思想是:仅渲染当前可视区域附近的固定条数数据(如40条),并在上下滚动时动态加载更多数据,同时回收不可见的数据。

(1)定义虚拟列表相关变量

javascript 复制代码
const fullDataCache = ref([]); // 完整数据缓存
const renderedProcessList = ref([]); // 当前渲染的数据(40条)
const startIndex = ref(0); // 当前渲染数据的起始索引
const endIndex = ref(0); // 当前渲染数据的结束索引
const bufferSize = 40; // 缓冲区大小,即同时渲染的数据条数
const scrollSize = 20; // 向上向下滚动加载数据条数
const shouldHandleScroll = ref(true); //控制跳转时是否触发向上向下滚动

(2)首先是初始化时候,不要一下子渲染后端返回的全部数据。而是接收这部分数据但是只渲染固定条数的数据。

javascript 复制代码
//初始化,全量更新,但只渲染部分数据
const queryProcessList = async (lineId) => {
  const res = await controlPlanApi.queryData(lineId);
  fullDataCache.value = res.data;
  startIndex.value = 0;
  endIndex.value = Math.min(bufferSize, fullDataCache.value.length);
  renderedProcessList.value = fullDataCache.value.slice(startIndex.value, endIndex.value); // 初始化渲染前40条数据
};

(3)接下来就是考虑上下滚动如何加载数据的问题。我的思路是,当滚动到距离顶部/底部10%的距离时,就动态替换原有的渲染数组索引。

|---------------|------------------------------|------------------------------|
| 原有的渲染数组索引 | 向上滚动 | 向下滚动 |
| startIndex | newStartIndex=startIndex-20 | newStartIndex=newEndIndex-40 |
| endIndex | newEndIndex=newStartIndex+40 | newEndIndex=endIndex+20 |

javascript 复制代码
// 滚动处理函数
const handleScroll = (event) => {
  
  if (!shouldHandleScroll.value) return;
  
  const container = event.target;
  const scrollTop = container.scrollTop;//距离顶部滚动的距离
  const scrollHeight = container.scrollHeight; // 总高度

  //当向下滚动距离底部小于10%时,加载当前数组索引的后面20个数据。
  if(scrollTop/scrollHeight > 0.9 && endIndex.value < fullDataCache.value.length){
    const newEnd = Math.min(fullDataCache.value.length, endIndex.value + scrollSize);//newEnd=旧的+20
    const newStart = Math.max(0, newEnd - bufferSize);//新开始索引 = newEnd-40
    startIndex.value = newStart;
    endIndex.value = newEnd;
    renderedProcessList.value = fullDataCache.value.slice(newStart, newEnd);
    // console.log("向下滚动",newStart, newEnd);
  }else if(scrollTop/scrollHeight < 0.1 && startIndex.value > 0){
    //当向上滚动距离顶部小于10%距离时,加载当前数组索引的前面20个数据。
    const newStart = Math.max(0, startIndex.value - scrollSize);//newStart = 旧start - 20
    const newEnd = Math.min(newStart + bufferSize, fullDataCache.value.length);//newEnd = newStart + 40
    startIndex.value = newStart;
    endIndex.value = newEnd;
    renderedProcessList.value = fullDataCache.value.slice(newStart, newEnd);
    // console.log("向上滚动",newStart, newEnd);
  }
};

(4)实现指定跳转

javascript 复制代码
// 跳转
const scrollToProcess = (id) => {

  const targetIndex = fullDataCache.value.findIndex(process => process.processId === id);
  if (targetIndex === -1) return;
  const newStart = Math.max(0, targetIndex - 20);
  const newEnd = Math.min(fullDataCache.value.length, newStart + bufferSize);

  // 更新当前渲染数据
  startIndex.value = newStart;
  endIndex.value = newEnd;
  renderedProcessList.value = fullDataCache.value.slice(newStart, newEnd);
  // console.log("跳转",newStart, newEnd, targetIndex);

  nextTick(() => {
    const targetElement = document.querySelector(`[data-process-id="${id}"]`);
    if (targetElement) {
      // 滚动到目标元素位置
      targetElement.scrollIntoView({
        behavior: 'smooth',
        block: 'center'
      });
    }
  });
};
相关推荐
Kagol17 小时前
深入浅出 TinyEditor 富文本编辑器系列之一:TinyEditor 是什么
前端·typescript·开源
空城雀17 小时前
python精通连续剧第一集:简单计算器
服务器·前端·python
超绝大帅哥17 小时前
为什么回调函数不是一种好的异步编程方式
javascript
不务正业的前端学徒17 小时前
手写简单的call bind apply
前端
jump_jump17 小时前
Ripple:一个现代的响应式 UI 框架
前端·javascript·前端框架
用户9047066835717 小时前
Nuxt css 如何写?
前端
夏天想17 小时前
element-plus的输入数字组件el-input-number 显示了 加减按钮(+ -) 和 小三角箭头(上下箭头),怎么去掉+,-或者箭头
前端·javascript·vue.js
0思必得017 小时前
[Web自动化] Selenium基础介绍
前端·python·selenium·自动化·web自动化
Filotimo_17 小时前
前端.d.ts文件作用
前端