5. 虚拟滚动优化
功能概述
实现高性能的虚拟滚动系统,支持动态高度计算、滚动位置管理和内存优化。
技术难点
- 虚拟滚动算法实现
- 动态高度计算
- 滚动位置管理
- 性能优化和内存管理
- 用户体验优化
实现思路
5.1 虚拟滚动核心组件
// src/pages/chat/components/ChatList/index.tsx
import {
defineComponent,
ref,
type ComponentPublicInstance,
onMounted,
onUnmounted,
watch,
type ExtractPropTypes,
type PropType,
reactive,
nextTick,
} from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { NScrollbar } from 'naive-ui'
import { Bubble } from '@/components'
import cls from 'classnames'
import { NotificationType } from '@/types'
export default defineComponent({
name: 'ChatList',
props: {
messages: {
type: Array as PropType<ExtractPropTypes<typeof Bubble>['content'][]>,
required: true,
default: () => [],
},
contentClass: {
type: String,
default: '',
},
},
setup(props) {
const scrollRef = ref<HTMLElement | null>(null)
const state = reactive({ isBottom: true, totalSize: 0 })
// 虚拟滚动配置
const virtualizer = useVirtualizer({
count: props.messages.length,
getScrollElement: () => scrollRef.value,
estimateSize: () => 100, // 预估每个项目的高度
overscan: 5, // 预渲染的项目数量
measureElement: el => el.getBoundingClientRect().height, // 动态测量元素高度
})
onMounted(() => {
// 获取滚动容器元素
scrollRef.value = document.querySelector(
'.chat-scrollbar .n-scrollbar-container'
)
scrollRef.value?.addEventListener('wheel', wheelCB)
})
onUnmounted(() => {
scrollRef.value?.removeEventListener('wheel', wheelCB)
})
// 监听消息列表变化
watch(
() => props.messages,
(list, oldList) => {
// 判断是否在底部
state.isBottom = state.isBottom ? true : list.length > oldList.length
// 更新虚拟滚动配置
virtualizer.value.setOptions({
...virtualizer.value.options,
count: list.length,
})
}
)
// 监听虚拟项目变化
watch(
() => virtualizer.value.getVirtualItems(),
() => updateVirtualItems()
)
// 更新虚拟项目
const updateVirtualItems = async () => {
if (!scrollRef.value) return
// 计算总高度
state.totalSize = Number(
[...scrollRef.value!.querySelectorAll('.chat-item')]
.reduce((acc, item) => acc + item.getBoundingClientRect().height, 0)
.toFixed(0)
)
// 如果在底部,自动滚动到底部
if (!state.isBottom) return
await nextTick()
scrollRef.value!.scrollTop = Number.MAX_SAFE_INTEGER
}
// 滚轮事件处理
const wheelCB = (e: WheelEvent) => {
// 向上滚动时,标记不在底部
state.isBottom = e.deltaY < 0 && false
}
// 虚拟项目回调
const virtualItemCB = (el: Element | ComponentPublicInstance | null) => {
if (!(el instanceof HTMLElement)) return
virtualizer.value.measureElement(el)
}
return () => {
return (
<NScrollbar
class="chat-scrollbar w-full flex-1 px-5 opacity-100"
contentClass={cls('relative !min-w-[540px]', props.contentClass)}
contentStyle={{ height: `${state.totalSize}px` }}
>
{virtualizer.value.getVirtualItems().map(({ index, start }) => {
const item = props.messages[index]
if (!item) return null
return (
<div
key={String(index)}
data-index={index}
ref={virtualItemCB}
class={cls('chat-item absolute top-0 left-0 w-full pb-4', {
hidden:
index === props.messages.length - 1 &&
item.type !== NotificationType.QUESTION,
})}
style={{ transform: `translateY(${start}px)` }}
>
<Bubble content={item} />
</div>
)
})}
{/* 渲染最后一条消息的特殊处理 */}
{(() => {
const lastChat = props.messages[props.messages.length - 1]
if (lastChat && lastChat.type === NotificationType.QUESTION) {
return (
<div
class="chat-item relative w-full pb-4"
style={{
transform: `translateY(${virtualizer.value.getTotalSize()}px)`,
}}
>
<Bubble content={lastChat} />
</div>
)
}
return null
})()}
</NScrollbar>
)
}
},
})
5.2 无限滚动组件
// src/pages/chat/knowledge-base/index.tsx
import { defineComponent, Fragment, reactive } from 'vue'
import { definePageMeta, useHead } from '#imports'
import { NDivider, NInput, NSelect, NInfiniteScroll } from 'naive-ui'
import { SearchIcon } from '@/assets/icons'
import { type KnowledgeItem } from '@/utils'
import { Upload, Item } from './components'
import cls from 'classnames'
import { common } from '@/theme'
const tabList = [{ label: '知识库', value: 'knowledge' }]
export default defineComponent({
name: 'KnowledgeBase',
setup() {
definePageMeta({ layout: 'chat' })
useHead({
title: 'JetPave',
meta: [{ name: 'description', content: 'JetPave SaaS' }],
htmlAttrs: { lang: 'zh-CN' },
})
const state = reactive({
fileList: [] as KnowledgeItem[],
type: 0,
loading: true,
noMore: false,
page: 1,
pageSize: 20,
})
// 加载更多数据
const loadMore = async () => {
if (state.loading || state.noMore) return
state.loading = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
// 模拟数据
const newItems = Array.from({ length: state.pageSize }, (_, i) => ({
id: state.page * state.pageSize + i,
name: `文件${state.page * state.pageSize + i}`,
type: 'application/pdf',
size: '1.2MB',
}))
// 检查是否还有更多数据
if (newItems.length < state.pageSize) {
state.noMore = true
}
state.fileList.push(...newItems)
state.page++
} catch (error) {
console.error('加载数据失败:', error)
} finally {
state.loading = false
}
}
return () => (
<div class="flex-1 flex flex-col gap-4 overflow-hidden">
<div class="flex justify-between items-center px-4">
<div class="flex">
{tabList.map((i, index) => (
<Fragment key={i.value}>
<div
class={cls('transition', {
[common.textPrimary]: state.type === index,
})}
>
{i.label}
</div>
{index !== tabList.length - 1 && <NDivider vertical />}
</Fragment>
))}
</div>
<div class="flex items-center gap-4">
<NInput
class="!w-56 shrink-0"
placeholder="搜索"
v-slots={{ prefix: () => <SearchIcon /> }}
/>
<NSelect
class="w-24 shrink-0"
options={[{ label: '全部', value: 'all' }]}
defaultValue="all"
/>
<Upload />
</div>
</div>
<NInfiniteScroll
class="flex-1"
scrollbarProps={{
contentClass: 'py-4',
}}
onLoad={loadMore}
>
<div class="flex flex-wrap gap-5 mx-auto max-w-[808px] xl:max-w-[1084px]">
{state.fileList.map((item, i) => (
<Item key={item.id} data={item} />
))}
</div>
{state.loading && (
<div class="text-center p-4">
<div class="inline-flex items-center gap-2">
<div class="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
<span class="text-gray-500">加载中...</span>
</div>
</div>
)}
{state.noMore && (
<div class="text-center p-4 text-gray-500">
没有更多了 🤪
</div>
)}
</NInfiniteScroll>
</div>
)
},
})
5.3 缩略图堆叠组件
// src/pages/chat/components/ThumbnailStack/index.tsx
import { defineComponent, ref, watch, computed } from 'vue'
import cls from 'classnames'
import { mergeCss } from '@/theme'
export const ThumbnailStack = defineComponent({
name: 'ThumbnailStack',
props: {
activeIndex: {
type: Number,
default: 0,
},
thumbnails: {
type: Array as PropType<any[]>,
default: () => [],
},
},
emits: ['update:activeIndex'],
setup(props, { emit }) {
const localActiveIndex = ref(props.activeIndex)
// 更新活动索引
const updateActiveIndex = (newIndex: number) => {
localActiveIndex.value = newIndex
emit('update:activeIndex', newIndex)
}
// 监听props变化
watch(
() => props.activeIndex,
newIndex => {
localActiveIndex.value = newIndex
}
)
// 监听缩略图变化
watch(
() => props.thumbnails,
newThumbnails => {
if (newThumbnails.length > 0) {
newThumbnails.forEach((item, index) => {
// 处理缩略图数据
})
}
},
{ deep: true, immediate: true }
)
// 计算每个卡片的3D样式
const getCardStyle = (index: number) => {
const relativeIndex =
(index - localActiveIndex.value + props.thumbnails.length) % props.thumbnails.length
const absCoord = Math.abs(relativeIndex)
// 只显示前5张卡片
if (absCoord > 4) {
return { display: 'none' }
}
const zIndex = 100 - absCoord
// 垂直前后层叠效果参数
let scale = 1
let translateX = 0
let translateY = 0
let translateZ = 0
let rotateX = 0
let opacity = 1
let width = '80%'
let height = '80%'
let top = '50%'
if (absCoord === 0) {
scale = 1
translateX = 0
translateY = 0
translateZ = 0
rotateX = 0
opacity = 1
top = '47%'
} else if (absCoord === 1) {
scale = 0.95
translateX = 0
translateY = 0
translateZ = -20
rotateX = 0
opacity = 0.75
top = '40%'
} else if (absCoord === 2) {
scale = 0.9
translateX = 0
translateY = 0
translateZ = -40
rotateX = 0
opacity = 0.55
top = '36%'
} else if (absCoord === 3) {
scale = 0.85
translateX = 0
translateY = 0
translateZ = -60
rotateX = 0
opacity = 0.35
top = '38%'
} else if (absCoord === 4) {
scale = 0.8
translateX = 0
translateY = 0
translateZ = -80
rotateX = 0
opacity = 0.2
top = '36%'
}
return {
zIndex,
opacity,
width,
height,
left: '50%',
top,
transform: `translate(-50%, -50%) perspective(800px) translateX(${translateX}px) translateY(${translateY}px) translateZ(${translateZ}px) rotateX(${rotateX}deg) scale(${scale})`,
transformOrigin: 'center center',
}
}
// 切换到指定索引
const goToSlide = (index: number) => {
updateActiveIndex(index)
}
// 切换到下一张
const nextSlide = () => {
const nextIndex = (localActiveIndex.value + 1) % props.thumbnails.length
updateActiveIndex(nextIndex)
}
// 切换到上一张
const prevSlide = () => {
const prevIndex = (localActiveIndex.value - 1 + props.thumbnails.length) % props.thumbnails.length
updateActiveIndex(prevIndex)
}
return () => (
<div class="relative w-full h-full overflow-hidden">
{/* 缩略图容器 */}
<div class="relative w-full h-full">
{props.thumbnails.map((thumbnail, index) => (
<div
key={index}
class={cls(
'absolute cursor-pointer transition-all duration-500 ease-out',
mergeCss(['transition'])
)}
style={getCardStyle(index)}
onClick={() => goToSlide(index)}
>
<div class="w-full h-full rounded-lg overflow-hidden shadow-lg">
<img
src={thumbnail.url}
alt={thumbnail.title}
class="w-full h-full object-cover"
/>
</div>
</div>
))}
</div>
{/* 导航按钮 */}
<div class="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2">
<button
onClick={prevSlide}
class={cls(
'w-8 h-8 rounded-full bg-white/80 hover:bg-white',
'flex items-center justify-center',
'transition-all duration-200',
mergeCss(['transition'])
)}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="flex items-center gap-1">
{props.thumbnails.map((_, index) => (
<button
key={index}
onClick={() => goToSlide(index)}
class={cls(
'w-2 h-2 rounded-full transition-all duration-200',
{
'bg-white': index === localActiveIndex.value,
'bg-white/40': index !== localActiveIndex.value,
}
)}
/>
))}
</div>
<button
onClick={nextSlide}
class={cls(
'w-8 h-8 rounded-full bg-white/80 hover:bg-white',
'flex items-center justify-center',
'transition-all duration-200',
mergeCss(['transition'])
)}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
)
},
})
5.4 技能列表组件
// src/components/SendWidget/components/SkillList/index.tsx
import { defineComponent, reactive } from 'vue'
import { NInput, NScrollbar } from 'naive-ui'
import cls from 'classnames'
import { mergeCss } from '@/theme'
const utilList = [
{
name: '数据分析',
desc: '智能分析数据趋势和模式',
icon: '📊',
color: 'bg-blue-500',
},
{
name: '代码生成',
desc: '根据需求生成高质量代码',
icon: '💻',
color: 'bg-green-500',
},
{
name: '文档写作',
desc: '协助撰写各类文档和报告',
icon: '📝',
color: 'bg-purple-500',
},
{
name: '图像处理',
desc: '智能图像编辑和优化',
icon: '🖼️',
color: 'bg-orange-500',
},
{
name: '翻译助手',
desc: '多语言翻译和本地化',
icon: '🌐',
color: 'bg-red-500',
},
]
export default defineComponent({
name: 'SkillList',
props: {
show: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const state = reactive({
searchUtil: '',
})
const onClose = () => emit('update:show', false)
return () => (
<div class="py-2">
<div class="px-2 pb-2">
<NInput
placeholder="搜索技能"
class="!rounded-lg"
size="large"
v-model:value={state.searchUtil}
/>
</div>
<NScrollbar class="h-[200px]" trigger="none">
{utilList
.filter(item =>
state.searchUtil ? item.name.includes(state.searchUtil) : true
)
.map(item => (
<div
class={cls(
'flex items-center gap-3 mx-2 px-2 py-2 rounded-lg cursor-pointer',
mergeCss(['bgHover', 'transition'])
)}
onClick={() => {
state.searchUtil = item.name
onClose()
}}
>
<div
class={cls(
'w-6 h-6 rounded flex items-center justify-center',
item.color
)}
>
<span class="text-sm">{item.icon}</span>
</div>
<div class="flex-1">
<div class="font-medium">{item.name}</div>
<div class="text-sm line-clamp-1">{item.desc}</div>
</div>
</div>
))}
</NScrollbar>
</div>
)
},
})
关键技术点
5.1 虚拟滚动算法
- 视口计算: 只渲染可见区域内的元素
- 高度预估: 使用预估高度进行初始布局
- 动态测量: 实时测量实际元素高度
- 预渲染: 渲染超出视口的元素以提供平滑滚动
5.2 性能优化策略
- 内存管理: 及时清理不可见的DOM元素
- 事件优化: 使用事件委托减少事件监听器
- 渲染优化: 避免不必要的重渲染
- 滚动优化: 使用transform代替top/left定位
5.3 用户体验优化
- 平滑滚动: 使用CSS transition提供流畅动画
- 加载状态: 显示加载指示器
- 错误处理: 优雅处理加载失败情况
- 响应式设计: 适配不同屏幕尺寸
5.4 无限滚动实现
- 分页加载: 按需加载数据
- 状态管理: 管理加载状态和是否还有更多数据
- 防抖处理: 避免频繁触发加载
- 错误重试: 自动重试失败的请求
总结
虚拟滚动是现代前端应用中处理大量数据的重要技术,通过只渲染可见元素来显著提升性能。实现要点包括:
- 算法设计: 精确计算可见区域和需要渲染的元素
- 性能优化: 减少DOM操作和内存占用
- 用户体验: 提供流畅的滚动体验和加载反馈
- 错误处理: 优雅处理各种异常情况
这5个功能模块展现了现代前端开发的技术深度,每个功能都需要深入理解相关技术原理和最佳实践才能实现。对于想要提升技术水平的开发者来说,这些功能都是很好的学习案例。