纸间书摘
52.7MB · 2025-11-17
当我们在页面中渲染一个很长的列表时(比如上万条数据),普通的v-for会一次性创建上万个DOM节点:
<li v-for="item in 10000">{{ item }}</li>
这会导致:
于是就有了性能神器——虚拟列表 (Virtual List) 。
虚拟列表的核心实现思路其实很简单:
假设列表共有 10000 条,每行高 40px:
| 区域 | 说明 |
|---|---|
| 可见区域 | 一次只显示约 10 行 |
| 缓冲区 | 上下各多渲染几行防止闪烁 |
| 占位容器 | 用总高度撑出滚动条 |
| 渲染窗口 | 动态平移(translateY)到对应位置 |
这样:
我们先用最简单的方法来实现一个简易版的,以便我们更好的理解最底层的思想原理:
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>
// 总高度(撑出滚动条):总数居条数*每一项高度
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)
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>
上面的基础版我们已经实现了最底层必要的代码,但在真实项目中可能还有下列问题:
接下来我们给这个简易版的虚拟列表加点小优化
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让代码在“下一次屏幕绘制”前执行;想让列表自动滚动第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)
如果数据是对象,直接写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) }}
容器可能因父元素布局变化而高度改变,使用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 | 自适应容器尺寸 |