Vue3+TS手写不定高虚拟列表Hooks,彻底解决长列表卡顿,生产直接复用

一、前言:为什么业务必须用「不定高虚拟列表」?

在中后台管理系统、大数据可视化、聊天会话列表、动态商品流、消息通知等场景中,我们经常需要渲染上千甚至上万条长列表数据。如果采用传统全量渲染方式,会一次性生成大量 DOM 节点,进而引发页面卡顿、滚动掉帧、白屏卡顿、内存占用过高等性能问题。

虚拟列表是前端长列表性能优化的最优解决方案,核心思想非常清晰:只渲染当前可视区域的 DOM 节点,销毁/缓存非可视区域内容,让页面始终维持极少 DOM 数量,从根源解决大数据渲染卡顿问题。

目前网上大部分开源虚拟列表方案,均基于固定列表项高度 实现,算法简单但业务适配性极差。真实业务中,文本自适应换行、内容长度不统一、自定义插槽、动态卡片布局,都会导致列表项高度不固定

因此,自适应不定高虚拟列表才是覆盖绝大多数业务场景的通用方案。本文基于 Vue3 组合式 Hooks 思想封装,实现逻辑与视图完全解耦、全局可复用、自动缓存节点高度、滚动丝滑无抖动、无错位空白。

二、定高 vs 不定高 虚拟列表核心差异对比

方案类型 优点 缺点 适用场景
定高虚拟列表 算法简单、计算开销小、滚动响应丝滑 高度固定写死,无法适配动态内容,业务兼容性差 每行高度完全统一的简单结构化列表
不定高虚拟列表 自适应任意动态高度、无需手动传入高度、业务适配性极强 需要动态计算真实高度+缓存管理,算法复杂度略高 聊天记录、富文本列表、商品卡片、动态内容不规则列表

三、不定高虚拟列表核心实现原理

不同于定高列表通过「索引 × 固定高度」快速计算位置,不定高虚拟列表的核心难点在于:未知高度、动态渲染、需要精准校正滚动位置。整套方案依赖「动态高度缓存 + 可视区间计算 + 偏移量实时修正」三大核心能力:

  1. 维护条目高度缓存:记录每一条列表项的真实渲染高度,避免重复重排计算,大幅提升滚动性能
  2. 累计高度动态计算:实时累加条目高度,计算滚动容器整体高度与每一项的顶部偏移量
  3. 动态截取可视区间:根据当前滚动位置,实时推算需要渲染的起始索引、结束索引,精准截取可视数据
  4. 占位容器模拟滚动区域:通过累计总高度撑开外层滚动条,保证滚动条比例、滑动范围与完整列表一致
  5. 实时偏移量校正:动态更新可视区域位移,解决不定高布局常见的滚动抖动、空白、错位、回弹问题

将所有虚拟列表核心逻辑抽离为独立 TS Hooks,放置在 composables/useVirtualList.ts,通过泛型约束数据类型、完整类型推导,实现逻辑与视图完全解耦、全局高复用,适配所有 Vue3 + TS 项目。

四、全局通用 Hooks 封装(TS 生产完整版)

四、全局通用 Hooks 封装(TS 生产完整版)

ini 复制代码
import { ref, computed, watch, type Ref } from 'vue'

/** 虚拟列表配置项 */
interface VirtualListOptions {
  pageSize?: number
  estimateHeight?: number
}

/**
 * 不定高虚拟列表 Hooks(TS 完整版)
 * 自动缓存高度、动态计算可视区域、支持刷新/分页/筛选
 */
export default function useVirtualList<T extends Record<string, any>>(
  listData: Ref<T[]>,
  options: VirtualListOptions = {}
) {
  const { pageSize = 10, estimateHeight = 80 } = options

  // 滚动容器 DOM
  const wrapperRef = ref<HTMLDivElement | null>(null)
  // 可视区域起始/结束索引
  const startIndex = ref(0)
  const endIndex = ref(pageSize)
  // 滚动偏移量
  const scrollOffset = ref(0)

  // 高度缓存:key=索引,value=对应条目高度
  const itemHeightCache = ref<Record<number, number>>({})

  // 获取单条条目真实高度(有缓存用缓存,无缓存用预估高度)
  const getItemHeight = (index: number): number => {
    return itemHeightCache.value[index] ?? estimateHeight
  }

  // 计算指定索引对应的顶部累积高度
  const getItemTop = (index: number): number => {
    let top = 0
    for (let i = 0; i < index; i++) {
      top += getItemHeight(i)
    }
    return top
  }

  // 列表整体总高度(用于撑开滚动条)
  const totalHeight = computed(() => {
    let total = 0
    listData.value.forEach((_, index) => {
      total += getItemHeight(index)
    })
    return total
  })

  // 当前可视区域渲染的数据
  const visibleList = computed<T[]>(() => {
    return listData.value.slice(startIndex.value, endIndex.value)
  })

  // 滚动事件:动态计算可视区间
  const handleScroll = () => {
    if (!wrapperRef.value) return
    const scrollTop = wrapperRef.value.scrollTop
    scrollOffset.value = scrollTop

    let heightSum = 0
    let start = 0
    const dataLen = listData.value.length

    for (let i = 0; i < dataLen; i++) {
      heightSum += getItemHeight(i)
      if (heightSum >= scrollTop) {
        start = i
        break
      }
    }

    startIndex.value = start
    endIndex.value = start + pageSize
  }

  // 更新并缓存单条高度
  const updateItemHeight = (index: number, height: number) => {
    if (height && itemHeightCache.value[index] !== height) {
      itemHeightCache.value[index] = height
    }
  }

  // 重置虚拟列表(刷新、筛选、重置场景)
  const resetVirtualList = () => {
    startIndex.value = 0
    endIndex.value = pageSize
    scrollOffset.value = 0
    itemHeightCache.value = {}
    if (wrapperRef.value) {
      wrapperRef.value.scrollTop = 0
    }
  }

  // 监听数据源全量变更,自动重置
  watch(
    listData,
    () => {
      resetVirtualList()
    },
    { deep: true }
  )

  return {
    wrapperRef,
    visibleList,
    totalHeight,
    scrollOffset,
    startIndex,
    endIndex,
    handleScroll,
    updateItemHeight,
    resetVirtualList
  }
}
ini 复制代码
import { ref, computed, watch, type Ref } from 'vue'

/** 虚拟列表配置参数 */
export interface VirtualListOptions {
  /** 单次渲染可视条数 */
  pageSize?: number
  /** 条目预估兜底高度 */
  estimateHeight?: number
}

/**
 * Vue3 不定高虚拟列表 Hooks(TS 生产版)
 * 支持自动高度缓存、动态可视区间计算、滚动偏移校正、数据刷新重置
 * 适配任意动态高度列表、分页加载、搜索筛选、增删改场景
 */
export default function useVirtualList<T extends Record<string, unknown>>(
  listData: Ref<T[]>,
  options: VirtualListOptions = {}
) {
  // 解构配置项,设置默认值
  const { pageSize = 10, estimateHeight = 80 } = options

  // 滚动容器 DOM 实例
  const wrapperRef = ref<HTMLDivElement | null>(null)
  // 可视区域起始索引
  const startIndex = ref<number>(0)
  // 可视区域结束索引
  const endIndex = ref<number>(pageSize)
  // 当前滚动偏移量
  const scrollOffset = ref<number>(0)

  // 条目高度缓存:key=数据索引,value=对应真实 DOM 高度
  const itemHeightCache = ref<Record<number, number>>({})

  /**
   * 获取单条条目真实高度
   * 有缓存优先读缓存,无缓存使用预估高度
   */
  const getItemHeight = (index: number): number => {
    return itemHeightCache.value[index] ?? estimateHeight
  }

  /**
   * 计算指定索引条目顶部累积偏移高度
   */
  const getItemTop = (index: number): number => {
    let top = 0
    for (let i = 0; i < index; i++) {
      top += getItemHeight(i)
    }
    return top
  }

  /**
   * 计算列表整体总高度(用于撑开滚动条,保证滚动范围正确)
   */
  const totalHeight = computed<number>(() => {
    let total = 0
    listData.value.forEach((_, index) => {
      total += getItemHeight(index)
    })
    return total
  })

  /**
   * 当前可视区域渲染数据
   */
  const visibleList = computed<T[]>(() => {
    return listData.value.slice(startIndex.value, endIndex.value)
  })

  /**
   * 滚动监听核心逻辑:动态计算可视区间、更新偏移量
   */
  const handleScroll = (): void => {
    if (!wrapperRef.value) return

    const scrollTop = wrapperRef.value.scrollTop
    scrollOffset.value = scrollTop

    let heightSum = 0
    let start = 0
    const dataLen = listData.value.length

    // 根据滚动高度匹配当前起始渲染索引
    for (let i = 0; i < dataLen; i++) {
      heightSum += getItemHeight(i)
      if (heightSum >= scrollTop) {
        start = i
        break
      }
    }

    startIndex.value = start
    endIndex.value = start + pageSize
  }

  /**
   * 更新并缓存单条条目真实 DOM 高度
   * @param index 数据全局索引
   * @param height 条目真实高度
   */
  const updateItemHeight = (index: number, height: number): void => {
    if (height && itemHeightCache.value[index] !== height) {
      itemHeightCache.value[index] = height
    }
  }

  /**
   * 重置虚拟列表所有状态
   * 适用于:数据刷新、搜索筛选、页面重置、弹窗重载
   */
  const resetVirtualList = (): void => {
    startIndex.value = 0
    endIndex.value = pageSize
    scrollOffset.value = 0
    itemHeightCache.value = {}
    if (wrapperRef.value) {
      wrapperRef.value.scrollTop = 0
    }
  }

  /**
   * 监听数据源全量变更,自动重置列表状态
   * 适配搜索、筛选、刷新场景,自动清空过期高度缓存
   */
  watch(
    listData,
    () => {
      resetVirtualList()
    },
    { deep: true }
  )

  return {
    wrapperRef,
    visibleList,
    totalHeight,
    scrollOffset,
    startIndex,
    endIndex,
    handleScroll,
    updateItemHeight,
    resetVirtualList
  }
}

五、TS 业务组件完整使用示例(开箱即用)

视图层完全适配 TS 语法,自带类型推导、无类型报错,可直接在 Vue3 + TS 项目中使用,结构简洁、无需冗余类型重复定义。

视图层只需关注页面结构与样式,无需处理复杂计算逻辑,直接引入 Hooks 即可快速实现不定高虚拟列表。

xml 复制代码
<template>
  <!-- 虚拟列表外层滚动容器 -->
  <div class="virtual-wrapper" ref="wrapperRef" @scroll="handleScroll">
    <!-- 占位层:用于撑开完整滚动高度,保证滚动条正常显示、比例正确 -->
    <div :style="{ height: totalHeight + 'px' }"></div>
    <!-- 可视内容层:通过 translateY 动态修正滚动偏移,解决不定高错位问题 -->
    <div class="virtual-content" :style="{ transform: `translateY(${scrollOffset}px)` }">
      <div
        class="list-item"
        v-for="(item, index) in visibleList"
        :key="item.id"
        ref="elRefs"
        @afterEnter="() => updateItemHeight(startIndex + index, elRefs[index]?.offsetHeight)"
      >
        <!-- 自定义不定高业务内容 -->
        <div class="title">{{ item.title }}</div>
        <div class="desc">{{ item.desc }}</div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, type Ref } from 'vue'
import useVirtualList from '@/composables/useVirtualList'

// 定义列表数据类型
interface ListItem {
  id: number
  title: string
  desc: string
}

// 模拟 2000 条不定高测试数据
const listData: Ref<ListItem[]> = ref(
  Array.from({ length: 2000 }, (_, i) => ({
    id: i + 1,
    title: `动态列表条目 ${i + 1}`,
    desc: i % 3 === 0 
      ? '短文本内容' 
      : i % 3 === 1 
        ? '超长文本内容超长文本内容超长文本内容超长文本内容' 
        : '中等长度描述文本'
  }))
)

// 初始化虚拟列表 Hooks,可自定义渲染条数、预估高度
const {
  wrapperRef,
  visibleList,
  totalHeight,
  scrollOffset,
  startIndex,
  handleScroll,
  updateItemHeight
} = useVirtualList(listData, {
  pageSize: 12,
  estimateHeight: 80
})

// 收集列表 DOM 节点,用于采集真实高度
const elRefs: Ref<HTMLDivElement[]> = ref([])
</script>

<style scoped>
.virtual-wrapper {
  height: 500px;
  overflow-y: auto;
  position: relative;
}
.virtual-content {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
}
.list-item {
  padding: 12px 16px;
  border-bottom: 1px solid #eee;
}
.title {
  font-size: 14px;
  font-weight: 500;
}
.desc {
  font-size: 12px;
  color: #666;
  margin-top: 4px;
  line-height: 1.6;
}
</style>

六、核心亮点与关键优化细节

1. 智能高度缓存策略

首次渲染采用预估高度占位,避免空白闪烁;节点渲染完成后自动采集真实 DOM 高度并缓存,后续滚动直接复用缓存数据,无需重复重排计算,从根源解决不定高列表常见的滚动回弹、抖动、错位问题。

2. 逻辑视图完全解耦,复用性极强

所有复杂计算、区间判断、缓存管理、状态重置逻辑全部封装在 Hooks 内部。视图层仅负责结构渲染,任意业务页面、任意自定义列表结构,只需传入数据源即可快速接入虚拟滚动能力,零冗余重复代码。

3. 数据源监听自动重置机制

深度监听列表数据源变化,在数据刷新、搜索筛选、分页切换时,自动清空历史高度缓存、重置滚动位置与可视区间,彻底避免新旧数据叠加、高度残留、页面错乱等问题。

4. 精准位移偏移量修正

通过动态计算 translateY 偏移量实时校正可视区域位置,完美适配高度不固定的动态布局,根治传统虚拟列表普遍存在的滚动空白、界面跳跃、定位不准等顽疾。

七、通用业务场景适配扩展

1. 完美适配分页加载

实现上拉加载下一页时,只需直接拼接数据源,Hooks 会自动增量缓存新增条目的高度,无需额外处理逻辑,滚动体验连贯自然。

2. 支持搜索筛选实时刷新

关键词筛选、条件过滤后数据源变更,自动触发重置逻辑,清空旧缓存、重新计算高度,适配全新列表布局。

3. 兼容增删改操作

列表置顶、删除条目、编辑内容等操作,只需更新数据源数组,虚拟列表会自动同步更新高度缓存与可视区域,状态实时同步。

八、分页增量加载(TS 生产级完整版)

原生封装的 Hooks 默认监听数据源全量变更会重置缓存,适配搜索、筛选、刷新 场景,但传统分页「下拉加载更多」属于数据增量场景,不能清空历史高度缓存,否则会出现滚动抖动、历史条目高度丢失问题。

这里优化适配生产级分页逻辑,区分「全量刷新」和「增量加载」,实现增量缓存高度、滚动不回弹、加载丝滑无错乱

8.1 核心优化思路

  • 关闭默认全量监听重置逻辑,手动区分数据操作类型
  • 分页加载更多:仅拼接数据、增量缓存新条目高度,保留旧缓存
  • 筛选/刷新/重置:全量替换数据、清空缓存、重置滚动位置

8.2 改造 Hooks(支持增量/全量双模式)

微调 Hooks,暴露手动重置方法,移除默认全局深度监听,交由业务层手动控制重置逻辑,适配分页增量场景。

ini 复制代码
import { ref, computed, type Ref } from 'vue'

/** 虚拟列表配置参数 */
export interface VirtualListOptions {
  /** 单次渲染可视条数 */
  pageSize?: number
  /** 条目预估兜底高度 */
  estimateHeight?: number
}

/**
 * Vue3 不定高虚拟列表 Hooks(TS 增强分页版)
 * 支持:全量刷新重置 + 分页增量加载缓存保留
 */
export default function useVirtualList<T extends Record<string, unknown>>(
  listData: Ref<T[]>,
  options: VirtualListOptions = {}
) {
  // 解构配置项,设置默认值
  const { pageSize = 10, estimateHeight = 80 } = options

  // 滚动容器 DOM 实例
  const wrapperRef = ref<HTMLDivElement | null>(null)
  // 可视区域起始索引
  const startIndex = ref<number>(0)
  // 可视区域结束索引
  const endIndex = ref<number>(pageSize)
  // 当前滚动偏移量
  const scrollOffset = ref<number>(0)

  // 条目高度缓存:key=数据索引,value=对应真实 DOM 高度
  const itemHeightCache = ref<Record<number, number>>({})

  /**
   * 获取单条条目真实高度
   * 有缓存优先读缓存,无缓存使用预估高度
   */
  const getItemHeight = (index: number): number => {
    return itemHeightCache.value[index] ?? estimateHeight
  }

  /**
   * 计算指定索引条目顶部累积偏移高度
   */
  const getItemTop = (index: number): number => {
    let top = 0
    for (let i = 0; i < index; i++) {
      top += getItemHeight(i)
    }
    return top
  }

  /**
   * 计算列表整体总高度(用于撑开滚动条,保证滚动范围正确)
   */
  const totalHeight = computed<number>(() => {
    let total = 0
    listData.value.forEach((_, index) => {
      total += getItemHeight(index)
    })
    return total
  })

  /**
   * 当前可视区域渲染数据
   */
  const visibleList = computed<T[]>(() => {
    return listData.value.slice(startIndex.value, endIndex.value)
  })

  /**
   * 滚动监听核心逻辑:动态计算可视区间、更新偏移量
   */
  const handleScroll = (): void => {
    if (!wrapperRef.value) return

    const scrollTop = wrapperRef.value.scrollTop
    scrollOffset.value = scrollTop

    let heightSum = 0
    let start = 0
    const dataLen = listData.value.length

    // 根据滚动高度匹配当前起始渲染索引
    for (let i = 0; i < dataLen; i++) {
      heightSum += getItemHeight(i)
      if (heightSum >= scrollTop) {
        start = i
        break
      }
    }

    startIndex.value = start
    endIndex.value = start + pageSize
  }

  /**
   * 更新并缓存单条条目真实 DOM 高度
   * @param index 数据全局索引
   * @param height 条目真实高度
   */
  const updateItemHeight = (index: number, height: number): void => {
    if (height && itemHeightCache.value[index] !== height) {
      itemHeightCache.value[index] = height
    }
  }

  /**
   * 全量重置虚拟列表状态
   * 适用于:搜索筛选、列表刷新、页面重置、弹窗重载
   */
  const resetVirtualList = (): void => {
    startIndex.value = 0
    endIndex.value = pageSize
    scrollOffset.value = 0
    itemHeightCache.value = {}
    if (wrapperRef.value) {
      wrapperRef.value.scrollTop = 0
    }
  }

  /**
   * 增量更新列表数据(分页加载专用)
   * 只拼接数据,保留历史高度缓存,不重置滚动位置
   */
  const appendListData = (newData: T[]): void => {
    listData.value = [...listData.value, ...newData]
  }

  return {
    wrapperRef,
    visibleList,
    totalHeight,
    scrollOffset,
    startIndex,
    endIndex,
    handleScroll,
    updateItemHeight,
    resetVirtualList,
    appendListData
  }
}

8.3 分页业务组件 TS 完整案例

实现上拉加载更多、分页防重、无数据兜底、刷新重置全套生产逻辑,完美适配虚拟列表增量机制。

ini 复制代码
<template>
  <div class="virtual-wrapper" ref="wrapperRef" @scroll="handleScroll">
    <div :style="{ height: totalHeight + 'px' }"></div>
    <div class="virtual-content" :style="{ transform: `translateY(${scrollOffset}px)` }">
      <div
        class="list-item"
        v-for="(item, index) in visibleList"
        :key="item.id"
        ref="elRefs"
        @afterEnter="() => updateItemHeight(startIndex + index, elRefs[index]?.offsetHeight)"
      >
        <div class="title">{{ item.title }}</div>
        <div class="desc">{{ item.desc }}</div>
      </div>
    </div>
    <!-- 加载状态兜底 -->
    <div class="load-more" v-if="loading">加载中...</div>
    <div class="load-more" v-if="noMore">没有更多数据了</div>
  </div>
</template>

<script setup lang="ts">
import { ref, type Ref } from 'vue'
import useVirtualList from '@/composables/useVirtualList'

// 定义列表数据类型
interface ListItem {
  id: number
  title: string
  desc: string
}

// 分页状态
const page = ref(1)
const pageSize = 20
const loading = ref(false)
const noMore = ref(false)

// 列表数据源
const listData: Ref<ListItem[]> = ref([])

// 初始化虚拟列表
const {
  wrapperRef,
  visibleList,
  totalHeight,
  scrollOffset,
  startIndex,
  handleScroll,
  updateItemHeight,
  resetVirtualList,
  appendListData
} = useVirtualList(listData, {
  pageSize: 12,
  estimateHeight: 80
})

// DOM收集
const elRefs: Ref<HTMLDivElement[]> = ref([])

// 模拟接口请求数据
const getListData = async (isRefresh = false) => {
  if (loading.value) return
  loading.value = true

  // 模拟接口延迟
  await new Promise(resolve => setTimeout(resolve, 600))

  // 模拟分页数据
  const start = (page.value - 1) * pageSize
  const newData: ListItem[] = Array.from({ length: pageSize }, (_, i) => {
    const idx = start + i
    return {
      id: idx + 1,
      title: `动态列表条目 ${idx + 1}`,
      desc: idx % 3 === 0 
        ? '短文本内容' 
        : idx % 3 === 1 
          ? '超长文本内容超长文本内容超长文本内容超长文本内容' 
          : '中等长度描述文本'
    }
  })

  // 模拟总共5页数据
  if (page.value >= 5) {
    noMore.value = true
  }

  // 区分:刷新重置 / 增量加载
  if (isRefresh) {
    listData.value = newData
    resetVirtualList()
    noMore.value = false
  } else {
    appendListData(newData)
  }

  loading.value = false
}

// 上拉加载更多
const handleScrollBottom = () => {
  if (noMore.value || loading.value) return
  // 判断滚动到底部阈值
  const { scrollTop, scrollHeight, clientHeight } = wrapperRef.value!
  if (scrollTop + clientHeight >= scrollHeight - 50) {
    page.value++
    getListData()
  }
}

// 监听滚动触底
const originScroll = handleScroll
const newHandleScroll = () => {
  originScroll()
  handleScrollBottom()
}

// 首次加载
getListData(true)


<style scoped>
.virtual-wrapper {
  height: 500px;
  overflow-y: auto;
  position: relative;
}
.virtual-content {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
}
.list-item {
  padding: 12px 16px;
  border-bottom: 1px solid #eee;
}
.title {
  font-size: 14px;
  font-weight: 500;
}
.desc {
  font-size: 12px;
  color: #666;
  margin-top: 4px;
  line-height: 1.6;
}
.load-more {
  text-align: center;
  padding: 16px;
  color: #999;
  font-size: 12px;
}
</style>

8.4 分页核心优势

  • 增量缓存不丢失:分页加载只拼接数据、保留历史高度缓存,滚动不回弹、无抖动
  • 场景精准区分:刷新/筛选全量重置、分页增量叠加,双场景完美适配
  • 防重加载机制:自带 loading 锁,避免快速滚动重复请求接口
  • 完备兜底状态:包含加载中、无更多数据兜底,交互完整

九、总结

这套Vue3 不定高虚拟列表通用 Hooks ,彻底解决了传统定高虚拟列表适配性差、无法应对动态内容的痛点,同时补齐了生产必备的分页增量加载能力,真正实现了业务通用的高性能长列表解决方案。

方案核心优势总结:

  • TS 强类型约束:通过泛型+接口约束数据类型,全程类型校验,杜绝隐式类型错误,适配大型工程项目
  • 解耦复用、零业务侵入:纯逻辑 Hooks 封装,视图只负责渲染,任意列表结构可快速接入
  • 智能高度缓存机制:预估高度兜底+真实高度缓存,兼顾首屏体验与滚动精度
  • 双模式数据更新:支持全量重置(刷新/筛选)+增量加载(分页),覆盖全部业务场景
  • 解决行业痛点:彻底根治虚拟列表滚动空白、错位、抖动、回弹、错乱等常见问题

十、生产落地注意事项 & 高频避坑指南

本套不定高虚拟列表 Hooks 虽通用性极强,但在实际项目落地中,存在部分容易忽略的细节与隐性坑点,这里统一整理生产级规范与避坑方案,保证项目稳定运行。

1. 列表项 Key 禁止使用索引(高频BUG,附正反案例)

示例代码初期为简化演示,未严格规范 Key 取值。正式业务绝对禁止使用 index 作为列表 key。当列表出现删除、新增、排序、筛选操作时,数组索引会重新排布,导致「高度缓存索引」与「真实数据」错位,出现高度缓存匹配错误、DOM 复用错乱、滚动内容漂移等严重问题。

错误写法(会 BUG)

ini 复制代码
<div
  class="list-item"
  v-for="(item, index) in visibleList"
  :key="index"
>
  {{ item.title }}
</div>

正确写法(生产规范)

ini 复制代码
<div
  class="list-item"
  v-for="(item, index) in visibleList"
  :key="item.id"
>
  {{ item.title }}
</div>

核心规范:必须使用数据唯一 ID(id/uuid)作为 Key,让「数据、DOM、高度缓存」三者永久绑定,杜绝错乱。

示例代码初期为简化演示,未严格规范 Key 取值。正式业务绝对禁止使用 index 作为列表 key。当列表出现删除、新增、排序、筛选操作时,数组索引会重新排布,导致「高度缓存索引」与「真实数据」错位,出现高度缓存匹配错误、DOM 复用错乱、滚动内容漂移等严重问题。

错误写法(会 BUG)

ini 复制代码
<div
  class="list-item"
  v-for="(item, index) in visibleList"
  :key="index"
>
  {{ item.title }}
</div>

正确写法(生产规范)

ini 复制代码
<div
  class="list-item"
  v-for="(item, index) in visibleList"
  :key="item.id"
>
  {{ item.title }}
</div>

核心规范:必须使用数据唯一 ID(id/uuid)作为 Key,让「数据、DOM、高度缓存」三者永久绑定,杜绝错乱。

2. 滚动容器必须固定有效高度(附错误/正确代码)

虚拟列表的可视区间计算、滚动监听、偏移校正全部依赖滚动容器的可视尺寸。若容器高度自适应、高度塌陷、百分比高度失效,会导致滚动计算逻辑彻底失效,出现大面积空白、滚动无反应问题。

错误写法(高度塌陷/无效)

arduino 复制代码
/* 无高度 / 自适应高度,虚拟列表失效 */
.virtual-wrapper {
  overflow-y: auto;
  position: relative;
}

正确写法(生产标准)

css 复制代码
.virtual-wrapper {
  height: 500px; /* 固定像素高度 */
  /* height: calc(100vh - 180px); 动态计算高度也可 */
  overflow-y: auto;
  position: relative;
}

落地规范 :容器必须具备明确计算后高度 + overflow-y: auto,缺一不可。

虚拟列表的可视区间计算、滚动监听、偏移校正全部依赖滚动容器的可视尺寸。若容器高度自适应、高度塌陷、百分比高度失效,会导致滚动计算逻辑彻底失效,出现大面积空白、滚动无反应问题。

错误写法(高度塌陷/无效)

arduino 复制代码
/* 无高度 / 自适应高度,虚拟列表失效 */
.virtual-wrapper {
  overflow-y: auto;
  position: relative;
}

正确写法(生产标准)

css 复制代码
.virtual-wrapper {
  height: 500px; /* 固定像素高度 */
  /* height: calc(100vh - 180px); 动态计算高度也可 */
  overflow-y: auto;
  position: relative;
}

落地规范 :容器必须具备明确计算后高度 + overflow-y: auto,缺一不可。

落地规范 :滚动容器必须设置明确高度(固定 px / 计算后 vh / 动态计算赋值),同时开启 overflow-y: auto

3. 列表异步内容渲染问题(图片/异步数据,附修复代码)

若列表内部包含图片、异步接口渲染内容、延时 DOM,会出现「先采集高度、后渲染内容」的情况,导致缓存高度偏小、滚动抖动、内容被截断。

问题场景:列表内图片未设置宽高,图片加载完成后列表项高度变大,但缓存仍是旧高度。

xml 复制代码
<div class="list-item" v-for="item in visibleList" :key="item.id">
  <!-- 图片异步加载,高度会变化 -->
  <img :src="item.imgUrl" alt="" />
  <p>{{ item.desc }}</p>
</div>

解决方案:异步资源加载完毕后,手动重置虚拟列表缓存。

ini 复制代码
<img 
  :src="item.imgUrl" 
  alt=""
  @load="resetVirtualList()"
/>

批量接口场景可在数据二次更新完成后 调用 resetVirtualList() 刷新高度缓存,保证高度采集精准。

若列表内部包含图片、异步接口渲染内容、延时 DOM,会出现「先采集高度、后渲染内容」的情况,导致缓存高度偏小、滚动抖动、内容被截断。

问题场景:列表内图片未设置宽高,图片加载完成后列表项高度变大,但缓存仍是旧高度。

xml 复制代码
<div class="list-item" v-for="item in visibleList" :key="item.id">
  <!-- 图片异步加载,高度会变化 -->
  <img :src="item.imgUrl" alt="" />
  <p>{{ item.desc }}</p>
</div>

解决方案:异步资源加载完毕后,手动重置虚拟列表缓存。

ini 复制代码
<img 
  :src="item.imgUrl" 
  alt=""
  @load="resetVirtualList()"
/>

批量接口场景可在数据二次更新完成后 调用 resetVirtualList() 刷新高度缓存。

4. 预估高度精准配置(附配置代码)

默认 estimateHeight: 80 为通用兜底值,若业务列表平均高度偏差过大,会出现首屏空白、初次滚动跳跃问题。

优化方案:根据业务 UI 平均高度自定义传入预估高度

php 复制代码
// 短文本列表:设置更小预估高度
const { ... } = useVirtualList(listData, {
  pageSize: 12,
  estimateHeight: 60
})

// 卡片/大图列表:设置更大预估高度
// estimateHeight: 120

预估高度越贴近真实平均高度,首屏渲染与初次滚动体验越丝滑,能有效规避首次滚动偏移、空白闪烁问题。

默认 estimateHeight: 80 为通用兜底值,若业务列表平均高度偏差过大,会出现首屏空白、初次滚动跳跃问题。

优化方案:根据业务 UI 平均高度自定义传入预估高度

php 复制代码
// 短文本列表:设置更小预估高度
const { ... } = useVirtualList(listData, {
  pageSize: 12,
  estimateHeight: 60
})

// 卡片/大图列表:设置更大预估高度
// estimateHeight: 120

预估高度越贴近真实平均高度,首屏渲染与初次滚动体验越丝滑。

解决方案 :异步资源加载完成后,手动调用 resetVirtualList() 重置高度缓存,重新采集真实 DOM 高度。

代码中默认预估高度为 80px,若业务列表普遍高度远大于或远小于该值,会出现初始滚动偏差、首屏空白问题。

优化规范 :根据业务列表平均高度自定义 estimateHeight,贴合真实布局,大幅提升初次滚动体验。

5. 分页增量 & 筛选刷新 双场景区分(核心代码案例)

全量重置和增量加载必须严格区分,混用会导致分页滚动回弹、高度缓存清空错乱。Hooks 原始的全局数据监听会自动重置缓存,仅适用于筛选、刷新场景,分页增量加载必须手动控制,禁止清空历史缓存。

标准业务代码规范

csharp 复制代码
// 1. 搜索、筛选、下拉刷新、重置 → 全量覆盖 + 清空缓存
const refreshList = async () => {
  page.value = 1
  const res = await api.getList({ page: 1 })
  listData.value = res.data
  resetVirtualList() // 必须重置
  noMore.value = false
}

// 2. 上拉加载更多 → 增量拼接 + 保留历史高度缓存
const loadMore = async () => {
  page.value++
  const res = await api.getList({ page: page.value })
  appendListData(res.data) // 增量API,不重置缓存
}

业务核心区分规则:刷新/筛选场景重置缓存、分页增量加载场景保留旧缓存,彻底解决分页滚动跳动问题。

全量重置和增量加载必须严格区分,混用会导致分页滚动回弹、高度缓存清空错乱。

标准业务代码规范

csharp 复制代码
// 1. 搜索、筛选、下拉刷新、重置 → 全量覆盖 + 清空缓存
const refreshList = async () => {
  page.value = 1
  const res = await api.getList({ page: 1 })
  listData.value = res.data
  resetVirtualList() // 必须重置
  noMore.value = false
}

// 2. 上拉加载更多 → 增量拼接 + 保留历史高度缓存
const loadMore = async () => {
  page.value++
  const res = await api.getList({ page: page.value })
  appendListData(res.data) // 增量API,不重置缓存
}

业务区分:刷新/筛选 → 重置缓存;分页加载 → 增量拼接、保留旧缓存。

6. 弹窗/抽屉内虚拟列表(延迟初始化代码)

弹窗、抽屉默认 display: none,DOM 未实际渲染,元素offsetHeight = 0,初始化采集的高度全部失效,会导致滚动空白、高度错乱、内容错位。

解决方案:弹窗打开、DOM 渲染完成后,手动重置列表状态

xml 复制代码
<template>
  <el-dialog v-model="visible" title="弹窗虚拟列表">
    <div class="virtual-wrapper" ref="wrapperRef" @scroll="handleScroll">
      <!-- 虚拟列表内容 -->
    </div>
  </el-dialog>
</template>

<script setup lang="ts">
import { nextTick } from 'vue'
// 弹窗打开后重置
const handleOpen = () => {
  // 等待DOM渲染完成后再初始化高度
  nextTick(() => {
    resetVirtualList()
  })
}
</script>

通过延迟重置的方式,等待弹窗 DOM 挂载完成后采集真实高度,彻底修复隐藏容器内虚拟列表失效问题。

弹窗、抽屉默认 display: none,DOM 元素 offsetHeight = 0,初始化采集高度全部失效,导致滚动空白、高度错乱。

解决方案:弹窗打开动画结束后,手动重置列表

xml 复制代码
<template>
  <el-dialog v-model="visible" title="弹窗虚拟列表">
    <div class="virtual-wrapper" ref="wrapperRef" @scroll="handleScroll"&gt;
      <!-- 虚拟列表内容 -->
    </div>
  </el-dialog>
</template>

<script setup lang="ts">
// 弹窗打开后重置
const handleOpen = () => {
  // 等待DOM渲染完成
  nextTick(() => {
    resetVirtualList()
  })
}
</script>

解决方案:在弹窗打开、DOM 渲染完成后再初始化虚拟列表,或打开弹窗后手动重置一次列表状态。

7. 禁止列表项脱离文档流(正反样式代码)

列表项内部元素使用浮动、绝对定位脱离文档流,会导致父容器高度塌陷,采集的 offsetHeight 数值不准,造成高度缓存错乱、滚动错位。

错误样式(高度塌陷)

css 复制代码
.list-item .left {
  float: left;
}
.list-item .abs-box {
  position: absolute;
  right: 0;
}

正确样式(稳定可采集)

css 复制代码
.list-item {
  display: flex;
  flex-direction: column;
  padding: 12px 16px;
  border-bottom: 1px solid #eee;
}

规范要求:列表项内部统一使用 flex、grid 或标准文档流布局,禁止元素脱离文档流,保证 DOM 高度可正常采集、缓存精准有效。

列表项内部元素使用浮动、绝对定位脱离文档流,会导致父容器高度塌陷,采集的 offsetHeight 不准,缓存错乱。

错误样式(高度塌陷)

css 复制代码
.list-item .left {
  float: left;
}
.list-item .abs-box {
  position: absolute;
  right: 0;
}

正确样式(稳定可采集)

css 复制代码
.list-item {
  display: flex;
  flex-direction: column;
  padding: 12px 16px;
  border-bottom: 1px solid #eee;
}

使用 flex/grid 标准文档流布局,保证节点高度可被正常采集缓存。

规范要求:列表项内部布局使用标准文档流、flex、grid 布局,保证 DOM 高度可正常采集。

8. 小数据量禁用虚拟列表(代码判断案例)

虚拟列表存在一定计算开销,属于大数据量性能优化方案。小数据量场景下,直接全量渲染性能更优,过度优化反而会增加代码冗余、提升维护成本。

业务判断代码

xml 复制代码
<template>
  <!-- 阈值判断:少于100条直接原生渲染,大于100条启用虚拟列表 -->
  <template v-if="listData.length < 100">
    <div class="list-item" v-for="item in listData" :key="item.id">
      {{ item.title }}
      {{ item.desc }}
    </div>
  </template>
  <template v-else>
    <!-- 虚拟列表组件 -->
    <div class="virtual-wrapper" ref="wrapperRef" @scroll="handleScroll">
      <div :style="{ height: totalHeight + 'px' }"></div>
      <div class="virtual-content" :style="{ transform: `translateY(${scrollOffset}px)` }">
        <div
          class="list-item"
          v-for="(item, index) in visibleList"
          :key="item.id"
          ref="elRefs"
          @afterEnter="() => updateItemHeight(startIndex + index, elRefs[index]?.offsetHeight)"
        >
          {{ item.title }}
          {{ item.desc }}
        </div>
      </div>
    </div>
  </template>

使用阈值规范:常规业务列表数据超过 100 条、出现滚动卡顿、页面掉帧问题时,再启用虚拟列表优化。

虚拟列表存在计算开销,小数据量直接全量渲染性能更优,无需过度优化。

业务判断代码

使用阈值:常规列表数据超过 100 条、存在滚动卡顿问题时,再启用虚拟列表。

相关推荐
ZC跨境爬虫6 小时前
跟着 MDN 学 HTML day_61:(构建反馈表单的结构化挑战)
前端·javascript·ui·html·音视频
卷帘依旧6 小时前
Vue2中defineProperty缺陷
前端
长安第一美人6 小时前
工业级实时监控系统开发:PHP+ZMQ+JS 前后端分离架构全解析
前端·嵌入式硬件·架构·交互·rk3588·zmq后端
ricardo19736 小时前
资源加载提速四件套:dns-prefetch / preconnect / preload / prefetch 实战
前端·面试
豹哥学前端6 小时前
JavaScript 异步编程完全指南:从回调地狱到 async/await,一次通关
前端·javascript·面试
kyriewen6 小时前
面试官让我手写Promise,我打开Cursor三秒生成,他愣了两秒说“你过了”
前端·javascript·面试
Bacon6 小时前
RAG 从入门到入土:Agent 时代,你的检索增强生成到底行不行?
前端·人工智能
软件开发技术深度爱好者6 小时前
HTML实现DOCX文档版题库图文考试系统(修订)
前端·javascript·html
宁雨桥6 小时前
从跨项目预览到分层架构:一次 `postMessage` 封装的深度思考
前端·架构·postmessage