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

显示模式

登录
ARCHIVE DOCUMENTVUE

长列表优化之虚拟列表

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/105-长列表优化之虚拟列表
本文目录12 个章节
  1. 一、日志列表的实际需求
  2. 二、为什么需要虚拟列表
  3. 三、虚拟列表的几个概念
  4. 四、浏览器渲染和绝对定位的正确理解
  5. 五、固定高度虚拟列表的实现
  6. 六、动态高度列表
  7. 七、WebSocket 消息缓冲
  8. 八、列表锁定和自动跟随
  9. 九、键盘聚焦和详情防抖
  10. 十、常见误区
  11. 总结
  12. 参考资料

长列表优化之虚拟列表

原文来自一个日志收集系统,讨论了长列表、WebSocket 缓冲、列表锁定、键盘聚焦和详情防抖。本文保留原来的需求和实现思路,修正 endIndex 拼写、v-for/v-if 混用、用索引作为 key、把“真实列表”与“可视列表”混淆、递归 setTimeout 自动滚动以及动态高度没有处理等问题,并给出 Vue 3.5 的固定高度实现。

一、日志列表的实际需求

原文的场景是一个持续接收日志的系统。随着时间推移,日志数组不断增长,如果每条日志都保留一个真实 DOM 节点,页面会逐渐出现:

  • DOM 节点数量越来越多;
  • 内存占用不断增长;
  • 列表更新和滚动越来越卡;
  • WebSocket 高频消息导致组件频繁更新;
  • 用户查看历史日志时,新日志仍然把视图拉到底部;
  • 键盘快速移动聚焦时,详情接口被重复请求。

为了尽量不改变原有使用习惯,需求可以整理为:

  1. 有新日志时,未锁定的列表能看到最新记录;
  2. 用户点击历史记录后,自动滚动暂停;
  3. 用户滚动回底部后,自动滚动恢复;
  4. 可以用方向键移动当前聚焦项;
  5. 聚焦快速移动时,详情请求需要防抖并取消过期请求;
  6. WebSocket 消息需要批量合并,避免每条消息都触发一次视图更新;
  7. 只渲染视口附近的少量节点,控制 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:可见窗口中实际创建的节点。

可视区、总高度、startIndex 和 endIndex 示意图(原文图片已本地化)

3.4 startIndexendIndex

假设总列表有 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 保存 startIndexendIndexscrollTop,模板中用 <template v-for> 包裹条件内容。不要在同一个元素上同时写 v-forv-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 测量
       → 修正该项高度和后续偏移 → 保持滚动锚点

常见实现方式有:

  1. 给每项一个估算高度,首次渲染后用 ResizeObserver 测量;
  2. 使用前缀和、Fenwick Tree 或分段索引快速计算偏移;
  3. 高度变化时补偿 scrollTop,避免用户视图跳动;
  4. 使用支持动态测量的虚拟化库,例如 TanStack Virtual;
  5. 对日志内容限制最大行数,减少高度变化和测量成本。

动态高度实现比固定高度复杂很多。如果产品允许,折叠详情、限制日志行数或使用固定行高通常更稳定。

七、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 一定合适。

WebSocket 消息缓冲池流程(原文图片已本地化)

八、列表锁定和自动跟随

“自动滚到底部”和“虚拟列表窗口移动”是两个不同问题:

  • 虚拟窗口根据 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

还要考虑用户是否正在查看历史、是否已经接近底部、内容追加后滚动高度变化、锁定提示和键盘聚焦。

虚拟列表完整优化方案总结(原文图片已本地化)

总结

  1. 虚拟列表只渲染可视窗口附近的 DOM,不能自动减少内存中的数据;
  2. 固定高度列表通过占位层、startIndexendIndex 和 overscan 计算窗口;
  3. endIndex 是正确拼写,模板应避免同一元素同时使用 v-forv-if
  4. 列表项应使用稳定业务 id 作为 key;
  5. WebSocket 数据应批量合并,并设计背压、截断和断线重连策略;
  6. 自动跟随、用户锁定和滚动到底部是独立的业务状态;
  7. 键盘事件使用 event.key,详情请求使用防抖和 AbortController;
  8. 动态高度需要测量和滚动锚点处理,不能直接套用固定行高公式;
  9. 真实性能要通过 Performance、Memory 和长任务数据验证。

参考资料

原文作者:Winder。原文关于日志列表需求、虚拟列表概念、可视区、索引、WebSocket 缓冲、列表锁定、键盘聚焦和防抖的主线予以保留;抓取噪声、错误标签、endIndex 拼写、过时 API、错误性能结论和不完整动态高度说法已修正或补充。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS