技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

深入虚拟列表:动态高度、缓冲与异步加载(Vue 实现)

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/88-深入【虚拟列表】动态高度、缓冲、异步加载 Vue实现
本文目录17 个章节
  1. 一、为什么需要虚拟列表
  2. 二、固定高度是最简单的基线
  3. 三、动态高度:为什么固定公式失效
  4. 四、用 positions 缓存位置
  5. 五、如何找到动态高度的开始索引
  6. 六、Vue 2 的窗口计算示例
  7. 七、不要在 updated 中无条件修改 positions
  8. 八、动态高度下的滚动锚点
  9. 九、滚动缓冲区(overscan)
  10. 十、异步资源导致的高度变化
  11. 十一、二分查找与大规模位置更新
  12. 十二、懒加载与异步请求
  13. 十三、空白、闪烁和性能优化
  14. 十四、VueUse 和成熟虚拟列表库
  15. 十五、当前实现的版本差异
  16. 总结
  17. 参考资料

深入虚拟列表:动态高度、缓冲与异步加载(Vue 实现)

原文在固定高度虚拟列表的基础上,继续讨论动态高度、滚动缓冲、图片加载和触底加载。本文保留原有问题分析和实现思路,并修正缓存初始化、二分查找、updated 递归更新、ResizeObserver 观察目标、触底判断和追加数据导致滚动跳动等问题。

一、为什么需要虚拟列表

当列表包含几十万条数据时,一次性把所有记录渲染成 DOM 会带来:

  • 初始创建和挂载时间长;
  • 大量 DOM 节点占用内存;
  • 更新、样式计算和布局成本增加;
  • 滚动过程中主线程工作量变大。

虚拟列表(windowing/virtual scrolling)的核心是:数据可以很多,但同一时刻只把视口附近的一小部分记录渲染成 DOM。通常通过一个具有完整高度的 wrapper 保持滚动条,再把可见行放在正确的偏移位置。

需要区分三个概念:

  • 分页:减少一次请求的数据量;
  • 无限滚动/懒加载:滚动到附近时继续请求数据;
  • 虚拟列表:减少当前时刻的 DOM 数量。

三者可以组合,但不是同一件事。时间分片可以把 JavaScript 工作拆到多个任务中,却不能自动把大量 DOM 变成虚拟列表。

二、固定高度是最简单的基线

假设每行高度都是 itemHeight = 50px,视口高度是 viewportHeight

const visibleCount = Math.ceil(viewportHeight / itemHeight)
const firstVisible = Math.floor(scrollTop / itemHeight)
const renderStart = Math.max(0, firstVisible - overscan)
const renderEnd = Math.min(
  list.length,
  firstVisible + visibleCount + overscan,
)
const offset = renderStart * itemHeight
const totalHeight = list.length * itemHeight

一个固定高度的 DOM 结构可以是:

<div class="viewport">
  <div class="spacer" style="height: 500000px">
    <div class="items" style="transform: translateY(12000px)">
      <!-- 只渲染视口和缓冲区内的行 -->
    </div>
  </div>
</div>

其中:

  • viewport 负责滚动,通常设置固定高度和 overflow: auto
  • spacer 或 wrapper 模拟完整列表高度;
  • items 只包含当前窗口的数据;
  • transform、padding-top 或绝对定位都可以表达偏移,但应避免每一行都单独绝对定位造成额外样式成本。

固定高度的优点是所有位置都能 O(1) 计算;本文的难点是当真实高度不固定时,如何逐步修正估算值。

三、动态高度:为什么固定公式失效

固定高度时可以直接计算:

总高度 = 数据量 × itemHeight
开始索引 = floor(scrollTop / itemHeight)
偏移量 = 开始索引 × itemHeight

但多行文本、图片、折叠内容和响应式宽度会让每一行高度不同:

动态高度列表的展示示例(已本地化)

包含图片的动态高度列表示例(已本地化)

这时需要解决:

  1. 首次渲染前拿不到所有行的真实高度;
  2. 一行高度变化会影响它后面所有行的 top/bottom;
  3. 滚动时必须快速找到对应的开始索引;
  4. 图片加载、字体加载和容器宽度变化可能再次改变高度;
  5. 修正上方行高度时不能让用户看到的内容突然跳走。

四、用 positions 缓存位置

维护一个按数据顺序排列的缓存数组:

type Position = {
  index: number
  id: string | number
  top: number
  bottom: number
  height: number
  measured: boolean
}

1. 正确初始化估算位置

原文的 initPositions(this.listData, this.positions)positions 数组当成了高度参数,属于明显错误。初始化时应传入预估高度;追加数据时则从上一个已知 bottom 继续生成,而不是把已有测量结果全部重置。

function initPositions(list, estimatedHeight) {
  let offset = 0

  return list.map((item, index) => {
    const height = estimatedHeight
    const position = {
      index,
      id: item.id ?? index,
      top: offset,
      bottom: offset + height,
      height,
      measured: false,
    }
    offset += height
    return position
  })
}

function appendEstimatedPositions(positions, items, estimatedHeight) {
  let offset = positions.length
    ? positions[positions.length - 1].bottom
    : 0
  const start = positions.length

  for (let i = 0; i < items.length; i++) {
    positions.push({
      index: start + i,
      id: items[i].id ?? start + i,
      top: offset,
      bottom: offset + estimatedHeight,
      height: estimatedHeight,
      measured: false,
    })
    offset += estimatedHeight
  }
}

Vue 2.7 可以在 data() 中放置 positions;Vue 3 可以使用 ref/shallowRef。生产实现还应使用稳定的业务 id 作为 key,并在插入、删除和排序时同步重建或迁移 position,而不是永远把数组 index 当作身份。

2. 总高度

function getTotalHeight(positions) {
  return positions.length === 0
    ? 0
    : positions[positions.length - 1].bottom
}

空列表时不能直接访问 positions[positions.length - 1].bottom,否则会抛出异常。

3. 根据真实高度更新后续位置

function updatePosition(positions, index, measuredHeight) {
  const position = positions[index]
  if (!position) return 0

  const height = Math.max(0, measuredHeight)
  const delta = height - position.height
  if (Math.abs(delta) < 0.5) return 0

  position.height = height
  position.measured = true
  position.bottom += delta

  for (let i = index + 1; i < positions.length; i++) {
    positions[i].top += delta
    positions[i].bottom += delta
  }

  return delta
}

原文使用 dValue = oldHeight - height 再减去 dValue,数学上可以得到相同结果,但使用 delta = new - old 更直观,也不容易在后续维护时把符号写反。

一次更新会影响后续位置,简单数组实现的更新成本是 O(n)。如果列表规模特别大且测量频繁,可以考虑前缀和、Fenwick Tree 或 Segment Tree;一般业务虚拟列表使用估算、批量测量和可见行数量控制后已经足够。

五、如何找到动态高度的开始索引

positionsbottom 按索引单调递增,可以寻找第一个满足:

positions[index].bottom > scrollTop

的 position。原文的二分查找中把 right 写成 right--,会退化为线性查找并且边界不清晰;正确的二分收缩应使用 right = mid - 1

function getStartIndex(positions, scrollTop) {
  if (positions.length === 0) return 0

  let left = 0
  let right = positions.length - 1
  let answer = positions.length - 1

  while (left <= right) {
    const mid = left + ((right - left) >> 1)

    if (positions[mid].bottom > scrollTop) {
      answer = mid
      right = mid - 1
    } else {
      left = mid + 1
    }
  }

  return answer
}

找到开始索引后,可以继续向后查找可见区结束索引:

function getEndIndex(positions, start, scrollTop, viewportHeight) {
  const bottom = scrollTop + viewportHeight
  let end = start

  while (
    end < positions.length
    && positions[end].top < bottom
  ) {
    end++
  }

  return end
}

如果每行高度已知但变化很少,可以用二分查找;如果所有位置频繁变化,则应批量测量,避免每次 scroll 都重新扫描整张表。

六、Vue 2 的窗口计算示例

下面保留 Options API 形式,方便和原文 Vue 2 代码对照。它只展示窗口状态和计算方式:

export default {
  props: {
    listData: { type: Array, required: true },
    viewportHeight: { type: Number, default: 400 },
  },

  data() {
    return {
      estimatedHeight: 50,
      overscan: 5,
      scrollTop: 0,
      positions: [],
      measuring: false,
      measureFrame: 0,
    }
  },

  computed: {
    totalHeight() {
      return getTotalHeight(this.positions)
    },

    startIndex() {
      return getStartIndex(this.positions, this.scrollTop)
    },

    renderStart() {
      return Math.max(0, this.startIndex - this.overscan)
    },

    renderEnd() {
      const end = getEndIndex(
        this.positions,
        this.startIndex,
        this.scrollTop,
        this.viewportHeight,
      )
      return Math.min(this.positions.length, end + this.overscan)
    },

    visibleData() {
      return this.listData.slice(this.renderStart, this.renderEnd)
    },

    currentOffset() {
      return this.positions[this.renderStart]?.top || 0
    },
  },

  watch: {
    listData: {
      immediate: true,
      handler(list) {
        const oldLength = this.positions.length
        const identityChanged = list.some((item, index) => {
          return this.positions[index]?.id !== (item.id ?? index)
        })

        if (oldLength === 0 || list.length < oldLength || identityChanged) {
          this.positions = initPositions(list, this.estimatedHeight)
        } else if (list.length > oldLength) {
          const next = this.positions.slice()
          appendEstimatedPositions(
            next,
            list.slice(oldLength),
            this.estimatedHeight,
          )
          this.positions = next
        }

        // 同长度的数据内容也可能变化,交给测量阶段确认高度。
        this.scheduleMeasure()
      },
    },
  },

  mounted() {
    this.$refs.viewport.addEventListener('scroll', this.onScroll, {
      passive: true,
    })
  },

  beforeDestroy() {
    this.$refs.viewport?.removeEventListener('scroll', this.onScroll)
    if (this.measureFrame) cancelAnimationFrame(this.measureFrame)
    this.resizeObserver?.disconnect()
  },

  methods: {
    onScroll(event) {
      this.scrollTop = event.currentTarget.scrollTop
      this.scheduleMeasure()
    },

    scheduleMeasure() {
      if (this.measureFrame) return

      this.measureFrame = requestAnimationFrame(() => {
        this.measureFrame = 0
        this.$nextTick(() => this.measureVisibleRows())
      })
    },

    measureVisibleRows() {
      if (this.measuring) return
      this.measuring = true

      try {
        const refs = this.$refs.items || []
        const nodes = Array.isArray(refs) ? refs : [refs]
        const anchorIndex = this.startIndex

        for (const node of nodes) {
          const index = Number(node.dataset.index)
          const height = node.getBoundingClientRect().height
          const delta = updatePosition(this.positions, index, height)

          // 如果修正的是当前视口之前的行,保持用户看到的内容锚点。
          if (delta && index < anchorIndex) {
            this.$refs.viewport.scrollTop += delta
          }
        }
      } finally {
        this.measuring = false
      }
    },
  },
}

模板的关键是 data-index、稳定 key 和完整高度 wrapper:

<template>
  <div
    ref="viewport"
    class="virtual-viewport"
    :style="{ height: `${viewportHeight}px` }"
  >
    <div class="virtual-wrapper" :style="{ height: `${totalHeight}px` }">
      <div
        class="virtual-items"
        :style="{ transform: `translateY(${currentOffset}px)` }"
      >
        <div
          v-for="(item, offset) in visibleData"
          :key="item.id"
          ref="items"
          :data-index="renderStart + offset"
          class="list-item"
        >
          <slot :item="item" />
        </div>
      </div>
    </div>
  </div>
</template>

这段代码是 Vue 2.7 风格:ref="items"v-for 中通常得到数组,生命周期销毁钩子是 beforeDestroy。Vue 3 应使用 onBeforeUnmount,并建议使用函数 ref 管理动态节点,不能机械地把 Vue 2 的 $refs 行为当成 Vue 3 契约。

动态高度修正后的虚拟列表效果(已本地化)

七、不要在 updated 中无条件修改 positions

原文在 updated 中每次调用 updatePositions,而 updatePositions 又修改响应式 positions,可能形成:

updated → 修改 positions → 更新视图 → updated → ...

即使没有无限循环,也会在每次更新后重复读取所有可见 DOM。更稳妥的方案是:

  1. 只测量当前渲染的行;
  2. 通过 requestAnimationFrame 合并同一帧的测量;
  3. 高度变化小于阈值时不更新;
  4. 有变化时批量修正后续位置;
  5. 观察器在组件卸载时断开。

如果使用 ResizeObserver,不要只观察列表容器:容器尺寸变化不一定意味着每一行高度变化,应该观察当前可见行。下面是需要合并到前一个 Vue 2 组件中的补充片段,pendingMeasurements 应放进同一个 data() 返回对象:

mounted() {
  if (typeof ResizeObserver === 'undefined') return

  this.resizeObserver = new ResizeObserver(entries => {
    // ResizeObserver 可能一次返回多条记录,统一在下一帧处理更稳妥。
    for (const entry of entries) {
      const index = Number(entry.target.dataset.index)
      const height = entry.contentRect.height
      this.pendingMeasurements.set(index, height)
    }
    this.scheduleApplyMeasurements()
  })

  this.$nextTick(() => this.observeVisibleRows())
},

data() {
  return {
    pendingMeasurements: new Map(),
  }
},

methods: {
  observeVisibleRows() {
    const refs = this.$refs.items || []
    const nodes = Array.isArray(refs) ? refs : [refs]
    for (const node of nodes) {
      this.resizeObserver?.observe(node)
    }
  },

  scheduleApplyMeasurements() {
    if (this.measureFrame) return
    this.measureFrame = requestAnimationFrame(() => {
      this.measureFrame = 0
      for (const [index, height] of this.pendingMeasurements) {
        updatePosition(this.positions, index, height)
      }
      this.pendingMeasurements.clear()
    })
  },
},

beforeDestroy() {
  this.resizeObserver?.disconnect()
},

ResizeObserver 回调发生在布局相关阶段,如果在回调里无条件改变被观察元素的尺寸,可能触发 ResizeObserver loop。尺寸更新应有稳定条件,必要时放到 requestAnimationFrame 中处理。

八、动态高度下的滚动锚点

假设用户当前看到第 100 行,前面的第 20 行图片加载后高度增加了 80px。如果只修改 positions,浏览器滚动位置可能让用户看到的内容整体向下跳。

常见处理方式是:

const anchorIndex = getStartIndex(positions, scrollTop)
const anchorId = list[anchorIndex].id
const anchorBefore = positions[anchorIndex].top

// 批量应用前方高度变化
applyMeasurements()

const anchorAfter = positions.find(p => p.id === anchorId)?.top
if (anchorAfter !== undefined) {
  viewport.scrollTop += anchorAfter - anchorBefore
}

也可以依赖浏览器的 scroll anchoring,但虚拟列表通常自己掌握 wrapper/transform,显式维护锚点更容易控制。对于“用户正在拖动滚动条”的场景,应避免过度校正导致反向跳动,可以根据滚动状态和变化位置决定是否调整。

九、滚动缓冲区(overscan)

仅渲染刚好可视的行,快速滚动时可能出现短暂空白:滚动事件发生了,但新一批 DOM 还没提交。解决方法是在可视区域上下多渲染一些行:

const overscan = 5
const renderStart = Math.max(0, startIndex - overscan)
const renderEnd = Math.min(list.length, endIndex + overscan)

虚拟列表上方和下方缓冲区示意图(已本地化)

原文用 bufferPercent * visibleCount 计算数量是可行的起点,但要注意:

  • 小屏幕和大屏幕的可视行数不同;
  • 复杂行渲染成本高时,缓冲太大反而增加主线程工作;
  • 快速滚轮或触控惯性滚动需要更大的 overscan;
  • 可以按像素距离而不是固定条数设置缓冲;
  • 需要为首尾边界取 Math.max/Math.min

如果使用动态高度,偏移量应以 renderStart 对应的 positions[renderStart].top 为准,而不是继续使用固定高度公式:

function getRenderOffset(positions, renderStart) {
  return positions[renderStart]?.top || 0
}

加入缓冲区后的虚拟列表效果(已本地化)

十、异步资源导致的高度变化

图片、字体、展开内容和接口返回的富文本都可能在首次渲染后改变行高。优先级从高到低通常是:

  1. 业务上给图片设置明确的 width/heightaspect-ratio
  2. 设计上限制文本行数或预留稳定空间;
  3. 无法预知时,用 ResizeObserver 观察可见行;
  4. 图片 load 作为兼容性补充,触发批量测量;
  5. 测量完成后修正 position 和滚动锚点。
<div
  v-for="(item, offset) in visibleData"
  :key="item.id"
  ref="items"
  :data-index="renderStart + offset"
>
  <img
    :src="item.image"
    width="320"
    height="180"
    loading="lazy"
    @load="scheduleMeasure"
  >
</div>

如果无法提供宽高,可以:

function onImageLoad(event, index) {
  const height = event.currentTarget
    .closest('.list-item')
    .getBoundingClientRect().height
  queueMeasurement(index, height)
}

但只监听 img.onload 不够:字体替换、容器宽度改变、折叠展开和异步文本同样会改变高度,所以 ResizeObserver 更通用。

异步内容加载后需要重新测量行高(已本地化)

十一、二分查找与大规模位置更新

原文指出 Array.find 是 O(n),二分查找是 O(log n),这个方向正确,但二分成立的前提是 positions[*].bottom 始终单调递增。每次高度更新后必须同步修正后续 top/bottom,否则二分结果会失效。

简单数组的复杂度如下:

操作复杂度
固定高度找到索引O(1)
动态高度二分找开始索引O(log n)
更新一行并修正后续位置O(n)
渲染窗口内行O(window)

当测量频率高且数据量极大时,可用前缀和结构或 Fenwick Tree 支持“单点高度变化 + 前缀和查询”,也可以使用成熟的 variable-size virtualizer。不要为了几百行数据先引入复杂树结构,先测量瓶颈。

十二、懒加载与异步请求

一次性请求几十万条数据会增加服务端处理时间、响应体大小和客户端解析时间。懒加载可以在虚拟列表附近继续请求,但它解决的是数据获取,不是 DOM 虚拟化。

1. 不要用精确相等判断触底

原文使用:

scrollTop + clientHeight === scrollHeight

由于缩放、滚动取整和小数像素,这个判断容易错过触底。应使用阈值:

const distanceToBottom = scrollHeight - (scrollTop + clientHeight)
if (distanceToBottom <= 200) {
  loadMore()
}

2. 使用 loading/hasMore 防重复

async loadMore() {
  if (this.loading || !this.hasMore) return

  this.loading = true
  try {
    const result = await fetchPage({
      cursor: this.nextCursor,
      limit: 100,
    })

    // listData 是 prop 时不能直接 push;由父组件更新数据,前面的
    // watcher 会识别新增项并从旧 bottom 继续追加估算 positions。
    this.$emit('update:listData', this.listData.concat(result.list))
    this.nextCursor = result.nextCursor
    this.hasMore = Boolean(result.nextCursor)
  } finally {
    this.loading = false
  }
}

追加新数据时不能重新调用 initPositions(this.listData, preItemSize),否则已测量的高度全部丢失、滚动条可能跳动。应该从旧 positions 的最后一个 bottom 接着追加估算位置。

滚动事件也可以用底部 sentinel 配合 IntersectionObserver 预取:

mounted() {
  this.loadObserver = new IntersectionObserver(entries => {
    if (entries.some(entry => entry.isIntersecting)) {
      this.loadMore()
    }
  }, {
    root: this.$refs.viewport,
    rootMargin: '300px 0px',
  })

  this.loadObserver.observe(this.$refs.sentinel)
},

beforeDestroy() {
  this.loadObserver?.disconnect()
}

IntersectionObserver 适合“接近底部时触发加载”,但它不能替代动态高度 position 计算,也不应该在每一行都创建一个 observer。

3. 后端返回总数

如果后端知道总数,可以返回类似:

{
  "page": 1,
  "size": 100,
  "count": 10000,
  "list": [],
  "nextCursor": "abc123",
  "updateTime": "2025-01-01T00:00:00Z"
}

size 是本次/每页数量,count 是总记录数,不能混为一谈。已知总数时,可以提前为未加载位置创建估算 positions,从而让滚动条接近最终长度;渲染时要区分“位置存在但数据尚未加载”和“数据不存在”,不能把空洞直接当成真实列表项。

未知总数时,滚动条会随着追加数据变长,这是无限滚动的正常表现,可以通过加载状态、骨架屏和“还剩多少”提示减少误解。

十三、空白、闪烁和性能优化

动态测量会增加布局读取成本,但白屏不一定只是“浏览器性能低”:

  • scroll handler 可能没有使用 passive;
  • 每个事件都同步读取大量 getBoundingClientRect
  • 读写 DOM 交替导致 layout thrashing;
  • overscan 太小,滚动速度超过渲染速度;
  • 行组件本身过重或触发了大量响应式更新;
  • 图片没有尺寸,加载后反复改变布局。

可以采用:

  • passive: true 的 scroll listener;
  • requestAnimationFrame 合并滚动和测量;
  • 先批量读尺寸,再批量写 position;
  • 合理 overscan;
  • 给图片预留宽高;
  • content-visibility/contain 等 CSS 优化(确认兼容性后使用);
  • 让行组件保持轻量,避免每行创建不必要的 watcher;
  • 对过滤、排序和大数据转换使用 worker 或分页,而不是只依赖虚拟 DOM。

虚拟列表减少的是 DOM 数量,不会自动减少排序、过滤、序列化和请求成本。

十四、VueUse 和成熟虚拟列表库

当前 VueUse 的 useVirtualList 可以快速实现固定或已知行高的虚拟列表:

import { useVirtualList } from '@vueuse/core'

const { list, containerProps, wrapperProps } = useVirtualList(items, {
  itemHeight: 42,
  overscan: 5,
})

如果每一行的高度已由数据预先决定,也可以按索引返回估计值;内容渲染后自动变化的高度仍需要额外测量:

const knownHeights: number[] = []
const { list, containerProps, wrapperProps } = useVirtualList(items, {
  itemHeight: index => knownHeights[index] ?? 42,
  overscan: 5,
})

VueUse 文档要求 itemHeight 与实际行高保持同步;它不是自动测量任意内容动态高度的完整方案。如果列表是内容驱动的动态高度,应评估支持 variable-size 的专用库,或实现本文的测量/缓存逻辑。

Vue 2 项目还要确认库的 Vue 版本支持;Vue 3/Nuxt 4 项目则要注意 SSR 时不要在服务端直接访问 windowResizeObserver 和 DOM。

十五、当前实现的版本差异

内容Vue 2.7Vue 3.5 / Nuxt 4
列表 refv-for ref 常得到 $refs 数组推荐函数 ref 或模板 ref,行为需按版本处理
卸载钩子beforeDestroy/destroyedonBeforeUnmount/onUnmounted
响应式数组defineProperty + 数组方法改写Proxy;仍需合理更新数据
虚拟列表组件Options API 可实现composable、renderless component 或专用库
SSR需要避免服务端 DOM 访问Nuxt 4 仍需区分 server/client 生命周期

总结

  1. 虚拟列表只渲染窗口附近的 DOM;它不同于分页、懒加载和时间分片,但可以组合使用;
  2. 固定高度可以 O(1) 计算,动态高度需要估算、测量、positions 缓存和二分查找;
  3. 高度变化会影响后续位置,更新时应使用 delta = newHeight - oldHeight 并考虑滚动锚点;
  4. 不要在 updated 中无条件修改响应式 positions,使用批量测量、rAF 和 ResizeObserver;
  5. 缓冲区用 overscan 减少快速滚动白屏,但过大也会增加渲染成本;
  6. 图片和异步内容优先设置尺寸,否则通过观察可见行的 ResizeObserver 修正高度;
  7. 触底加载要使用阈值、loading、hasMore 和 cursor,追加数据时不能重置已有测量结果;
  8. VueUse useVirtualList 适合固定或已知行高,任意动态高度需要额外测量或专用库;
  9. 真正的性能优化要测量 DOM、布局、脚本、网络和数据处理,不要只看虚拟列表本身。

参考资料

原文作者:西维。原文关于动态高度预估、positions、二分查找、缓冲区、图片加载和懒加载的主线予以保留;错误的缓存初始化、二分边界、updated 重复更新、仅观察容器、精确触底判断、重置已测量 positions 和无关 base64 占位图已清理或修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS