虚拟化
高性能的虚拟滚动实现,适用于大量数据列表渲染。
概述
虚拟滚动通过只渲染可见区域内的元素来优化长列表性能。@cat-kit/fe 提供了 Virtualizer 和 VirtualContainer 两个类来实现虚拟滚动。
Virtualizer - 虚拟化核心
Virtualizer 负责计算哪些元素应该被渲染。
基本用法
typescript
import { Virtualizer } from '@cat-kit/fe'
const virtualizer = new Virtualizer({
length: 10000, // 总元素数量
estimateSize: () => 50, // 估算每个元素高度
buffer: 5, // 上下缓冲元素数量
onChange: ({ items, totalSize }) => {
console.log('可见元素:', items)
console.log('总高度:', totalSize)
// 更新 UI
renderItems(items)
}
})配置选项
typescript
interface VirtualizerOption {
/** 元素总数 */
length?: number
/** 缓冲区元素数量(上下各多渲染几个) */
buffer?: number
/** 估算元素尺寸的函数 */
estimateSize?: (index: number) => number
/** 可见元素变化时的回调 */
onChange?: (ctx: { items: VirtualItem[]; totalSize: number }) => void
}更新状态
typescript
// 更新滚动位置和容器尺寸
virtualizer.update({
length: 15000, // 更新总数
containerSize: 600, // 容器高度
offsetSize: 1000, // 滚动距离
buffer: 10 // 更新缓冲区
})更新元素尺寸
当元素实际渲染后,需要更新其真实尺寸:
typescript
// 元素渲染后测量真实高度
const element = document.querySelector(`[data-index="0"]`)
if (element) {
const height = element.getBoundingClientRect().height
virtualizer.updateItemSize(0, height)
}获取可见项
typescript
const items = virtualizer.getVisibleItems()
// items: VirtualItem[]
interface VirtualItem {
index: number // 元素索引
start: number // 元素起始位置(px)
size: number // 元素尺寸(px)
}VirtualContainer - 容器管理
VirtualContainer 将 Virtualizer 与 DOM 元素绑定。
基本用法
typescript
import { Virtualizer, VirtualContainer } from '@cat-kit/fe'
const virtualizer = new Virtualizer({
length: 10000,
estimateSize: () => 50,
onChange: ({ items }) => {
renderItems(items)
}
})
const container = new VirtualContainer({
vertical: virtualizer
})
// 连接到 DOM 元素
container.connect('#list-container')滚动控制
typescript
// 滚动到指定位置(px)
container.scrollTo(1000)
// 滚动到指定元素
container.scrollToIndex(100)清理
typescript
// 断开连接,移除事件监听
container.disconnect()完整示例
Vue 3 示例
vue
<template>
<div class="virtual-list-container" ref="containerRef">
<div class="virtual-list-spacer" :style="{ height: totalSize + 'px' }">
<div
v-for="item in visibleItems"
:key="item.index"
:data-index="item.index"
class="virtual-list-item"
:style="{
position: 'absolute',
top: item.start + 'px',
left: 0,
right: 0
}"
:ref="(el) => measureItem(el as HTMLElement, item.index)"
>
{{ data[item.index] }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { Virtualizer, VirtualContainer, type VirtualItem } from '@cat-kit/fe'
// 数据
const data = ref(Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`))
// 虚拟滚动状态
const visibleItems = ref<VirtualItem[]>([])
const totalSize = ref(0)
// DOM 引用
const containerRef = ref<HTMLElement>()
// 创建虚拟化器
const virtualizer = new Virtualizer({
length: data.value.length,
estimateSize: () => 50,
buffer: 5,
onChange: ({ items, totalSize: size }) => {
visibleItems.value = items
totalSize.value = size
}
})
const container = new VirtualContainer({
vertical: virtualizer
})
// 测量元素实际高度
function measureItem(el: HTMLElement | null, index: number) {
if (!el) return
const height = el.getBoundingClientRect().height
virtualizer.updateItemSize(index, height)
}
onMounted(() => {
if (containerRef.value) {
container.connect(containerRef.value)
}
})
onUnmounted(() => {
container.disconnect()
})
</script>
<style scoped>
.virtual-list-container {
height: 600px;
overflow: auto;
border: 1px solid #ccc;
}
.virtual-list-spacer {
position: relative;
}
.virtual-list-item {
padding: 10px;
border-bottom: 1px solid #eee;
}
</style>React 示例
typescript
import { useEffect, useRef, useState } from 'react'
import { Virtualizer, VirtualContainer, type VirtualItem } from '@cat-kit/fe'
function VirtualList({ data }: { data: string[] }) {
const [visibleItems, setVisibleItems] = useState<VirtualItem[]>([])
const [totalSize, setTotalSize] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const virtualizerRef = useRef<Virtualizer>()
const containerManagerRef = useRef<VirtualContainer>()
const itemRefs = useRef<Map<number, HTMLDivElement>>(new Map())
useEffect(() => {
// 创建虚拟化器
const virtualizer = new Virtualizer({
length: data.length,
estimateSize: () => 50,
buffer: 5,
onChange: ({ items, totalSize }) => {
setVisibleItems(items)
setTotalSize(totalSize)
}
})
const container = new VirtualContainer({
vertical: virtualizer
})
virtualizerRef.current = virtualizer
containerManagerRef.current = container
if (containerRef.current) {
container.connect(containerRef.current)
}
return () => {
container.disconnect()
}
}, [data.length])
// 测量元素
useEffect(() => {
visibleItems.forEach(item => {
const el = itemRefs.current.get(item.index)
if (el && virtualizerRef.current) {
const height = el.getBoundingClientRect().height
virtualizerRef.current.updateItemSize(item.index, height)
}
})
}, [visibleItems])
return (
<div ref={containerRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${totalSize}px`, position: 'relative' }}>
{visibleItems.map(item => (
<div
key={item.index}
ref={el => el && itemRefs.current.set(item.index, el)}
data-index={item.index}
style={{
position: 'absolute',
top: `${item.start}px`,
left: 0,
right: 0,
padding: '10px',
borderBottom: '1px solid #eee'
}}
>
{data[item.index]}
</div>
))}
</div>
</div>
)
}原生 JavaScript 示例
typescript
import { Virtualizer, VirtualContainer } from '@cat-kit/fe'
class VirtualListRenderer {
private data: string[]
private virtualizer: Virtualizer
private container: VirtualContainer
private containerEl: HTMLElement
constructor(containerEl: HTMLElement, data: string[]) {
this.data = data
this.containerEl = containerEl
this.virtualizer = new Virtualizer({
length: data.length,
estimateSize: () => 50,
buffer: 5,
onChange: ({ items, totalSize }) => {
this.render(items, totalSize)
}
})
this.container = new VirtualContainer({
vertical: this.virtualizer
})
this.container.connect(containerEl)
}
private render(items: VirtualItem[], totalSize: number) {
const spacer = this.containerEl.querySelector('.spacer') as HTMLElement
if (!spacer) {
const newSpacer = document.createElement('div')
newSpacer.className = 'spacer'
newSpacer.style.position = 'relative'
this.containerEl.appendChild(newSpacer)
}
spacer.style.height = `${totalSize}px`
spacer.innerHTML = ''
items.forEach(item => {
const el = document.createElement('div')
el.className = 'item'
el.dataset.index = String(item.index)
el.style.position = 'absolute'
el.style.top = `${item.start}px`
el.style.left = '0'
el.style.right = '0'
el.textContent = this.data[item.index]
// 测量实际高度
spacer.appendChild(el)
const height = el.getBoundingClientRect().height
this.virtualizer.updateItemSize(item.index, height)
})
}
destroy() {
this.container.disconnect()
}
}
// 使用
const data = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`)
const container = document.querySelector('#container') as HTMLElement
const list = new VirtualListRenderer(container, data)高级用法
动态高度元素
typescript
const virtualizer = new Virtualizer({
length: data.length,
// 根据内容估算不同高度
estimateSize: index => {
const item = data[index]
return item.length > 100 ? 100 : 50
},
onChange: ({ items }) => {
renderItems(items)
}
})水平虚拟滚动
typescript
const horizontalVirtualizer = new Virtualizer({
length: columns.length,
estimateSize: () => 150, // 列宽
buffer: 3
})
const container = new VirtualContainer({
horizontal: horizontalVirtualizer
})重置状态
typescript
// 当数据完全变化时重置
virtualizer.reset()性能优化建议
- 准确估算:尽可能准确地估算元素尺寸,减少重计算
- 合理缓冲:buffer 值通常设置为 3-10
- 避免频繁更新:使用防抖处理
updateItemSize - 复用元素:配合对象池模式复用 DOM 元素
- 懒加载图片:配合 IntersectionObserver 懒加载图片
API 参考
Virtualizer
构造函数
typescript
new Virtualizer(option: VirtualizerOption)方法
update(option: UpdateOption): void- 更新配置updateItemSize(index: number, size: number): void- 更新元素尺寸getVisibleItems(): VirtualItem[]- 获取可见项getTotalSize(): number- 获取总尺寸reset(): void- 重置状态
VirtualContainer
构造函数
typescript
new VirtualContainer(option?: {
horizontal?: Virtualizer
vertical?: Virtualizer
})方法
connect(el: string | HTMLElement): void- 连接容器disconnect(): void- 断开连接scrollTo(offset: number): void- 滚动到位置scrollToIndex(index: number): void- 滚动到元素
