虚拟滚动优化——js技能提升

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 虚拟滚动算法
  1. 视口计算: 只渲染可见区域内的元素
  2. 高度预估: 使用预估高度进行初始布局
  3. 动态测量: 实时测量实际元素高度
  4. 预渲染: 渲染超出视口的元素以提供平滑滚动
5.2 性能优化策略
  1. 内存管理: 及时清理不可见的DOM元素
  2. 事件优化: 使用事件委托减少事件监听器
  3. 渲染优化: 避免不必要的重渲染
  4. 滚动优化: 使用transform代替top/left定位
5.3 用户体验优化
  1. 平滑滚动: 使用CSS transition提供流畅动画
  2. 加载状态: 显示加载指示器
  3. 错误处理: 优雅处理加载失败情况
  4. 响应式设计: 适配不同屏幕尺寸
5.4 无限滚动实现
  1. 分页加载: 按需加载数据
  2. 状态管理: 管理加载状态和是否还有更多数据
  3. 防抖处理: 避免频繁触发加载
  4. 错误重试: 自动重试失败的请求

总结

虚拟滚动是现代前端应用中处理大量数据的重要技术,通过只渲染可见元素来显著提升性能。实现要点包括:

  1. 算法设计: 精确计算可见区域和需要渲染的元素
  2. 性能优化: 减少DOM操作和内存占用
  3. 用户体验: 提供流畅的滚动体验和加载反馈
  4. 错误处理: 优雅处理各种异常情况

这5个功能模块展现了现代前端开发的技术深度,每个功能都需要深入理解相关技术原理和最佳实践才能实现。对于想要提升技术水平的开发者来说,这些功能都是很好的学习案例。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

叶浩成520

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值