虚拟列表(Virtual List)组件实现与优化铁臂猿版(简易版)
引入:本文使用vue3+ts+tailwindcss实现
一、为什么需要“虚拟列表”?
当我们在页面中渲染一个很长的列表时(比如上万条数据),普通的v-for会一次性创建上万个DOM节点:
<li v-for="item in 10000">{{ item }}</li>
这会导致:
- 页面加载慢;
- 内存占用大;
- 滚动卡顿。
于是就有了性能神器——虚拟列表 (Virtual List) 。
二、核心思想
虚拟列表的核心实现思路其实很简单:
固定外层盒子的高,通过计算只渲染出可见区域的数据,其余不可见元素用一个占位元素撑出滚动条。
可视化理解
假设列表共有 10000 条,每行高 40px:
| 区域 | 说明 |
|---|---|
| 可见区域 | 一次只显示约 10 行 |
| 缓冲区 | 上下各多渲染几行防止闪烁 |
| 占位容器 | 用总高度撑出滚动条 |
| 渲染窗口 | 动态平移(translateY)到对应位置 |
这样:
- 页面上实际只有几十个 DOM;
- 但滚动条看起来依然完整;
- 用户看起来就像全部都渲染出来一样。
三、基础版虚拟列表示例
我们先用最简单的方法来实现一个简易版的,以便我们更好的理解最底层的思想原理:
- 支持滚动
- 支持滚动数据切片
- 支持缓冲区
JS部分实现思路:
用一个固定高度的容器制造滚动条,通过计算只渲染可视区域的数据,并用偏移量让这些数据看起来在正确的位置。
1. 主要变量设置,本教程为简易版,默认所有数据项等高
<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)计算
// 总高度(撑出滚动条):总数居条数*每一项高度
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.滚动事件监听
const onScroll = () => {
//这里加一个判断防止DOM为渲染发生闪烁
if (!containerRef.value) return
scrollTop.value = containerRef.value.scrollTop
}
onMounted(() => {
// 初始化计算,避免第一次渲染闪烁
onScroll()
})
HTML部分实现思路及代码:
包括三层的盒子:
第一层:最外层固定高度的盒子,负责滚动 第二层:撑开高度,让滚动条看起来像有全部数据 第三层:可视区域,只渲染看得见的几条数据,跟着滚动动态移动
<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>
父组件使用实例:
<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)触发频率极高(每秒上百次),频繁计算会导致页面卡顿。
解决: 利用浏览器的“下一帧更新”机制
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行怎么做?
function scrollToIndex(index: number) {
if (!containerRef.value) return
containerRef.value.scrollTop = index * props.itemHeight
}
defineExpose({ scrollToIndex })
父组件调用:
<button @click="goTo200">滚动到第 200 行</button>
<VirtualList ref="listRef" :items="list" />
const listRef = ref()
const goTo200 = () => listRef.value?.scrollToIndex(200)
3.类型安全展示(防止TS报错)
如果数据是对象,直接写item.id会报错(因为item是unknown)
解决:封装一个安全函数。
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自动监听。
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 |
自适应容器尺寸 |