人人都会AI编程

21.2 列表渲染优化

更新时间:2026-07-09

当列表数据量从几十条增长到几百、几千甚至更多时,页面会出现明显的卡顿、滚动掉帧,甚至导致浏览器直接崩溃。原因很简单:Vue 需要为每一条数据创建对应的 VNode 并维护其响应式依赖,大量的 DOM 节点会让浏览器的渲染引擎不堪重负。这一节聚焦三种最实用的优化手段,帮你从容应对长列表。


虚拟列表:只渲染看得见的项

虚拟列表的核心思路是只创建当前视口内可见的那部分 DOM 节点,上下不可见的区域用占位容器撑开滚动条。用户滚动时,动态计算该显示哪些项,移除移出视口的节点,创建进入视口的新节点。DOM 总量始终保持在一个极低的常数级别,从而让滚动持续保持 60fps。

社区方案:vue-virtual-scroller
这是 Vue 生态中最成熟的虚拟列表组件,支持纵向、横向滚动,支持不等高项、动态高度,甚至支持带缓冲区的“预渲染”。

安装后,你只需要替换原来的 v-for 循环:

<template>
  <RecycleScroller
    class="scroller"
    :items="list"
    :item-size="50"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="user">
      {{ item.name }}
    </div>
  </RecycleScroller>
</template>

<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { ref } from 'vue'

const list = ref([...]) // 假设有 10000 条数据
</script>

item-size 是每项的高度(必须一致,若高度不固定则用 DynamicScroller),key-field 用于标识每条数据的唯一键。滚动时,组件内部会自动计算偏移量并只渲染当前可见区域附近的小部分元素。

实用建议

  • 如果数据量超过 500 条 就可以考虑虚拟列表,不需要等到卡顿才动手。
  • 若列表项高度不固定,推荐使用 DynamicScroller + DynamicScrollerItem,它会自动测量每一项的真实高度并修正滚动位置。
  • 当列表项内部包含图片或异步内容时,为防止测量不准,可在图片加载完成后手动调用 recomputeSizes()
  • 不要同时开启浏览器的平滑滚动,否则与虚拟列表的快速偏移计算冲突,会出现闪动。

key 属性的正确使用与常见错误

key 是列表渲染时 Vue 用来追踪节点身份的唯一标识,它的正确与否直接影响 Diff 算法的效率和节点状态是否正确保持。

key 的作用原理
当列表数据发生变化(排序、插入、删除)时,Vue 会比较新旧 VNode。如果带 key,Vue 可以精确地知道哪个节点“移动了”而不是“被删除了又新建”,从而复用现有 DOM 节点,只移动它们的位置。如果没有 key 或使用 index 作为 key,Vue 会按照位置对比,可能导致不必要的 DOM 重绘,甚至状态错乱(例如输入框内容被错误复用)。

常见错误用法

  • index 作为 key
  <li v-for="(item, index) in list" :key="index">
  

当列表头部插入新项时,所有后续项的 key 都发生了变化,Vue 会认为原来索引 0 的 DOM 现在对应新插入项,索引 1 对应旧的索引 0… 最终结果是整个列表全部重新渲染,失去性能优势。更重要的是,如果列表项内有非受控组件(如 <input> 或带内部状态的自定义组件),它们的值会错乱。

  • 忘记写 key 或 key 不唯一

不写 key 时 Vue 使用“就地复用”策略,这是为了性能的退化方案,但同样会引发输入框残留等异常。key 值不唯一(比如多行数据有相同的 id)会导致 Vue 在 Diff 时误匹配节点,产生不可预测的渲染 bug。

正确做法
始终使用数据项中唯一且稳定的字段作为 key,比如后端返回的 id

<li v-for="item in list" :key="item.id">

即使进行排序或过滤操作,只要 id 不变,Vue 就能精准复用,移入/移出 DOM 的操作最小化。

实用小结

  • key 是性能工具也是正确性保证,永远不要用 index 做 key,除非你百分之百确定该列表不会发生插入、删除、排序。
  • 对于频繁变动的列表,好的 key 能让 Diff 效率翻倍。对于 1000 条数据,正确的 key 和错误 key 的性能差异可以轻松达到 10 倍以上。

长列表的懒加载与分页加载

如果你的列表数据来自服务端,并且总量可能非常大(数万条),那么即使用了虚拟列表,一次性请求、存储、处理所有数据也会占用大量内存和网络带宽。此时需要从数据源层面进行优化。

分页加载(传统方案)
最成熟的方案,后端提供分页接口,前端维护 pagepageSize。用户切换页码或点击“加载更多”时,请求当前页数据并替换列表或追加数据。

<template>
  <div>
    <ul>
      <li v-for="item in list" :key="item.id">{{ item.text }}</li>
    </ul>
    <button @click="loadNext" :disabled="loading">加载更多</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { fetchList } from '@/api'

const list = ref([])
const page = ref(1)
const loading = ref(false)
const hasMore = ref(true)

async function loadNext() {
  if (loading.value || !hasMore.value) return
  loading.value = true
  const res = await fetchList({ page: page.value, size: 20 })
  list.value.push(...res.data)
  hasMore.value = res.hasMore
  page.value++
  loading.value = false
}
</script>

无限滚动(交叉观察器方案)
用户体验更好的做法是滚动到底部自动触发加载,而不是点击按钮。使用 IntersectionObserver 监听列表底部的哨兵元素:

<template>
  <div>
    <ul>
      <li v-for="item in list" :key="item.id">{{ item.text }}</li>
    </ul>
    <div ref="sentinel" class="sentinel"></div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const sentinel = ref(null)
let observer

async function loadMore() {
  // 请求逻辑同上
}

onMounted(() => {
  observer = new IntersectionObserver(([entry]) => {
    if (entry.isIntersecting) {
      loadMore()
    }
  })
  observer.observe(sentinel.value)
})
onUnmounted(() => observer.disconnect())
</script>

分页加载 + 虚拟列表组合
当每一页的数据量仍然较多时,可以将分页加载与虚拟列表结合:每次请求到的结果追加进一个大的数据集,然后这个数据集传入 <RecycleScroller>。这样既控制了内存总量(只有可见的 DOM),又不会一次请求所有数据。实现时需注意,新数据追加后可能需要手动通知虚拟列表重新测量高度。

实用建议

  • 首屏只加载一屏能显示的数据量(通常 20~50 条),后续按需加载,避免无用请求。
  • 如果列表涉及实时数据(如后台监控列表),不建议使用“加载更多”,而应采用 WebSocket 推送或轮询小范围更新,结合虚拟列表直接修改某几项数据,保持视图最小更新。
  • 对于纯前端静态长列表(如省市区数据),直接上虚拟列表即可,无需分页。

掌握了这三招,你就可以从“让长列表跑起来”进入“让长列表飞起来”的阶段。通常,虚拟列表解决 DOM 量级问题,key 优化让 Diff 更聪明,而分页/懒加载则从源头控制数据洪流,三者结合足以应对 99% 的列表性能挑战。