虚拟列表(Virtual List)组件实现与优化铁臂猿版(简易版)

引入:本文使用vue3+ts+tailwindcss实现

一、为什么需要"虚拟列表"?

当我们在页面中渲染一个很长的列表 时(比如上万条数据),普通的v-for会一次性创建上万个DOM节点:

vue 复制代码
<li v-for="item in 10000">{{ item }}</li>

这会导致:

  • 页面加载慢;
  • 内存占用大;
  • 滚动卡顿。

于是就有了性能神器------虚拟列表 (Virtual List)

二、核心思想

虚拟列表的核心实现思路其实很简单:

固定外层盒子的高,通过计算只渲染出可见区域的数据,其余不可见元素用一个占位元素撑出滚动条。

可视化理解

假设列表共有 10000 条,每行高 40px:

区域 说明
可见区域 一次只显示约 10 行
缓冲区 上下各多渲染几行防止闪烁
占位容器 用总高度撑出滚动条
渲染窗口 动态平移(translateY)到对应位置

这样:

  • 页面上实际只有几十个 DOM
  • 但滚动条看起来依然完整;
  • 用户看起来就像全部都渲染出来一样。

三、基础版虚拟列表示例

我们先用最简单的方法来实现一个简易版的,以便我们更好的理解最底层的思想原理:

  • 支持滚动
  • 支持滚动数据切片
  • 支持缓冲区

JS部分实现思路:

用一个固定高度的容器制造滚动条,通过计算只渲染可视区域的数据,并用偏移量让这些数据看起来在正确的位置。

1. 主要变量设置,本教程为简易版,默认所有数据项等高

js 复制代码
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
/**
 * 组件参数(Props)
 */
const props = defineProps({
  items: { type: Array, default: () => [] }, // 数据数组
  itemHeight: { type: Number, default: 40 }, // 单行高度
  height: { type: Number, default: 300 },     // 容器高度
  buffer: { type: Number, default: 5 }        // 上下缓冲行数
})
/**
 *  状态变量(会随滚动变化)
 */
const containerRef = ref<HTMLDivElement>()   // 滚动容器 DOM
const scrollTop = ref(0)                     // 当前滚动位置
</script>

2.可视区域计算:包括高度计算和偏移量(offset)计算

js 复制代码
// 总高度(撑出滚动条):总数居条数*每一项高度
const totalHeight = computed(() => props.items.length * props.itemHeight)

// 当前一屏可显示多少行(加上缓冲):可视区高度/每一项高度
const visibleCount = computed(() => {
  const base = Math.ceil(props.height / props.itemHeight)
  return base + props.buffer * 2
})

// 可见数据的起止索引:
// 1. 当前位置 ÷ 每一项高度 = 当前完全滚过多少项(向下取整得到最完整的已滚过项数)
// 2. 减去缓冲区:因为上方缓冲区的内容也需要提前渲染
// 3. 确保索引不小于0(边界保护)
const startIndex = computed(() => {
  const start = Math.floor(scrollTop.value / props.itemHeight) - props.buffer
  return start < 0 ? 0 : start
})

// 结束索引 = 开始索引 + 要渲染的总数量(可见区+上下缓冲区)
// 注意:这里应该加上边界检查,防止超过数组长度
const endIndex = computed(() => {
  const end = startIndex.value + visibleCount.value
  return end > props.items.length ? props.items.length : end
})
// 当前可见的数据切片
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))

// 平移偏移量(用于 translateY)
const offsetTop = computed(() => startIndex.value * props.itemHeight)

3.滚动事件监听

js 复制代码
const onScroll = () => {
//这里加一个判断防止DOM为渲染发生闪烁
  if (!containerRef.value) return
  scrollTop.value = containerRef.value.scrollTop
}

onMounted(() => {
  // 初始化计算,避免第一次渲染闪烁
  onScroll()
})

HTML部分实现思路及代码:

包括三层的盒子:

第一层 :最外层固定高度的盒子,负责滚动 第二层 :撑开高度,让滚动条看起来像有全部数据 第三层:可视区域,只渲染看得见的几条数据,跟着滚动动态移动

js 复制代码
<template>
  <!-- 外层容器:固定高度 + 滚动 -->
  <div
    ref="containerRef"
    class="overflow-auto border rounded"
    :style="{ height: `${props.height}px` }"
    @scroll="onScroll"
  >
    <!-- 占位容器:撑起滚动条 -->
    <div :style="{ height: `${totalHeight}px`, position: 'relative' }">
      <!-- 渲染窗口:通过 translateY 偏移 -->
      <div :style="{ transform: `translateY(${offsetTop}px)` }">
        <div
          v-for="(item, idx) in visibleItems"
          :key="startIndex + idx"
          class="border-b px-3 flex items-center"
          :style="{ height: `${props.itemHeight}px` }"
        >
          {{ item }}
        </div>
      </div>
    </div>
  </div>
</template>

父组件使用实例:

js 复制代码
<template>
  <VirtualList :items="list" :height="400" :itemHeight="35" />
</template>

<script setup lang="ts">
import VirtualList from './VirtualList.vue'

// 模拟 1 万条数据
const list = Array.from({ length: 10000 }, (_, i) => `第 ${i + 1} 条数据`)
</script>

四、优化方案:让虚拟列表更丝滑

上面的基础版我们已经实现了最底层必要的代码,但在真实项目中可能还有下列问题:

  • 滚动事件触发太频繁
  • 容器尺寸改变没有自动适配
  • 想要暴露API比如:"滚动到第N行"
  • item如果不是字符串而是对象,访问属性会类型报错

接下来我们给这个简易版的虚拟列表加点小优化

1.使用requestAnimationFrame节流滚动事件

原因: 滚动事件(scroll)触发频率极高(每秒上百次),频繁计算会导致页面卡顿。

解决: 利用浏览器的"下一帧更新"机制

js 复制代码
let rafId: number | null = null
const onScroll = () => {
  if (!containerRef.value) return
  const top = containerRef.value.scrollTop

  // 如果上一帧任务还没执行完,就取消它
  if (rafId !== null) cancelAnimationFrame(rafId)

  // 只保留最后一次
  rafId = requestAnimationFrame(() => {
    scrollTop.value = top
    rafId = null
  })
}

原理

  • requestAnimationFrame让代码在"下一次屏幕绘制"前执行;
  • 每帧约16ms,即使滚动再快,最多60次/秒,性能稳定

2.支持"滚动到指定行"

想让列表自动滚动第200行怎么做?

js 复制代码
function scrollToIndex(index: number) {
  if (!containerRef.value) return
  containerRef.value.scrollTop = index * props.itemHeight
}
defineExpose({ scrollToIndex })

父组件调用:

vue 复制代码
<button @click="goTo200">滚动到第 200 行</button>
<VirtualList ref="listRef" :items="list" />
ts 复制代码
const listRef = ref()
const goTo200 = () => listRef.value?.scrollToIndex(200)

3.类型安全展示(防止TS报错)

如果数据是对象,直接写item.id会报错(因为item是unknown) 解决:封装一个安全函数。

js 复制代码
function displayText(item: unknown): string {
  if (item && typeof item === 'object') {
    const r = item as Record<string, unknown>
    return String(r.label ?? r.id ?? '[Object]')
  }
  return String(item)
}
//模板
{{ displayText(item) }}

4.自动适配容器高度变化

容器可能因父元素布局变化而高度改变,使用ResizeObserver自动监听。

ts 复制代码
import { onBeforeUnmount } from 'vue'

let resizeObserver: ResizeObserver | null = null
onMounted(() => {
  const el = containerRef.value
  if (el) {
    resizeObserver = new ResizeObserver(() => {
      // 重新计算高度
      containerHeight.value = el.clientHeight
    })
    resizeObserver.observe(el)
  }
})
onBeforeUnmount(() => resizeObserver?.disconnect())

五、最终结果

现在实现的虚拟列表:

  • 流畅不卡顿;
  • 类型安全;
  • 可主动滚动;
  • 自适应容器变化;
  • 对象数据、字符串数据都能显示;
  • 真正做到 性能与体验两不误

六、总结:

模块 作用
totalHeight 撑出滚动条
visibleItems 控制渲染数量
offsetTop 平移到正确位置
requestAnimationFrame 优化高频滚动性能
scrollToIndex 提供外部控制
ResizeObserver 自适应容器尺寸
相关推荐
宇余2 小时前
Unibest:新一代uni-app工程化最佳实践指南
前端·vue.js
性野喜悲2 小时前
ts+uniapp小程序时间日期选择框(分开选择)
前端·javascript·vue.js
P***25393 小时前
前端构建工具缓存清理,npm cache与yarn cache
前端·缓存·npm
好奇的菜鸟3 小时前
解决 npm 依赖版本冲突:从 “unable to resolve dependency tree“ 到依赖管理高手
前端·npm·node.js
lcc1873 小时前
Vue 内置指令
前端·vue.js
lijun_xiao20094 小时前
前端React18入门到实战
前端
o***Z4484 小时前
前端响应式设计资源,框架+模板
前端
IT_陈寒4 小时前
Vue 3.4 正式发布:5个不可错过的性能优化与Composition API新特性
前端·人工智能·后端
N***73854 小时前
前端无障碍开发资源,WCAG指南与工具
前端