Vue 基础体系 · 第 39/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 长列表虚拟化:可视区、动态高度、滚动锚点和性能

当列表只有几十项时,直接使用 v-for 渲染通常没有问题。当列表增长到几千、几万甚至更多项时,瓶颈往往不在 JavaScript 数组本身,而在于:

  1. 同时创建了过多 DOM 节点;
  2. 大量组件实例参与响应式更新;
  3. 浏览器需要维护更多布局、绘制和事件对象;
  4. 列表项高度变化时,滚动位置可能发生跳动;
  5. 用户滚动时,主线程需要在有限时间内完成事件处理、计算、布局和绘制。

长列表虚拟化的核心不是删除数据,而是只把当前可能进入视口的少量数据映射成 DOM。列表数据仍然完整存在,DOM 只是数据的一个动态窗口。

本文使用 Vue 3、Composition API、TypeScript 和现代 Vite 工具链,依次说明:

  • 什么是可视区和过扫描区;
  • 固定高度列表如何计算渲染范围;
  • 动态高度为什么更复杂;
  • 如何使用高度估计、ResizeObserver 和树状数组维护位置;
  • 高度变化后如何保持滚动锚点;
  • Vue 生命周期、响应式更新和滚动事件如何协同;
  • 常见失败方式、诊断方法和生产取舍。

一、虚拟化到底解决了什么问题

设列表有 NN 项,每项对应一个 DOM 节点。

普通列表的 DOM 数量近似为:

Dnormal=ND_{\text{normal}} = N

虚拟化列表只渲染可视区及其前后缓冲项。设可视区内能容纳 VV 项,前后各保留 OO 项过扫描,则:

DvirtualV+2OD_{\text{virtual}} \approx V + 2O

N=100000N = 100000,而 V=20V = 20O=10O = 10 时,DOM 数量可以从约十万个降低到约四十个。

这里必须区分三个数量:

  • 数据项数量:列表中的完整数据数量,例如 100000
  • 可视项数量:当前真正与视口相交或即将相交的列表项数量;
  • 渲染项数量:可视项加过扫描项,通常略大于可视项。

虚拟化不会让 100000 条数据消失,也不会自动降低数据请求、排序和过滤的成本。它主要降低的是 DOM、组件实例以及与 DOM 相关的布局和绘制成本。

虚拟化列表的基本结构

一个常见的虚拟化列表包含三层:

滚动容器
└── 总高度占位元素
    └── 当前渲染窗口
        ├── 列表项 A
        ├── 列表项 B
        └── 列表项 C

总高度占位元素让浏览器仍然显示一个接近完整列表的滚动条。当前渲染窗口通过 position: absolute 放到它在完整列表中应该出现的位置。

例如:

<div class="viewport">
  <div class="spacer" style="height: 2000000px">
    <div class="row" style="position: absolute; top: 950000px">
      当前可视列表项
    </div>
  </div>
</div>

这里的 2000000px 是逻辑列表总高度,950000px 是当前项在完整列表中的逻辑顶部位置。DOM 中只存在少量 .row,但滚动条仍然表现得像列表完整存在。


二、可视区:从滚动位置推导渲染范围

1. 可视区的定义

设:

  • ss:滚动容器的 scrollTop
  • hh:滚动容器的可视高度,即 clientHeight
  • y=s+hy = s + h:可视区底部;
  • ii 项的顶部为 PiP_i
  • ii 项的底部为 Pi+1P_{i+1}

那么垂直可视区是:

[s,s+h)[s, s+h)

列表项 ii 与可视区相交的条件是:

Pi<s+hPi+1>sP_i < s+h \quad \land \quad P_{i+1} > s

如果只渲染严格相交的项,快速滚动时可能出现空白:滚动事件到达、渲染更新和浏览器绘制之间存在时间差。因此工程实现通常会加入过扫描区

2. 过扫描区

过扫描区是可视区前后额外渲染的范围。它可以按像素或按项数表示。

按项数表示时:

Rstart=max(0,VstartO)R_{\text{start}} = \max(0, V_{\text{start}} - O)

Rend=min(N,Vend+O)R_{\text{end}} = \min(N, V_{\text{end}} + O)

其中:

  • VstartV_{\text{start}}:可视区中的第一项;
  • VendV_{\text{end}}:可视区结束位置;
  • OO:过扫描项数;
  • [Rstart,Rend)[R_{\text{start}}, R_{\text{end}}):实际渲染范围。

过扫描项数不是越大越好:

  • 太小:快速滚动时容易出现白屏;
  • 太大:DOM 数量增加,虚拟化收益下降;
  • 项目内容复杂时,应优先减少单项渲染成本,而不是盲目增加过扫描。

3. 固定高度列表的直接计算

如果每一项高度都为固定值 HH,第 ii 项顶部就是:

Pi=iHP_i = iH

滚动位置为 ss 时,第一项索引可以计算为:

istart=sHi_{\text{start}} = \left\lfloor \frac{s}{H} \right\rfloor

可视区结束索引近似为:

iend=s+hHi_{\text{end}} = \left\lceil \frac{s+h}{H} \right\rceil

总高度为:

T=NHT = NH

这时计算复杂度是 O(1)O(1),固定高度虚拟列表实现简单、稳定,也最容易获得可预测性能。


三、固定高度虚拟列表的 Vue 实现

下面是一个可以直接放入 Vue 3 项目的固定高度组件。它要求每项高度一致,内容不应在渲染后改变高度。

<!-- FixedVirtualList.vue -->
<script setup lang="ts" generic="T">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'

const props = withDefaults(defineProps<{
  items: T[]
  itemHeight: number
  overscan?: number
}>(), {
  overscan: 5,
})

const viewport = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const viewportHeight = ref(0)

let frameId: number | null = null

const visibleRange = computed(() => {
  const count = props.items.length
  const height = props.itemHeight

  if (count === 0 || height <= 0) {
    return { start: 0, end: 0 }
  }

  const firstVisible = Math.floor(scrollTop.value / height)
  const lastVisible = Math.ceil(
    (scrollTop.value + viewportHeight.value) / height,
  )

  return {
    start: Math.max(0, firstVisible - props.overscan),
    end: Math.min(count, lastVisible + props.overscan),
  }
})

const visibleItems = computed(() => {
  const { start, end } = visibleRange.value

  return props.items
    .slice(start, end)
    .map((item, offset) => ({
      item,
      index: start + offset,
      top: (start + offset) * props.itemHeight,
    }))
})

const totalHeight = computed(() => {
  return props.items.length * props.itemHeight
})

function updateScrollTop() {
  frameId = null

  if (viewport.value) {
    scrollTop.value = viewport.value.scrollTop
  }
}

function onScroll() {
  // scroll 事件可能在一帧内触发多次,只安排一次计算。
  if (frameId !== null) {
    return
  }

  frameId = requestAnimationFrame(updateScrollTop)
}

let resizeObserver: ResizeObserver | null = null

onMounted(() => {
  if (!viewport.value) {
    return
  }

  resizeObserver = new ResizeObserver(entries => {
    const entry = entries[0]

    if (entry) {
      viewportHeight.value = entry.contentRect.height
    }
  })

  resizeObserver.observe(viewport.value)
  viewportHeight.value = viewport.value.clientHeight
})

onBeforeUnmount(() => {
  resizeObserver?.disconnect()

  if (frameId !== null) {
    cancelAnimationFrame(frameId)
  }
})
</script>

<template>
  <div
    ref="viewport"
    class="virtual-viewport"
    @scroll.passive="onScroll"
  >
    <div
      class="virtual-spacer"
      :style="{ height: `${totalHeight}px` }"
    >
      <div
        v-for="row in visibleItems"
        :key="row.index"
        class="virtual-row"
        :style="{
          top: `${row.top}px`,
          height: `${itemHeight}px`,
        }"
      >
        <slot
          :item="row.item"
          :index="row.index"
        />
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-viewport {
  height: 480px;
  overflow: auto;
  position: relative;
  overflow-anchor: none;
}

.virtual-spacer {
  position: relative;
  width: 100%;
}

.virtual-row {
  position: absolute;
  left: 0;
  right: 0;
  overflow: hidden;
}
</style>

调用方式:

<script setup lang="ts">
import FixedVirtualList from './FixedVirtualList.vue'

interface User {
  id: number
  name: string
}

const users: User[] = Array.from({ length: 100_000 }, (_, id) => ({
  id,
  name: `用户 ${id}`,
}))
</script>

<template>
  <FixedVirtualList
    :items="users"
    :item-height="48"
    :overscan="8"
  >
    <template #default="{ item, index }">
      <div class="user-row">
        <span>{{ index }}</span>
        <strong>{{ item.name }}</strong>
      </div>
    </template>
  </FixedVirtualList>
</template>

这段代码的关键路径如下:

  1. 滚动事件触发;
  2. requestAnimationFrame 将同一帧内的多次事件合并;
  3. 更新 scrollTop
  4. visibleRange 重新计算;
  5. Vue 只更新当前范围内的 v-for
  6. 新的列表项通过 top 定位到逻辑位置。

@scroll.passive 是浏览器事件监听选项,表示监听器不会调用 preventDefault(),浏览器可以更早处理滚动。它不会自动降低 Vue 更新成本,所以仍然需要合并滚动更新。

key 不应使用可视窗口位置

如果使用:

:key="row.index - visibleRange.start"

那么滚动一项时,原来第 2 个 DOM 节点可能被复用来显示第 3 个数据项,组件内部状态、输入框焦点和异步内容都可能错位。

更可靠的是使用数据项稳定的业务 ID:

:key="row.item.id"

如果数据项没有稳定 ID,至少应使用不会随可视窗口变化的逻辑索引。对于插入、删除和重新排序,稳定业务 ID 更重要。


四、动态高度为什么不能直接套用固定高度公式

固定高度列表依赖:

Pi=iHP_i = iH

动态高度列表中,第 ii 项高度为 HiH_i,因此:

Pi=k=0i1HkP_i = \sum_{k=0}^{i-1} H_k

ii 项底部为:

Pi+1=Pi+HiP_{i+1} = P_i + H_i

这意味着第 10000 项的位置取决于前 9999 项的高度。只要前面某项发生变化,后续所有项的顶部都可能变化。

一个完整算例

假设有四项,高度分别为:

H = [40, 80, 60, 100]

前缀位置为:

P0 = 0
P1 = 40
P2 = 120
P3 = 180
P4 = 280

因此:

项索引 顶部 底部
0 0 40
1 40 120
2 120 180
3 180 280

如果滚动位置为 130

  • 第 0 项范围是 [0, 40),不在滚动位置处;
  • 第 1 项范围是 [40, 120),已经经过;
  • 第 2 项范围是 [120, 180),当前位于可视区起点;
  • 因此起始项为索引 2

如果第 0 项从 40px 变成 70px,高度差为 +30px,新的位置是:

H' = [70, 80, 60, 100]
P' = [0, 70, 150, 210, 310]

第 2 项顶部从 120px 变成 150px。如果 scrollTop 仍然是 130px,用户看到的内容就会整体向下移动 30px。这就是动态高度列表中的滚动跳动。

未测量项必须有高度估计

虚拟化的矛盾在于:

  • 不渲染某项,就无法直接测量它的真实高度;
  • 不知道高度,就无法精确计算后续项的位置。

因此动态高度列表通常为每项维护:

Hi^\hat{H_i}

其中 Hi^\hat{H_i} 是当前高度估计值。已渲染并测量的项使用真实高度,尚未渲染的项使用估计高度:

Hi^={Hi,如果已经测量Hestimate,否则\hat{H_i} = \begin{cases} H_i, & \text{如果已经测量}\\ H_{\text{estimate}}, & \text{否则} \end{cases}

初始总高度是估计总高度。随着更多项被测量,总高度和后续位置逐步修正。

这也是动态高度虚拟化无法像固定高度虚拟化那样天然稳定的根本原因:初始滚动条和后续滚动条都可能修正。


五、动态高度的位置索引:前缀和与树状数组

1. 朴素前缀和

最直接的实现是每次高度改变后重新计算:

prefix[0] = 0
prefix[i + 1] = prefix[i] + height[i]

然后通过二分查找确定滚动位置对应的项。

这种方式:

  • 查询位置:O(logN)O(\log N)
  • 修改一项高度:需要重建后续前缀和,通常是 O(N)O(N)

如果列表高度频繁变化,或者每次更新都影响大量项,O(N)O(N) 更新会成为问题。

2. 树状数组

树状数组,也叫 Fenwick Tree,用于维护一组可修改数值的前缀和。

它支持:

  • 单项增量更新:O(logN)O(\log N)
  • 前缀和查询:O(logN)O(\log N)
  • 按前缀和反查索引:O(logN)O(\log N)

树状数组中的 tree 并不是直接存每个元素,而是让不同节点负责不同长度的区间。lowbit(i) 决定节点覆盖范围:

lowbit(i) = i & -i

例如,节点 8 可以负责一段长度为 8 的区间,节点 6 可以负责长度为 2 的区间。这样更新一个位置时,只需要更新包含它的少量节点。

3. 位置反查的边界

设前缀和为:

Pk=i=0k1HiP_k = \sum_{i=0}^{k-1} H_i

要找到滚动位置 yy 所处的项,需要找出满足:

Piy<Pi+1P_i \le y < P_{i+1}

的索引 ii

在边界 y = P_i 时,应选择第 i 项,而不是第 i - 1 项。这要求实现使用“第一个严格大于 y 的前缀和”,不能简单使用向下取整。

例如:

高度:[40, 80, 60]
前缀:[0, 40, 120, 180]
  • y = 39 时,所在项是索引 0
  • y = 40 时,所在项是索引 1
  • y = 120 时,所在项是索引 2

边界处理错误会表现为:

  • 列表项重复一项或跳过一项;
  • 滚动到项边界时闪烁;
  • 渲染范围末尾出现空白。

六、动态高度虚拟列表的可运行实现

下面的组件实现了这些机制:

  • 未测量项使用 estimateHeight
  • 使用树状数组维护高度和位置;
  • 使用 ResizeObserver 测量已渲染项;
  • 使用过扫描区减少快速滚动空白;
  • 高度变化发生在锚点之前时,自动修正 scrollTop
  • 使用 requestAnimationFrame 合并滚动更新。

这个示例假定列表顺序在组件存活期间基本稳定,后文会讨论插入、删除和重排的处理方式。

<!-- DynamicVirtualList.vue -->
<script setup lang="ts" generic="T extends { id: string | number }">
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  watch,
} from 'vue'

const props = withDefaults(defineProps<{
  items: T[]
  estimateHeight: number
  overscan?: number
}>(), {
  overscan: 5,
})

class FenwickTree {
  private tree: number[]
  readonly size: number

  constructor(size: number, initialValue: number) {
    this.size = size
    this.tree = Array(size + 1).fill(0)

    // 初始化为每项均为 initialValue。
    for (let i = 0; i < size; i++) {
      this.add(i, initialValue)
    }
  }

  add(index: number, delta: number) {
    for (let i = index + 1; i <= this.size; i += i & -i) {
      this.tree[i] += delta
    }
  }

  // 返回前 count 项的总和,count 的范围是 [0, size]。
  sum(count: number) {
    let result = 0

    for (let i = Math.min(count, this.size); i > 0; i -= i & -i) {
      result += this.tree[i]
    }

    return result
  }

  total() {
    return this.sum(this.size)
  }

  // 返回最大的 count,使得 sum(count) <= value。
  // 这个 count 同时就是 value 所在的项索引。
  upperBound(value: number) {
    let index = 0
    let accumulated = 0

    // 不超过 size 的最大二进制步长。
    let step = 1
    while ((step << 1) <= this.size) {
      step <<= 1
    }

    for (; step > 0; step >>= 1) {
      const next = index + step

      if (
        next <= this.size &&
        accumulated + this.tree[next] <= value
      ) {
        index = next
        accumulated += this.tree[next]
      }
    }

    // index 是满足前缀和 <= value 的元素数量。
    // 若 value 位于第 i 项内部,index 就是 i。
    return Math.min(index, Math.max(0, this.size - 1))
  }

  // 返回第一个前缀和 >= value 所对应的元素数量。
  // 例如高度 [40, 80],value=40 时返回 1。
  lowerBoundPrefix(value: number) {
    if (value <= 0) {
      return 0
    }

    const total = this.total()

    if (value > total) {
      return this.size
    }

    let index = 0
    let accumulated = 0

    let step = 1
    while ((step << 1) <= this.size) {
      step <<= 1
    }

    for (; step > 0; step >>= 1) {
      const next = index + step

      if (
        next <= this.size &&
        accumulated + this.tree[next] < value
      ) {
        index = next
        accumulated += this.tree[next]
      }
    }

    // index 是前缀和仍然小于 value 的最大元素数量。
    // 加一后得到第一个前缀和 >= value 的元素数量。
    return Math.min(index + 1, this.size)
  }
}

const viewport = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const viewportHeight = ref(0)
const structureVersion = ref(0)

let tree = new FenwickTree(0, 0)
const heights = new Map<number, number>()
const observers = new Map<number, ResizeObserver>()

function rebuildTree() {
  tree = new FenwickTree(
    props.items.length,
    props.estimateHeight,
  )

  heights.clear()

  // 旧观察器对应的索引可能已经失效。
  for (const observer of observers.values()) {
    observer.disconnect()
  }
  observers.clear()

  structureVersion.value++
}

function getHeight(index: number) {
  return heights.get(index) ?? props.estimateHeight
}

function getTop(index: number) {
  return tree.sum(index)
}

function updateHeight(index: number, measuredHeight: number) {
  if (!Number.isFinite(measuredHeight) || measuredHeight <= 0) {
    return
  }

  const oldHeight = getHeight(index)
  const delta = measuredHeight - oldHeight

  // ResizeObserver 可能报告非常小的浮点差异。
  if (Math.abs(delta) < 0.5) {
    return
  }

  const anchorIndex = tree.upperBound(scrollTop.value)
  const anchorOffset =
    scrollTop.value - getTop(anchorIndex)

  heights.set(index, measuredHeight)
  tree.add(index, delta)
  structureVersion.value++

  /*
   * 如果变化发生在锚点之前,锚点顶部也会移动 delta。
   * 增加同样的 scrollTop,可以保持用户看到的锚点内容不变。
   */
  if (index < anchorIndex && viewport.value) {
    const newAnchorTop = getTop(anchorIndex)
    const newScrollTop = newAnchorTop + anchorOffset

    viewport.value.scrollTop = newScrollTop
    scrollTop.value = newScrollTop
  }
}

function bindRow(index: number, element: Element | null) {
  const oldObserver = observers.get(index)
  oldObserver?.disconnect()
  observers.delete(index)

  if (!(element instanceof HTMLElement)) {
    return
  }

  const observer = new ResizeObserver(entries => {
    const entry = entries[0]

    if (!entry) {
      return
    }

    /*
     * getBoundingClientRect().height 包含边框,更适合与实际布局位置对应。
     * 如果使用 contentRect.height,则必须统一 box-sizing 和边框计算规则。
     */
    const measuredHeight = entry.target.getBoundingClientRect().height
    updateHeight(index, measuredHeight)
  })

  observer.observe(element)
  observers.set(index, observer)

  // 某些浏览器或初次布局时,回调可能稍后才到;主动测量一次。
  updateHeight(index, element.getBoundingClientRect().height)
}

const visibleRange = computed(() => {
  // 让 computed 显式依赖高度变化。
  structureVersion.value

  const count = props.items.length

  if (count === 0 || viewportHeight.value <= 0) {
    return { start: 0, end: 0 }
  }

  const firstVisible = tree.upperBound(scrollTop.value)

  // 第一个顶部 >= 可视区底部的项作为结束边界。
  const endVisible = tree.lowerBoundPrefix(
    scrollTop.value + viewportHeight.value,
  )

  return {
    start: Math.max(0, firstVisible - props.overscan),
    end: Math.min(count, endVisible + props.overscan),
  }
})

const visibleItems = computed(() => {
  const { start, end } = visibleRange.value

  return props.items
    .slice(start, end)
    .map((item, offset) => {
      const index = start + offset

      return {
        item,
        index,
        top: getTop(index),
      }
    })
})

const totalHeight = computed(() => {
  structureVersion.value
  return tree.total()
})

let scrollFrame: number | null = null

function syncScrollTop() {
  scrollFrame = null

  if (viewport.value) {
    scrollTop.value = viewport.value.scrollTop
  }
}

function onScroll() {
  if (scrollFrame !== null) {
    return
  }

  scrollFrame = requestAnimationFrame(syncScrollTop)
}

let viewportObserver: ResizeObserver | null = null

onMounted(async () => {
  rebuildTree()

  if (!viewport.value) {
    return
  }

  viewportObserver = new ResizeObserver(entries => {
    const entry = entries[0]

    if (entry) {
      viewportHeight.value = entry.contentRect.height
    }
  })

  viewportObserver.observe(viewport.value)
  viewportHeight.value = viewport.value.clientHeight

  await nextTick()
  scrollTop.value = viewport.value.scrollTop
})

watch(
  () => props.items.length,
  async () => {
    rebuildTree()
    await nextTick()

    if (viewport.value) {
      scrollTop.value = viewport.value.scrollTop
    }
  },
)

onBeforeUnmount(() => {
  viewportObserver?.disconnect()

  for (const observer of observers.values()) {
    observer.disconnect()
  }
  observers.clear()

  if (scrollFrame !== null) {
    cancelAnimationFrame(scrollFrame)
  }
})
</script>

<template>
  <div
    ref="viewport"
    class="virtual-viewport"
    @scroll.passive="onScroll"
  >
    <div
      class="virtual-spacer"
      :style="{ height: `${totalHeight}px` }"
    >
      <div
        v-for="row in visibleItems"
        :key="row.item.id"
        class="virtual-row"
        :style="{ top: `${row.top}px` }"
        :ref="element => bindRow(row.index, element)"
      >
        <slot
          :item="row.item"
          :index="row.index"
        />
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-viewport {
  height: 480px;
  overflow: auto;
  position: relative;

  /*
   * 组件自己维护滚动锚点时,关闭浏览器的自动滚动锚定,
   * 避免浏览器和组件同时修正 scrollTop。
   */
  overflow-anchor: none;
}

.virtual-spacer {
  position: relative;
  width: 100%;
}

.virtual-row {
  position: absolute;
  left: 0;
  right: 0;
}
</style>

使用示例:

<script setup lang="ts">
import DynamicVirtualList from './DynamicVirtualList.vue'

interface Message {
  id: number
  title: string
  content: string
}

const messages: Message[] = Array.from(
  { length: 50_000 },
  (_, id) => ({
    id,
    title: `消息 ${id}`,
    content: id % 5 === 0
      ? '这是一条更长的消息,用来模拟动态高度内容。'.repeat(8)
      : '普通消息内容',
  }),
)
</script>

<template>
  <DynamicVirtualList
    :items="messages"
    :estimate-height="64"
    :overscan="8"
  >
    <template #default="{ item }">
      <article class="message-card">
        <h3>{{ item.title }}</h3>
        <p>{{ item.content }}</p>
      </article>
    </template>
  </DynamicVirtualList>
</template>

这段实现的初始状态如下:

items.length = 50000
estimateHeight = 64
逻辑总高度 = 50000 × 64 = 3200000px

假设第 10 项实际测得 120px,原估计为 64px

delta = 120 - 64 = 56px

树状数组中第 10 项及其相关节点增加 56。第 10 项之后的逻辑顶部都会增加 56px,但只需要 O(logN)O(\log N) 个树节点更新,而不是重新计算全部前缀和。


七、滚动锚点:为什么必须修正 scrollTop

1. 锚点的定义

滚动锚点是用户当前视觉上依赖的内容位置。常见锚点是:

  • 当前位于视口顶部附近的列表项;
  • 当前拥有焦点的输入控件;
  • 聊天列表中底部的最后一条消息;
  • 用户正在阅读的文章段落。

设锚点项索引为 aa,其顶部为 PaP_a,当前滚动位置为 ss,则锚点相对视口顶部的偏移是:

d=sPad = s - P_a

当锚点之前某项高度发生变化,锚点的新顶部变为 PaP'_a。为了保持用户看到的内容位置不变,应设置:

s=Pa+ds' = P'_a + d

如果变化总量为 Δ\Delta,且发生在锚点之前:

Pa=Pa+ΔP'_a = P_a + \Delta

因此:

s=s+Δs' = s + \Delta

这就是代码中:

viewport.value.scrollTop += delta

成立的原因。

2. 为什么只修正锚点之前的变化

假设当前锚点是索引 20

  • 索引 5 高度增加 30px:锚点下移 30px,必须增加 scrollTop
  • 索引 20 自身高度增加:锚点顶部不变,通常不需要改变 scrollTop
  • 索引 30 高度增加:锚点及其上方内容不变,不应改变 scrollTop

因此判断条件是:

if (changedIndex < anchorIndex) {
  scrollTop += delta
}

3. 锚点修正的反例

错误实现:

function updateHeight(index: number, height: number) {
  heights[index] = height
  rebuildAllPositions()
}

这种实现更新了位置,但没有维护滚动锚点。用户向下滚动时会看到:

  • 当前阅读的卡片突然上移或下移;
  • 图片加载完成后列表跳动;
  • 继续滚动时鼠标或触控位置与内容不再对应。

另一个错误是无条件修正:

viewport.scrollTop += delta

如果变化发生在当前锚点之后,也增加滚动位置,用户会看到内容被错误地推移。


八、ResizeObserver 的生命周期和并发问题

1. 为什么不能只在 onMounted 测量一次

动态内容可能在初次渲染后改变高度,例如:

  • 图片加载完成;
  • 字体加载完成;
  • 展开和折叠;
  • 异步数据填充;
  • 窗口宽度改变导致文本换行;
  • 浏览器缩放或移动端旋转。

Vue 的 onMounted 只表示组件 DOM 已经挂载,不代表未来高度永远不变。ResizeObserver 监听的是元素尺寸变化,因此适合处理这类后续变化。

ResizeObserver 是浏览器 Web API,不是 Vue API。它在现代浏览器中可用,但如果项目需要支持较老浏览器,应提供 polyfill 或能力检测。

2. 为什么必须在卸载时断开观察器

如果组件卸载时没有调用:

observer.disconnect()

观察器仍可能持有回调和元素引用,导致:

  • 无意义的回调继续执行;
  • 组件相关对象无法及时回收;
  • 路由切换后出现“幽灵更新”;
  • 大量列表反复进入和离开页面时内存增长。

Vue 的 onBeforeUnmount 是清理 DOM 观察器、事件、定时器和动画帧的合适位置。

3. 高度测量与渲染更新的时序

典型时序如下:

sequenceDiagram
    participant S as scroll 事件
    participant V as Vue 响应式状态
    participant D as DOM 渲染
    participant R as ResizeObserver
    participant A as 锚点修正

    S->>V: 更新 scrollTop
    V->>D: 计算并渲染新的可视范围
    D->>R: 新列表项尺寸可测
    R->>V: 报告真实高度
    V->>A: 更新前缀和和总高度
    A->>V: 修正 scrollTop
    V->>D: 重新计算列表项位置

ResizeObserver 的回调不是同步发生在模板执行期间。它通常在布局计算后异步通知。因此不能假设:

visibleItems.value = ...
const height = element.offsetHeight

在同一个同步调用栈中就能得到最终高度。需要接受“先估计、后测量、再修正”的工作模式。


九、数据变化、索引变化和稳定 ID

上面的示例使用数组索引保存高度:

Map<number, number>

这种方式只适合列表顺序稳定的场景。如果在头部插入一项,原来的索引全部后移:

旧数据:A(0), B(1), C(2)
新数据:X(0), A(1), B(2), C(3)

如果仍然把旧索引 1 的高度当成新索引 1 的高度,就会把 B 的高度错误地应用到 A。

更稳妥的生产实现

应使用稳定 ID 保存测量高度:

const heightsById = new Map<string | number, number>()

计算位置时再根据当前数组索引取 ID:

const id = props.items[index].id
const height = heightsById.get(id) ?? estimateHeight

如果列表支持插入、删除和重排,还需要同步维护树状数组:

  1. 根据新数组长度扩展或重建树状数组;
  2. 按稳定 ID 恢复已有高度;
  3. 新 ID 使用估计高度;
  4. 重排后重新计算当前锚点;
  5. 如果锚点仍存在,按锚点 ID 恢复其相对位置;
  6. 如果锚点已被删除,则选择其后继或前驱作为新锚点。

对于头部追加或聊天消息场景,通常保存:

anchorId
anchorOffset

更新数据后重新找到 anchorId 的新索引,再设置:

scrollTop=top(anchorId)+anchorOffsetscrollTop = top(anchorId) + anchorOffset

如果用户原本在列表底部,还可以使用“是否接近底部”作为另一种锚点:

distanceToBottom=scrollHeightclientHeightscrollTopdistanceToBottom = scrollHeight - clientHeight - scrollTop

distanceToBottom 小于某个阈值时,新增消息后将滚动位置设置为新的最大值;否则不要强行把用户带到底部。


十、性能:真正需要控制的不是一个数字

1. 滚动处理必须避免同步重活

滚动事件可能高频触发。以下代码会在每个事件中立即执行复杂计算:

function onScroll() {
  scrollTop.value = viewport.scrollTop
  expensiveCalculation()
}

更好的基本结构是合并到动画帧:

let frameId: number | null = null

function onScroll() {
  if (frameId !== null) {
    return
  }

  frameId = requestAnimationFrame(() => {
    frameId = null
    scrollTop.value = viewport.scrollTop
  })
}

这并不意味着每一帧都必须渲染更多内容,而是避免同一帧中重复处理多次滚动事件。

2. 可视范围计算应避免全量遍历

固定高度使用除法即可:

O(1)O(1)

动态高度如果每次滚动都重新累加所有前置项:

let top = 0

for (let i = 0; i < start; i++) {
  top += heights[i]
}

则滚动到后部时可能变成 O(N)O(N)。使用前缀和、树状数组或专门的区间索引结构,可以把查询降到 O(logN)O(\log N)

3. 行组件自身也可能成为瓶颈

虚拟化只减少“同时存在的项数量”,并不能自动优化每一项内部的逻辑。以下代码仍可能很慢:

<div v-for="item in visibleItems" :key="item.id">
  <ComplexChart :data="item.chartData" />
  <ExpensiveMarkdown :source="item.content" />
</div>

如果当前同时渲染 50 项,每项都包含复杂图表,虚拟化仍可能卡顿。此时需要进一步分析:

  • 行组件是否因为父组件更新而重复渲染;
  • 是否能拆分稳定子树;
  • 是否能缓存格式化结果;
  • 是否应延迟加载图片和图表;
  • 是否能避免在模板中重复执行昂贵函数。

Vue 的 computed、稳定 key、合理拆分组件可以降低不必要更新,但不能替代性能分析。

4. 过扫描和用户体验之间的取舍

过扫描越大:

  • 快速滚动时白屏概率越低;
  • 同时存在的 DOM 越多;
  • 高度观察器数量越多;
  • 行组件更新成本越高。

过扫描越小:

  • DOM 更少;
  • 快速滚动更容易看到尚未渲染的区域;
  • 移动端低端设备上可能出现短暂空白。

应根据实际项高度、滚动速度、设备性能和行组件复杂度进行测试,而不是使用固定的“最佳值”。


十一、动态高度列表的常见失败表现

失败一:所有项都用估计高度,但从不测量

表现:

  • 第一个屏幕看起来正常;
  • 越向后滚动,位置误差越大;
  • 滚动条总高度不准确;
  • 点击某个逻辑索引后,内容位置明显偏移。

原因是估计误差会累加:

误差(Pi)=k=0i1(HkHk^)误差(P_i) = \sum_{k=0}^{i-1} (H_k - \hat{H_k})

即使每项平均只误差 2px,前面有 5000 项时,理论累计误差也可能达到 10000px

失败二:测量后不修正锚点

表现:

  • 图片加载后阅读位置跳动;
  • 展开卡片时用户被推离当前内容;
  • 快速滚动时画面上下抖动。

原因是后续项顶部位置被更新,但 scrollTop 仍保持原来的数值。

诊断方式:

  1. 记录高度变化前的锚点索引;
  2. 记录 scrollTop - anchorTop
  3. 记录高度变化后的锚点顶部;
  4. 检查是否满足:
newScrollTop - newAnchorTop
≈ oldScrollTop - oldAnchorTop

失败三:使用错误的边界查找

表现:

  • 在项高度边界处重复渲染;
  • 某一项刚好位于顶部时被错误排除;
  • 可视区底部少渲染一项或多渲染一项。

需要明确使用的是:

  • top < viewportBottom
  • bottom > viewportTop
  • 边界是否包含;
  • 起始索引和结束索引是否采用半开区间 [start, end)

采用半开区间通常更容易推导和实现。

失败四:使用索引作为高度缓存,但数据发生插入

表现:

  • 插入一项后,多项高度错位;
  • 列表内容与占位位置不匹配;
  • 某些项需要滚动几次后才恢复。

原因是索引不是稳定身份。可改为按业务 ID 缓存高度,或者在每次结构变化后完整重建高度索引。

失败五:滚动容器高度不确定

如果滚动容器没有有效高度,例如:

.virtual-viewport {
  overflow: auto;
}

但父元素也没有明确高度,clientHeight 可能为 0 或随内容增长,虚拟化计算就没有稳定的视口边界。

应保证滚动容器有明确或可推导的高度:

.virtual-viewport {
  height: 480px;
  overflow: auto;
}

或者由父级的 flexgrid 布局明确约束其高度。


十二、焦点、键盘操作和可访问性

虚拟化列表的 DOM 节点会随着滚动被创建和销毁,因此焦点管理不能只依赖 DOM 节点本身。

例如,用户聚焦了第 100 项中的输入框,滚动后该项离开过扫描区,DOM 被卸载,焦点就会丢失。需要在数据层保存:

const focusedItemId = ref<string | number | null>(null)

重新渲染该项后,再根据 ID 恢复焦点。

对于键盘导航,还应维护逻辑索引,而不是仅使用当前 DOM 中的第几个元素:

ArrowDown:
  logicalIndex = min(logicalIndex + 1, itemCount - 1)
  滚动到 logicalIndex
  等待渲染完成
  聚焦该项

如果要实现“滚动到某项”,固定高度列表可以直接计算:

viewport.scrollTop = index * itemHeight

动态高度列表则需要:

viewport.scrollTop = tree.sum(index)

但该位置仍可能基于估计高度。目标项渲染并测量后,位置可能再次修正,因此聚焦逻辑通常需要等待一次或多次 nextTick() 和测量回调。

此外,虚拟化并不适合所有语义场景:

  • 需要浏览器原生全文查找全部内容;
  • 需要搜索引擎直接读取全部内容;
  • 需要屏幕阅读器将整个列表作为完整静态文档读取;
  • 列表项之间存在复杂的 DOM 相邻关系。

这类场景可能需要分页、分段渲染或不采用虚拟化。


十三、何时使用固定高度、动态高度或分页

固定高度虚拟化

适用于:

  • 表格行;
  • 文件列表;
  • 联系人列表;
  • 每行布局高度明确;
  • 内容可以截断或内部滚动。

优点是:

  • 位置计算简单;
  • 滚动稳定;
  • 性能可预测;
  • 代码和测试成本较低。

如果业务允许,固定高度通常比动态高度更容易维护。

动态高度虚拟化

适用于:

  • 消息流;
  • 评论和回复;
  • 图片、文本混排;
  • 折叠面板;
  • 内容长度差异很大的卡片列表。

代价是:

  • 需要测量;
  • 需要高度估计;
  • 需要锚点修正;
  • 需要处理图片和字体加载;
  • 数据插入和重排更复杂。

分页或无限加载

分页和虚拟化解决的问题不同:

  • 分页减少内存中的数据量和网络传输量;
  • 虚拟化减少同时存在的 DOM 数量;
  • 无限加载控制数据的持续获取;
  • 虚拟化控制数据已经获取后的展示成本。

它们可以组合使用:

分页加载 1000 条
└── 对当前 1000 条使用虚拟化

如果一次加载十万条数据本身就造成内存压力,仅使用虚拟化仍然不够。


十四、验证虚拟化是否真的生效

不要只凭“滚动起来感觉快”判断。

可以在浏览器开发者工具中验证:

  1. 检查 Elements 面板中列表项 DOM 数量;
  2. 滚动到列表底部,确认 DOM 数量没有接近数据总量;
  3. 在 Performance 面板录制滚动过程;
  4. 观察长任务、布局计算和绘制耗时;
  5. 模拟低端移动设备和较慢网络;
  6. 测试图片加载、窗口缩放、展开折叠和快速滚动;
  7. 测试头部插入、删除当前项和重新排序;
  8. 测试键盘焦点是否在卸载后正确恢复。

一个简单的运行时检查可以临时加入:

console.debug({
  totalItems: props.items.length,
  renderedItems: visibleItems.value.length,
  totalHeight: totalHeight.value,
  scrollTop: scrollTop.value,
})

预期结果是:

totalItems: 50000
renderedItems: 20 ~ 60

而不是随着滚动逐渐增长到几万。

性能优化还必须区分测量结论和经验判断:

  • “只渲染可视范围附近的项”是虚拟化机制;
  • “使用 requestAnimationFrame 合并滚动状态更新”是常见实现;
  • “过扫描设置为 8 项”只是示例值,不是 Vue 或浏览器保证的最佳值;
  • “树状数组更新为 O(logN)O(\log N)”是该数据结构的复杂度保证;
  • 实际帧率仍取决于列表项组件、图片、布局、设备和浏览器。

结语

长列表虚拟化可以归结为四个相互依赖的问题:

  1. 根据 scrollTop 和视口高度找到可视范围;
  2. 为未测量项提供高度估计,为已测量项维护真实高度;
  3. 使用前缀和或树状数组将逻辑索引映射为像素位置;
  4. 高度变化时维护滚动锚点,避免用户正在阅读的内容跳动。

固定高度列表可以通过:

Pi=iHP_i = iH

直接计算;动态高度列表必须通过:

Pi=k=0i1HkP_i = \sum_{k=0}^{i-1} H_k

维护累计位置,并处理估计值到真实值的变化。

Vue 在这里主要负责响应式状态、模板渲染和生命周期管理;虚拟化本身不是某个单独的 Vue API,而是滚动位置、布局测量、索引结构和 DOM 窗口共同组成的系统。理解这些因果关系后,才能正确处理动态高度、插入删除、焦点恢复和性能诊断,而不是只把 v-for 替换成另一个组件。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。