深入虚拟列表:动态高度、缓冲与异步加载(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
但多行文本、图片、折叠内容和响应式宽度会让每一行高度不同:


这时需要解决:
- 首次渲染前拿不到所有行的真实高度;
- 一行高度变化会影响它后面所有行的 top/bottom;
- 滚动时必须快速找到对应的开始索引;
- 图片加载、字体加载和容器宽度变化可能再次改变高度;
- 修正上方行高度时不能让用户看到的内容突然跳走。
四、用 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;一般业务虚拟列表使用估算、批量测量和可见行数量控制后已经足够。
五、如何找到动态高度的开始索引
positions 的 bottom 按索引单调递增,可以寻找第一个满足:
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。更稳妥的方案是:
- 只测量当前渲染的行;
- 通过
requestAnimationFrame合并同一帧的测量; - 高度变化小于阈值时不更新;
- 有变化时批量修正后续位置;
- 观察器在组件卸载时断开。
如果使用 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
}

十、异步资源导致的高度变化
图片、字体、展开内容和接口返回的富文本都可能在首次渲染后改变行高。优先级从高到低通常是:
- 业务上给图片设置明确的
width/height或aspect-ratio; - 设计上限制文本行数或预留稳定空间;
- 无法预知时,用
ResizeObserver观察可见行; - 图片
load作为兼容性补充,触发批量测量; - 测量完成后修正 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 时不要在服务端直接访问 window、ResizeObserver 和 DOM。
十五、当前实现的版本差异
| 内容 | Vue 2.7 | Vue 3.5 / Nuxt 4 |
|---|---|---|
| 列表 ref | v-for ref 常得到 $refs 数组 | 推荐函数 ref 或模板 ref,行为需按版本处理 |
| 卸载钩子 | beforeDestroy/destroyed | onBeforeUnmount/onUnmounted |
| 响应式数组 | defineProperty + 数组方法改写 | Proxy;仍需合理更新数据 |
| 虚拟列表组件 | Options API 可实现 | composable、renderless component 或专用库 |
| SSR | 需要避免服务端 DOM 访问 | Nuxt 4 仍需区分 server/client 生命周期 |
总结
- 虚拟列表只渲染窗口附近的 DOM;它不同于分页、懒加载和时间分片,但可以组合使用;
- 固定高度可以 O(1) 计算,动态高度需要估算、测量、positions 缓存和二分查找;
- 高度变化会影响后续位置,更新时应使用
delta = newHeight - oldHeight并考虑滚动锚点; - 不要在
updated中无条件修改响应式 positions,使用批量测量、rAF 和 ResizeObserver; - 缓冲区用 overscan 减少快速滚动白屏,但过大也会增加渲染成本;
- 图片和异步内容优先设置尺寸,否则通过观察可见行的 ResizeObserver 修正高度;
- 触底加载要使用阈值、loading、hasMore 和 cursor,追加数据时不能重置已有测量结果;
- VueUse
useVirtualList适合固定或已知行高,任意动态高度需要额外测量或专用库; - 真正的性能优化要测量 DOM、布局、脚本、网络和数据处理,不要只看虚拟列表本身。
参考资料
- VueUse
useVirtualList - MDN:ResizeObserver
- MDN:Intersection Observer API
- Vue 3 Composables
- Vue 3 性能优化
- Vue 2 列表渲染
原文作者:西维。原文关于动态高度预估、positions、二分查找、缓冲区、图片加载和懒加载的主线予以保留;错误的缓存初始化、二分边界、updated 重复更新、仅观察容器、精确触底判断、重置已测量 positions 和无关 base64 占位图已清理或修正。