【深入】动态高度虚拟列表Vue3实现(2)

96 阅读8分钟

📚 系列文章:本文是虚拟列表系列的第二篇,建议先阅读 固定高度虚拟列表实现

🎯 本文你将学到

  • ✅ 理解动态高度虚拟列表的核心难点
  • ✅ 掌握"预估高度 + 渲染后修正"的解决方案
  • ✅ 完整实现一个生产级别的动态高度虚拟列表组件
  • ✅ 学会使用懒加载优化虚拟列表性能

为什么需要动态高度虚拟列表

在实际业务开发中,我们经常会遇到这些场景:

场景特点示例
💬 消息列表文本长度不固定微信聊天、钉钉消息
📝 评论/回复嵌套层级不同掘金评论区、知乎回答
📰 Feed 流图文混排微博、小红书
📋 数据表格单元格内容不定后台管理系统

这些场景的共同特点是:列表项高度不固定,无法提前预知


核心难点分析

固定高度 vs 动态高度:复杂度对比

让我们先对比一下两种实现的核心区别:

┌─────────────────────────────────────────────────────────────────┐
│                    固定高度:所有计算都是 O(1)                    │
├─────────────────────────────────────────────────────────────────┤
│  Item 0  │ height: 50px    │  scrollTop = 250                   │
│  Item 1  │ height: 50px    │  startIndex = 250 / 50 = 5  ✅     │
│  Item 2  │ height: 50px    │  offset = 5 * 50 = 250px   ✅      │
│  Item 3  │ height: 50px    │  totalHeight = n * 50      ✅      │
│  ...     │ ...             │                                    │
└─────────────────────────────────────────────────────────────────┘
​
┌─────────────────────────────────────────────────────────────────┐
│                    动态高度:需要遍历累加                         │
├─────────────────────────────────────────────────────────────────┤
│  Item 0  │ height: 80px    │  scrollTop = 250                   │
│  Item 1  │ height: 120px   │  startIndex = ???                  │
│  Item 2  │ height: 60px    │  需要遍历:80+120+60 = 260 > 250   │
│  Item 3  │ height: 200px   │  所以 startIndex = 2               │
│  ...     │ ...             │                                    │
└─────────────────────────────────────────────────────────────────┘

🐔🥚 "鸡生蛋"问题

动态高度虚拟列表面临一个经典的循环依赖问题:

┌──────────────────────────────────────────────────────────────┐
│                    动态高度的"鸡生蛋"问题                      │
└──────────────────────────────────────────────────────────────┘
​
问题链条:
┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│ 需要知道高度  │ ──► │ 才能计算位置  │ ──► │ 才能决定渲染  │
└──────────────┘     └──────────────┘     └──────────────┘
       ▲                                          │
       │                                          │
       └──────────────────────────────────────────┘
                   但高度需要渲染后才知道!

核心问题清单

问题固定高度方案动态高度难点
计算 startIndexscrollTop / itemHeight❌ 无法直接计算
计算总高度totalCount * itemHeight❌ 需要累加所有高度
计算偏移量startIndex * itemHeight❌ 需要累加前面所有高度
获取元素高度固定值❌ 渲染后才能获取

解决方案设计

💡 核心思路:预估高度 + 渲染后修正

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│ 使用预估高度  │ ──► │ 先渲染一批    │ ──► │ 获取真实高度  │
└──────────────┘     └──────────────┘     └──────────────┘
                                                │
       ┌────────────────────────────────────────┘
       ▼
┌──────────────┐     ┌──────────────┐
│ 更新位置缓存  │ ──► │ 修正后续位置  │
└──────────────┘     └──────────────┘

数据结构设计:positionCache

为了解决动态高度的计算问题,我们需要维护一个位置缓存表

// 存储每个元素的位置信息
const positionCache = [
  { index: 0, top: 0,   bottom: 80,  height: 80  },
  { index: 1, top: 80,  bottom: 200, height: 120 },
  { index: 2, top: 200, bottom: 260, height: 60  },
  { index: 3, top: 260, bottom: 460, height: 200 },
  // ...
]

🤔 为什么需要 top 和 bottom?

你可能会问:只存 indexheight 不就够了吗?

答案是不够! 让我们看看为什么:

// ❌ 只有 height 时,计算 startIndex 需要这样:
let accumulatedHeight = 0
let startIndex = 0
for (let i = 0; i < positionCache.length; i++) {
  accumulatedHeight += positionCache[i].height
  if (accumulatedHeight > scrollTop) {
    startIndex = i
    break
  }
}
// 时间复杂度:O(n)// ✅ 有 top/bottom 时,可以直接查找:
const startIndex = positionCache.findIndex(item => item.bottom > scrollTop)
// 甚至可以用二分查找优化到 O(log n)

positionCache 结构详解

positionCache = [
  {
    index: 0,      // 元素索引
    top: 0,        // 元素顶部距离列表顶部的距离
    bottom: 80,    // 元素底部距离列表顶部的距离
    height: 80     // 元素高度 (bottom - top)
  },
  {
    index: 1,
    top: 80,       // = 上一项的 bottom(保证连续性)
    bottom: 200,   // = top + height
    height: 120
  },
  // ...
]

关键约束条件

  • top[i] = bottom[i-1](连续性)
  • bottom[i] = top[i] + height[i]
  • height[i] 初始为预估值,渲染后更新为真实值

整体架构图

┌─────────────────────────────────────────────────────────────────────────┐
│  Container(容器)                                                       │
│  height: 400px, overflow: auto                                          │
│                                                                         │
│  ┌───────────────────────────────────────────────────────────────────┐ │
│  │  Phantom(幽灵层)                                                  │ │
│  │  height: positionCache[last].bottom                               │ │
│  │  作用:撑起滚动条,让用户感觉有完整列表                               │ │
│  │                                                                    │ │
│  │  ┌─────────────────────────────────────────────────────────────┐  │ │
│  │  │  Content(内容层)                                           │  │ │
│  │  │  transform: translateY(contentOffset)                       │  │ │
│  │  │                                                              │  │ │
│  │  │  ┌─────────────────────────────────────────────────────┐   │  │ │
│  │  │  │  Item [startIndex]     height: 80px                 │   │  │ │
│  │  │  ├─────────────────────────────────────────────────────┤   │  │ │
│  │  │  │  Item [startIndex+1]   height: 120px                │   │  │ │
│  │  │  ├─────────────────────────────────────────────────────┤   │  │ │
│  │  │  │  Item [startIndex+2]   height: 60px                 │   │  │ │
│  │  │  ├─────────────────────────────────────────────────────┤   │  │ │
│  │  │  │  ...                   只渲染可见区域 + 缓冲区        │   │  │ │
│  │  │  └─────────────────────────────────────────────────────┘   │  │ │
│  │  └─────────────────────────────────────────────────────────────┘  │ │
│  └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘

代码实现

Step 1:初始化位置缓存

首先,我们需要用预估高度初始化所有元素的位置信息:

/** 初始化位置缓存 */
const initPositionCache = (listData, estimatedHeight) => {
  positionCache.value = listData.map((item, index) => ({
    index,
    top: index * estimatedHeight,           // 0, 50, 100, 150, ...
    bottom: (index + 1) * estimatedHeight,  // 50, 100, 150, 200, ...
    height: estimatedHeight                 // 50, 50, 50, 50, ...
  }))
}

初始化后的数据结构(假设 estimatedHeight = 50):

Index │ TopBottomHeight │ 状态
──────┼──────┼────────┼────────┼──────────
  0   │  0   │  50    │  50    │ 预估值
  1   │  50100   │  50    │ 预估值
  2   │  100150   │  50    │ 预估值
  3   │  150200   │  50    │ 预估值
  4   │  200250   │  50    │ 预估值
 ...  │ ...  │  ...   │  ...   │ ...

Step 2:计算可见区域

// 计算:幽灵层高度(撑起滚动条)
const phantomHeight = computed(() => {
  const cache = positionCache.value
  if (!cache.length) return 0
  return cache[cache.length - 1]?.bottom || 0
})
​
// 计算:可见数量(容器能显示的数量 + 缓冲区)
const visibleCount = computed(() => {
  return Math.ceil(containerHeight.value / estimatedHeight.value) + bufferSize.value
})
​
// 计算:结束索引
const endIndex = computed(() => {
  return Math.min(startIndex.value + visibleCount.value, props.listData.length)
})
​
// 计算:可见数据
const visibleData = computed(() => {
  return listData.value.slice(startIndex.value, endIndex.value)
})

Step 3:获取开始索引和偏移量

/** 获取开始索引 - 找到第一个 bottom > scrollTop 的元素 */
const getStartIndex = (scrollTop) => {
  if (!positionCache.value.length) return 0
  const item = positionCache.value.find(item => item && item.bottom > scrollTop)
  return item ? item.index : 0
}
​
/** 获取内容偏移量 - 使用当前项的 top 值 */
const getContentOffset = () => {
  if (startIndex.value === 0) return 0
  const cache = positionCache.value
  return cache[startIndex.value]?.top || 0
}

Step 4:渲染后更新真实高度

这是动态高度虚拟列表的核心逻辑

/** 更新高度信息 - 渲染后获取真实高度并修正位置缓存 */
const updatePositions = () => {
  // 如果组件未激活(keep-alive 缓存中),不更新
  if (!isActive.value) return
​
  const nodes = itemRefs.value
  if (!nodes.length) return
​
  let needUpdate = false
​
  nodes.forEach((node, idx) => {
    if (!node) return
​
    const realIndex = startIndex.value + idx
    const rect = node.getBoundingClientRect()
    const realHeight = rect.height
​
    // 如果高度为 0,说明元素不可见,跳过
    if (realHeight === 0) return
​
    const cachedItem = positionCache.value[realIndex]
    if (!cachedItem) return
​
    const oldHeight = cachedItem.height
    const heightDiff = realHeight - oldHeight
​
    // 允许 0.5px 误差,避免浮点数精度问题
    if (Math.abs(heightDiff) > 0.5) {
      needUpdate = true
​
      // 1️⃣ 更新当前项
      cachedItem.height = realHeight
      cachedItem.bottom = cachedItem.top + realHeight
​
      // 2️⃣ 级联更新后续所有项的位置
      for (let i = realIndex + 1; i < positionCache.value.length; i++) {
        const prevItem = positionCache.value[i - 1]
        const currItem = positionCache.value[i]
        currItem.top = prevItem.bottom
        currItem.bottom = currItem.top + currItem.height
      }
    }
  })
​
  // 如果有更新,重新计算偏移量
  if (needUpdate) {
    contentOffset.value = getContentOffset()
  }
}

更新流程图解

渲染后更新流程
─────────────────────────────────────────────────────────────
​
DOM 渲染完成 (onUpdated + nextTick)
    │
    ▼
┌─────────────────────────────────────────┐
│ 遍历所有渲染的 DOM 节点                   │
│ itemRefs.forEach(node => ...)           │
└────────┬────────────────────────────────┘
         │
         ▼
┌─────────────────────────────────────────┐
│ 获取真实高度                             │
│ const { height } = getBoundingClientRect() │
└────────┬────────────────────────────────┘
         │
         ▼
┌─────────────────────────────────────────┐
│ 计算高度差                               │
│ heightDiff = realHeight - oldHeight     │
│                                          │
│ 例如:oldHeight=50, realHeight=80       │
│ heightDiff = 80 - 50 = 30               │
└────────┬────────────────────────────────┘
         │
         ▼
    heightDiff !== 0 ?
         │
    ┌────┴────┐
    │ 是      │ 否
    ▼         ▼
┌─────────┐  ┌─────────┐
│ 更新缓存 │  │ 跳过    │
└────┬────┘  └─────────┘
     │
     ▼
┌─────────────────────────────────────────┐
│ 1. 更新当前项的 heightbottom         │
│ 2. 级联更新后续所有项的 topbottom    │
└─────────────────────────────────────────┘

Step 5:滚动处理

/** 滚动处理 - 使用 RAF 优化性能 */
const ticking = ref(false)
const handleScroll = (e) => {
  if (!ticking.value) {
    requestAnimationFrame(() => {
      // 检查组件是否还存在
      if (!containerRef.value) {
        ticking.value = false
        return
      }
​
      const newScrollTop = e.target.scrollTop
      scrollTop.value = newScrollTop
​
      // 计算新的起始索引
      const newStartIndex = getStartIndex(newScrollTop)
      
      // 只有索引变化时才更新,减少不必要的渲染
      if (newStartIndex !== startIndex.value) {
        startIndex.value = newStartIndex
        contentOffset.value = getContentOffset()
      }
      
      ticking.value = false
    })
    ticking.value = true
  }
}

Step 6:模板结构

<template>
  <!-- 容器 -->
  <div 
    ref="containerRef" 
    class="container" 
    :style="{ height: containerHeight + 'px', width: containerWidth + 'px' }"
  >
    <!-- 幽灵层:撑起滚动条 -->
    <div 
      ref="phantomRef" 
      class="phantom" 
      :style="{ height: phantomHeight + 'px' }"
    />
    
    <!-- 内容层:通过 transform 定位 -->
    <div 
      ref="contentRef" 
      class="content" 
      :style="{ transform: `translateY(${contentOffset}px)` }"
    >
      <div 
        v-for="(item, index) in visibleData" 
        :key="startIndex + index" 
        :ref="el => setItemRef(el, index)"
        class="list-item"
      >
        <slot :item="item" :index="startIndex + index">
          {{ item }}
        </slot>
      </div>
    </div>
  </div>
</template>

效果

lovegif_1770182005883.gif

完整代码

📦 完整组件代码

<!-- 动态高度list -->
<template>
    <!-- 容器 -->
    <div ref="containerRef" class="container" :style="{ height: containerHeight + 'px', width: containerWidth + 'px' }">
        <!-- 幽灵高度 -->
        <div ref="phantomRef" class="phantom" :style="{ height: phantomHeight + 'px' }">
        </div>
        <!-- 内容区域 -->
        <div ref="contentRef" class="content" :style="{ transform: `translateY(${contentOffset}px)` }">
            <div v-for="(item, index) in visibleData" :key="startIndex + index" :ref="el => setItemRef(el, index)"
                class="list-item">
                <slot :item="item" :index="startIndex + index">
                    {{ item }}
                </slot>
            </div>
        </div>
    </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, toRefs, onUpdated, nextTick, watch } from 'vue';
const props = defineProps({
    // 数据源
    listData: {
        type: Array,
        default: () => []
    },
    // 预估高度
    estimatedHeight: {
        type: Number,
        default: 50
    },
    // 容器高度
    containerHeight: {
        type: Number,
        default: 400
    },
    //容器宽度
    containerWidth: {
        type: Number,
        default: 300
    },
    // 缓冲区大小
    bufferSize: {
        type: Number,
        default: 5
    }
})

const { listData, estimatedHeight, containerHeight, bufferSize } = toRefs(props)

// 容器 ref
const containerRef = ref(null);
//列表项ref
const itemRefs = ref([])
// 组件是否激活(用于 keep-alive)
const isActive = ref(true)

/** 位置缓存表 */
const positionCache = ref([])
// 当前滚动位置
const scrollTop = ref(0);
// 开始索引
const startIndex = ref(0)
//内容偏移量
const contentOffset = ref(0)



// 计算:幽灵层高度(撑起滚动条)
const phantomHeight = computed(() => {
    const cache = positionCache.value
    if (!cache.length) return 0
    return cache[cache.length - 1]?.bottom || 0
})
//计算: 可见数量
const visibleCount = computed(() => {
    return Math.ceil(containerHeight.value / estimatedHeight.value) + bufferSize.value
})
//计算: 结束索引
const endIndex = computed(() => {
    return Math.min(startIndex.value + visibleCount.value, props.listData.length)
})
//计算:可见数据
const visibleData = computed(() => {
    return listData.value.slice(startIndex.value, endIndex.value)
})



/** 初始化位置 */
const initPositionCache = (listData, estimatedHeight) => {
    positionCache.value = listData.map((item, index) => {
        return {
            index,
            top: index * estimatedHeight,
            bottom: (index + 1) * estimatedHeight,
            height: estimatedHeight
        }
    })
}

/** 获取开始索引 */
const getStartIndex = (scrollTop) => {
    if (!positionCache.value.length) return 0
    const item = positionCache.value.find(item => item && item.bottom > scrollTop)
    return item ? item.index : 0
}

/** 获取偏移量 */
const getContentOffset = () => {
    if (startIndex.value === 0) return 0
    const cache = positionCache.value
    return cache[startIndex.value]?.top || 0
}

/** 设置列表项 ref(函数形式) */
const setItemRef = (el, index) => {
    if (el) {
        itemRefs.value[index] = el
    }
}


/** 更新高度信息 */
const updatePositions = () => {
    // 如果组件未激活(keep-alive 缓存中),不更新
    if (!isActive.value) return

    const nodes = itemRefs.value
    if (!nodes.length) return

    let needUpdate = false

    nodes.forEach((node, idx) => {
        if (!node) return

        const realIndex = startIndex.value + idx
        const rect = node.getBoundingClientRect()
        const realHeight = rect.height

        // 如果高度为 0,说明元素不可见,跳过
        if (realHeight === 0) return

        const cachedItem = positionCache.value[realIndex]

        if (!cachedItem) return

        const oldHeight = cachedItem.height
        const heightDiff = realHeight - oldHeight

        if (Math.abs(heightDiff) > 0.5) {  // 允许 0.5px 误差
            needUpdate = true

            // 更新当前项
            cachedItem.height = realHeight
            cachedItem.bottom = cachedItem.top + realHeight

            // 更新后续所有项的位置
            for (let i = realIndex + 1; i < positionCache.value.length; i++) {
                const prevItem = positionCache.value[i - 1]
                const currItem = positionCache.value[i]
                currItem.top = prevItem.bottom
                currItem.bottom = currItem.top + currItem.height
            }
        }
    })

    // 如果有更新,重新计算偏移量
    if (needUpdate) {
        contentOffset.value = getContentOffset()
    }

}



/** 滚动处理 RAF */
const ticking = ref(false)
const handleScroll = (e) => {
    if (!ticking.value) {
        requestAnimationFrame(() => {
            // 检查组件是否还存在
            if (!containerRef.value) {
                ticking.value = false
                return
            }

            const newScrollTop = e.target.scrollTop
            scrollTop.value = newScrollTop

            // 计算新的起始索引
            const newStartIndex = getStartIndex(newScrollTop)
            // 只有索引变化时才更新
            if (newStartIndex !== startIndex.value) {
                startIndex.value = newStartIndex
                contentOffset.value = getContentOffset()
            }
            ticking.value = false
        })
        ticking.value = true
    }
}


/** 生命周期 */
onMounted(() => {
    // 绑定滚动事件(初始化移到 watch 中处理)
    containerRef.value?.addEventListener('scroll', handleScroll, { passive: true })
})


onUpdated(() => {
    nextTick(() => {
        updatePositions()
    })
})

onUnmounted(() => {
    containerRef.value?.removeEventListener('scroll', handleScroll)
    // 清空状态,防止内存泄漏和影响其他页面
    itemRefs.value = []
    positionCache.value = []
    ticking.value = false
    isActive.value = false
})

// keep-alive 激活时
onActivated(() => {
    isActive.value = true
    // 重新绑定滚动事件(以防万一)
    containerRef.value?.addEventListener('scroll', handleScroll, { passive: true })
})

// keep-alive 停用时
onDeactivated(() => {
    isActive.value = false
    // 移除滚动事件,防止在后台触发
    containerRef.value?.removeEventListener('scroll', handleScroll)
})

// 监听数据变化,重新初始化位置缓存
watch(
    () => props.listData,
    (newData) => {
        if (newData && newData.length > 0) {
            // 清空旧的 refs
            itemRefs.value = []
            // 重新初始化位置缓存
            initPositionCache(newData, estimatedHeight.value)
            // 重置滚动状态
            scrollTop.value = 0
            startIndex.value = 0
            contentOffset.value = 0
            // 重置容器滚动位置
            if (containerRef.value) {
                containerRef.value.scrollTop = 0
            }
        }
    },
    {
        immediate: true,  // 立即执行一次(处理初始数据)
        deep: false       // 不需要深度监听,只监听数组引用变化
    }
)

</script>

<style lang="less" scoped>
.container {
    position: relative;
    overflow-y: auto;
    border: 1px solid #e4e7ed;
    border-radius: 4px;
    // 优化滚动性能
    will-change: transform;
    -webkit-overflow-scrolling: touch;
}

.phantom {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    z-index: -1;
}

.content {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    will-change: transform;
}

.list-item {
    display: flex;
    align-items: center;
    padding: 12px 16px;
    border-bottom: 1px solid #f0f0f0;
    box-sizing: border-box;
    word-break: break-word;

    &:last-child {
        border-bottom: none;
    }
}
</style>

🎉 结语

动态高度虚拟列表是前端性能优化中的重要技术,掌握它可以让你轻松应对各种长列表场景。希望这篇文章能帮助你深入理解其原理并应用到实际项目中!

如果觉得有帮助,欢迎点赞收藏 👍

下期预告:虚拟列表系列(3)—— 优化性能