长列表优化之虚拟列表
原文来自一个日志收集系统,讨论了长列表、WebSocket 缓冲、列表锁定、键盘聚焦和详情防抖。本文保留原来的需求和实现思路,修正
endIndex拼写、v-for/v-if混用、用索引作为 key、把“真实列表”与“可视列表”混淆、递归setTimeout自动滚动以及动态高度没有处理等问题,并给出 Vue 3.5 的固定高度实现。
一、日志列表的实际需求
原文的场景是一个持续接收日志的系统。随着时间推移,日志数组不断增长,如果每条日志都保留一个真实 DOM 节点,页面会逐渐出现:
- DOM 节点数量越来越多;
- 内存占用不断增长;
- 列表更新和滚动越来越卡;
- WebSocket 高频消息导致组件频繁更新;
- 用户查看历史日志时,新日志仍然把视图拉到底部;
- 键盘快速移动聚焦时,详情接口被重复请求。
为了尽量不改变原有使用习惯,需求可以整理为:
- 有新日志时,未锁定的列表能看到最新记录;
- 用户点击历史记录后,自动滚动暂停;
- 用户滚动回底部后,自动滚动恢复;
- 可以用方向键移动当前聚焦项;
- 聚焦快速移动时,详情请求需要防抖并取消过期请求;
- WebSocket 消息需要批量合并,避免每条消息都触发一次视图更新;
- 只渲染视口附近的少量节点,控制 DOM 数量。


二、为什么需要虚拟列表
浏览器不是不能渲染几万条节点,而是节点数量、布局、样式计算、绘制、事件绑定和响应式更新都会带来成本。对于几十万条数据,分页、服务端查询、时间范围筛选和增量加载通常应该优先考虑;当产品确实要求连续滚动时,虚拟列表才是重要方案。
虚拟列表(virtual list/virtual scroller)的核心不是减少数据,而是:
让滚动区域拥有完整的总高度,但只在视口附近创建少量真实 DOM 节点。


虚拟列表只能减少 DOM 数量,不能自动减少内存中的数据、网络流量或日志处理成本。如果日志无限增长,还要配合:
- 服务端按时间或游标分页;
- 只保留最近 N 条记录;
- 把历史日志写入 IndexedDB 或文件;
- 按时间范围查询和卸载旧数据;
- 对搜索、过滤和格式化做增量处理。
三、虚拟列表的几个概念
3.1 可视区(viewport)
用户实际看到并滚动的容器:
.log-viewport {
width: 375px;
height: 500px;
margin: 0 auto;
overflow: auto;
position: relative;
}
3.2 总高度占位层(spacer)
占位层不一定渲染所有日志,它只负责撑起滚动条的总高度。固定行高时:
总高度 = 数据总量 × 单行高度
动态行高时,需要估算高度并根据实际测量结果修正。
3.3 可见窗口(visible window)
根据 scrollTop、视口高度、行高和 overscan 计算要渲染的区间:
startIndex = floor(scrollTop / rowHeight) - overscan
endIndex = ceil((scrollTop + viewportHeight) / rowHeight) + overscan
overscan 是视口上下额外渲染的缓冲行数,可以减少快速滚动时白屏,但设置过大会增加 DOM 数量。
原文称渲染出来的那一小段为“真实列表”,这个名称容易和总列表混淆。更准确的说法是:
- 总列表:内存中的完整数据集合;
- 可见窗口:当前需要渲染的切片;
- 占位层:提供总滚动高度的元素;
- 真实 DOM:可见窗口中实际创建的节点。

3.4 startIndex 和 endIndex
假设总列表有 1000 条,当前窗口渲染第 100 到第 200 条:
startIndex = 99 // 如果这里使用数组下标
endIndex = 199
原文标题中的结束索引名称有拼写错误,统一使用 endIndex。代码中还要统一“包含右端点”还是“半开区间”的约定,推荐使用半开区间 [start, end),这样不容易出现 +1/-1 错误。
四、浏览器渲染和绝对定位的正确理解
原文列出了从 HTML/CSS 解析到布局、绘制和显示的流程,这可以帮助理解页面更新成本:
HTML → DOM
CSS → CSSOM
DOM + CSSOM → Render Tree
布局(Layout)→ 绘制(Paint)→ 合成(Composite)
但“绝对定位后只会重绘这一块”不是可靠的通用结论。绝对定位可以让节点脱离正常文档流,减少部分布局影响;是否触发局部布局、绘制或合成,还取决于样式、层叠上下文、浏览器和实际修改内容。
虚拟列表常用绝对定位或 transform: translateY(),主要原因是把可见窗口放到正确的滚动偏移位置,而不是保证所有更新都只影响一个图层。
.virtual-window {
position: relative;
will-change: transform;
}
will-change 不能滥用。长期给大量节点创建合成层可能增加内存,只有在确实测量到收益时才使用。


五、固定高度虚拟列表的实现
固定行高是最容易实现、滚动性能也最稳定的方案。日志行如果可以通过 CSS 控制为固定高度,优先采用它。
下面是一个 Vue 3.5 <script setup lang="ts"> 示例:
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue'
type LogRecord = {
id: string
time: string
level: 'info' | 'warn' | 'error'
message: string
}
const rowHeight = 32
const overscan = 8
const records = shallowRef<LogRecord[]>([])
const viewport = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const viewportHeight = ref(500)
const focusedIndex = ref(-1)
const locked = ref(false)
const startIndex = computed(() => {
return Math.max(0, Math.floor(scrollTop.value / rowHeight) - overscan)
})
const endIndex = computed(() => {
const visibleCount = Math.ceil(viewportHeight.value / rowHeight)
return Math.min(
records.value.length,
startIndex.value + visibleCount + overscan * 2,
)
})
const visibleRecords = computed(() => {
return records.value.slice(startIndex.value, endIndex.value)
})
const totalHeight = computed(() => records.value.length * rowHeight)
const translateY = computed(() => startIndex.value * rowHeight)
function isNearBottom() {
const el = viewport.value
if (!el) return true
return el.scrollHeight - el.scrollTop - el.clientHeight <= rowHeight * 2
}
function handleScroll() {
const el = viewport.value
if (!el) return
scrollTop.value = el.scrollTop
viewportHeight.value = el.clientHeight
// 用户滚动回底部后,恢复自动跟随。
if (isNearBottom()) locked.value = false
}
function scrollToBottom() {
const el = viewport.value
if (!el) return
el.scrollTop = el.scrollHeight - el.clientHeight
scrollTop.value = el.scrollTop
}
function selectRecord(index: number) {
focusedIndex.value = index
locked.value = true
requestDetail(records.value[index]?.id)
}
function moveFocus(event: KeyboardEvent) {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
event.preventDefault()
const direction = event.key === 'ArrowUp' ? -1 : 1
const nextIndex = Math.min(
Math.max(focusedIndex.value + direction, 0),
records.value.length - 1,
)
if (nextIndex < 0) return
selectRecord(nextIndex)
nextTick(() => {
document
.querySelector<HTMLElement>(`[data-log-index="${nextIndex}"]`)
?.scrollIntoView({ block: 'nearest' })
})
}
let resizeObserver: ResizeObserver | undefined
onMounted(() => {
const el = viewport.value
if (!el || typeof ResizeObserver === 'undefined') return
resizeObserver = new ResizeObserver(([entry]) => {
viewportHeight.value = entry.contentRect.height
})
resizeObserver.observe(el)
})
onUnmounted(() => {
resizeObserver?.disconnect()
})
</script>
<template>
<div
ref="viewport"
class="log-viewport"
tabindex="0"
@scroll.passive="handleScroll"
@keydown="moveFocus"
>
<!-- 只负责提供完整滚动高度,不渲染所有记录 -->
<div class="log-spacer" :style="{ height: `${totalHeight}px` }">
<!-- 只移动可见窗口 -->
<div
class="virtual-window"
:style="{ transform: `translateY(${translateY}px)` }"
>
<div
v-for="(record, offset) in visibleRecords"
:key="record.id"
:data-log-index="startIndex + offset"
class="log-row"
:class="{ active: startIndex + offset === focusedIndex }"
@click="selectRecord(startIndex + offset)"
>
<span>{{ record.time }}</span>
<strong>{{ record.level }}</strong>
<span>{{ record.message }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.log-spacer {
position: relative;
}
.virtual-window {
position: absolute;
inset: 0 0 auto;
}
.log-row {
height: 32px;
box-sizing: border-box;
display: flex;
gap: 8px;
align-items: center;
}
</style>
上例用 rowHeight 计算窗口,所以 CSS 中的实际行高必须和它一致。生产代码还应根据日志内容处理换行、超长文本、可访问性和横向滚动。

5.1 Vue 2 的兼容思路
Vue 2 项目可以使用 Options API 保存 startIndex、endIndex 和 scrollTop,模板中用 <template v-for> 包裹条件内容。不要在同一个元素上同时写 v-for 和 v-if:
<template v-for="(item, index) in visibleItems">
<div :key="item.id" class="list-row">
{{ index }} - {{ item.message }}
</div>
</template>
Vue 3 中也应通过计算属性先得到 visibleRecords,而不是在模板中用 v-if 过滤完整数组。
5.2 VueUse 的现成实现
如果不需要自己控制所有细节,可以使用 VueUse 的 useVirtualList:
import { useVirtualList } from '@vueuse/core'
const { list, containerProps, wrapperProps } = useVirtualList(records, {
itemHeight: 32,
overscan: 8,
})
<div v-bind="containerProps" class="log-viewport">
<div v-bind="wrapperProps">
<div v-for="row in list" :key="row.data.id">
{{ row.data.message }}
</div>
</div>
</div>
使用第三方实现前要确认它对固定高度、动态高度、横向滚动、键盘聚焦、SSR 和大量追加数据的支持情况。
六、动态高度列表
原文场景是日志列表,日志文本可能换行,因此固定高度方案不一定适用。动态高度不能简单地用 index × rowHeight 计算,需要维护每一项的实际高度和累计偏移:
估算高度 → 渲染窗口 → ResizeObserver 测量
→ 修正该项高度和后续偏移 → 保持滚动锚点
常见实现方式有:
- 给每项一个估算高度,首次渲染后用
ResizeObserver测量; - 使用前缀和、Fenwick Tree 或分段索引快速计算偏移;
- 高度变化时补偿
scrollTop,避免用户视图跳动; - 使用支持动态测量的虚拟化库,例如 TanStack Virtual;
- 对日志内容限制最大行数,减少高度变化和测量成本。
动态高度实现比固定高度复杂很多。如果产品允许,折叠详情、限制日志行数或使用固定行高通常更稳定。
七、WebSocket 消息缓冲
原文用数组收集一段时间内的 WebSocket 消息,每隔 500ms 批量追加到完整列表。核心收益是减少响应式更新次数,但“渲染次数减少一半就等于性能提升一倍”不能直接成立,实际效果要用性能面板和用户体验验证。
下面是一个更安全的批处理示例:
const pendingRecords: LogRecord[] = []
let flushTimer: number | undefined
function receiveRecords(records: LogRecord | LogRecord[]) {
pendingRecords.push(...(Array.isArray(records) ? records : [records]))
if (flushTimer !== undefined) return
flushTimer = window.setTimeout(flushRecords, 100)
}
function flushRecords() {
flushTimer = undefined
if (pendingRecords.length === 0) return
const shouldStickToBottom = !locked.value && isNearBottom()
const batch = pendingRecords.splice(0, pendingRecords.length)
// shallowRef 适合批量替换大数组,避免对每条日志深度代理。
records.value = records.value.concat(batch)
// 日志系统通常还要设置保留上限,避免 JS 内存无限增长。
const maxRecords = 100_000
if (records.value.length > maxRecords) {
records.value = records.value.slice(-maxRecords)
}
if (shouldStickToBottom) {
nextTick(scrollToBottom)
}
}
生产环境还要处理:
- WebSocket 断线重连和重复消息;
- 单次批次过大导致主线程长任务;
- 服务器突发流量导致 pending 数组无限增长;
- 日志排序、丢弃策略和背压;
- 页面切后台时降低刷新频率;
- 卸载组件时清理 timer 和 socket。
可以根据消息量使用 requestAnimationFrame、时间窗口、数量窗口或两者结合,而不是固定认为 500ms 一定合适。

八、列表锁定和自动跟随
“自动滚到底部”和“虚拟列表窗口移动”是两个不同问题:
- 虚拟窗口根据
scrollTop决定渲染哪些节点; - 自动跟随在新数据到来时修改
scrollTop。
建议把锁定状态表达成业务状态,而不是使用拼写混乱的长变量名:
const locked = ref(false)
function lockList() {
locked.value = true
}
function unlockList() {
locked.value = false
nextTick(scrollToBottom)
}
新消息到来时,只有用户未锁定且原来接近底部,才自动滚动到底部。用户正在查看历史位置时,不应该因为一条新消息改变他的阅读位置。滚动回底部后再自动恢复,是比“任何新消息都滚到底部”更符合日志查看体验的策略。

九、键盘聚焦和详情防抖
聚焦项应使用稳定的业务 id,focusedIndex 只表示当前窗口中的位置或总数据中的位置。列表项 key 不建议使用数组 index:日志插入、删除或截断时,index 可能对应另一条记录。
键盘事件应使用标准的 event.key,而不是已不推荐的 keyCode:
function onKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowUp') {
event.preventDefault()
moveFocusBy(-1)
} else if (event.key === 'ArrowDown') {
event.preventDefault()
moveFocusBy(1)
}
}
详情请求可以使用防抖,并取消已经过期的请求:
let detailTimer: number | undefined
let detailController: AbortController | undefined
function requestDetail(id: string | undefined) {
if (!id) return
if (detailTimer !== undefined) {
window.clearTimeout(detailTimer)
}
detailController?.abort()
detailTimer = window.setTimeout(async () => {
detailTimer = undefined
detailController = new AbortController()
try {
const response = await fetch(`/api/logs/${id}`, {
signal: detailController.signal,
})
if (!response.ok) throw new Error(`请求失败:${response.status}`)
const detail = await response.json()
// 只展示当前仍然有效的详情。
renderDetail(detail)
} catch (error) {
if ((error as DOMException).name !== 'AbortError') {
reportDetailError(error)
}
}
}, 300)
}
onUnmounted(() => {
if (detailTimer !== undefined) window.clearTimeout(detailTimer)
detailController?.abort()
})
如果使用 Vue 3.5 的 watch 监听当前 id,也可以在 watcher 中使用 onWatcherCleanup 清理过期请求;无论采用哪种方式,都要防止慢请求覆盖用户已经选中的新记录。


十、常见误区
10.1 虚拟列表不是分页的替代品
虚拟列表限制 DOM 数量;分页、游标和保留上限限制数据量。数据本身有几十万条时,仍要考虑网络和内存。
10.2 使用虚拟列表后就不会卡
如果每条日志都要执行复杂 Markdown 渲染、语法高亮、正则格式化或深层响应式代理,即使 DOM 很少,JavaScript 仍然可能卡顿。应缓存格式化结果、使用 shallowRef、批量更新并减少昂贵计算。
10.3 动态高度和固定高度一样简单
固定高度可以用乘法计算偏移;动态高度需要测量、缓存和锚点补偿,选择库或方案时要单独验证。
10.4 自动滚动只需要设置 scrollTop
还要考虑用户是否正在查看历史、是否已经接近底部、内容追加后滚动高度变化、锁定提示和键盘聚焦。

总结
- 虚拟列表只渲染可视窗口附近的 DOM,不能自动减少内存中的数据;
- 固定高度列表通过占位层、
startIndex、endIndex和 overscan 计算窗口; endIndex是正确拼写,模板应避免同一元素同时使用v-for和v-if;- 列表项应使用稳定业务 id 作为 key;
- WebSocket 数据应批量合并,并设计背压、截断和断线重连策略;
- 自动跟随、用户锁定和滚动到底部是独立的业务状态;
- 键盘事件使用
event.key,详情请求使用防抖和 AbortController; - 动态高度需要测量和滚动锚点处理,不能直接套用固定行高公式;
- 真实性能要通过 Performance、Memory 和长任务数据验证。
参考资料
- Vue 3:性能优化
- Vue 3:列表渲染
- VueUse:useVirtualList
- TanStack Virtual:Vue
- Vue 3:
nextTick - MDN:ResizeObserver
- MDN:AbortController
原文作者:Winder。原文关于日志列表需求、虚拟列表概念、可视区、索引、WebSocket 缓冲、列表锁定、键盘聚焦和防抖的主线予以保留;抓取噪声、错误标签、endIndex 拼写、过时 API、错误性能结论和不完整动态高度说法已修正或补充。