Vue 虚拟列表的实现

tao
发布于2025-07-22 | 更新于2025-08-06

虚拟列表是在前端开发中针对超长列表数据展示进行按需渲染的优化方案,在 npm 上已经有一些非常好用的虚拟列表实现,可以直接 npm install 使用,比如 vue-virtual-scroller (Blazing fast scrolling of any amount of data)等。

固定列表项高度

首先想到从简单的列表项高度为固定高度开始实现:

<template>
  <div class="virtual-list" ref="scrollContainer" @scroll="handleScroll">
    <div :style="{ height: `${stringArray.length * ITEM_HEIGHT}px` }">
      <!-- 相比于使用 padding-top 填充高度,transform 是 GPU 加速的,不会引起重排,性能更佳 -->
      <ul :style="{ transform: `translateY(${translateY}px)` }">
        <li v-for="item in visibleItems" :key="item">{{ item }}</li>
      </ul>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'

// 模拟 10000 条列表项
const stringArray: string[] = Array.from({ length: 10000 }, (_, index) => `${index}`)

// 固定高度 53
const ITEM_HEIGHT = 53

// 固定可视项数量 5
const VISIBLE_COUNT = 5

// 缓冲数量
const buffer = 10

// 可视列表项
const visibleItems = ref<string[]>([])

// 滚动位置
const translateY = ref(0)

// 滚动容器
const scrollContainer = ref<HTMLDivElement>()

// 更新可视列表项
function updateVisibleItems() {
  const scrollTop = scrollContainer.value!.scrollTop

  // 获取开始索引
  const startIndex = ~~(scrollTop / ITEM_HEIGHT)
  const start = Math.max(0, startIndex - buffer)

  // 计算滚动位置
  translateY.value = start * ITEM_HEIGHT

  // 获取结束索引
  const endIndex = startIndex + VISIBLE_COUNT
  const end = Math.min(stringArray.length, endIndex + buffer)

  visibleItems.value = stringArray.slice(start, end)
}

// 滚动监听
const handleScroll = () => {
  // 经典的 requestAnimationFrame 滚动节流模式,回调函数会被浏览器安排在下一次重绘前执行
  // 相比 setTimeout 可以减少卡顿感
  requestAnimationFrame(() => {
    updateVisibleItems()
  })
}

onMounted(() => {
  updateVisibleItems()
})
</script>

<style scoped>
.virtual-list {
  width: 300px;
  height: 265px;
  overflow-y: auto;
}

.virtual-list ul {
  width: 100%;
  list-style: none;
  padding: 0;
  margin: 0;
}

.virtual-list ul li {
  width: 100%;
  height: 53px;
  padding: 16px 16px;
  line-height: 20px;
  border-bottom: 1px solid #ddd;
}
</style>

以上实现的核心思路就是只渲染视口内的数据 + 伪造总高度

以下是 vue-virtual-scroller 的核心实现思路:

<template>
  <div class="virtual-list" ref="scrollContainer" @scroll="handleScroll">
    <ul :style="{ height: `${stringArray.length * ITEM_HEIGHT}px` }">
      <!-- 相比于使用 padding-top 填充高度,transform 是 GPU 加速的,不会引起重排,性能更佳 -->
      <li
        v-for="view in pool"
        :key="view.item"
        :style="[
          { transform: `translateY(${view.position}px)` },
          { visibility: view.nr.used ? 'visible' : 'hidden' },
        ]"
      >
        {{ view.item }}
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, markRaw, shallowReactive } from 'vue'

interface NodeReference {
  index: number
  used: boolean
}

interface ViewItem {
  item: string
  position: number
  nr: NodeReference
}

// 固定高度 53
const ITEM_HEIGHT = 53

// 固定视口高度
const VIEWPORT_HEIGHT = 265

// 模拟 10000 条列表项
const stringArray: string[] = Array.from({ length: 10000 }, (_, index) => `${index}`)

// 缓冲高度
const buffer = 265

// 可视列表项
const visibleItems = ref<Map<string, ViewItem>>(new Map())

// 渲染池
const pool = ref<ViewItem[]>([])

// 回收池
const recyclePool = ref<ViewItem[]>([])

// 滚动容器
const scrollContainer = ref<HTMLDivElement>()

// 上一次的开始和结束索引
let lastStartIndex = 0
let lastEndIndex = 0

// 回收列表项
function removeAndRecycleView(view: ViewItem) {
  recyclePool.value.push(view)
  view.nr.used = false
  view.position = -9999
  visibleItems.value.delete(view.item)
}

function removeAndRecycleAllViews() {
  visibleItems.value.clear()
  recyclePool.value = []
  for (let i = 0; i < pool.value.length; i++) {
    const view = pool.value[i]
    removeAndRecycleView(view)
  }
}

// 从回收池中获取列表项
function getRecycledView() {
  if (recyclePool.value.length) {
    const view = recyclePool.value.pop()
    view!.nr.used = true
    return view
  } else {
    return null
  }
}

// 创建渲染项
function createView(index: number, item: string): ViewItem {
  const nr = markRaw({
    index,
    used: true,
  })
  const view = shallowReactive({
    item,
    position: 0,
    nr,
  })
  pool.value.push(view)
  return view
}

// 更新可视列表项
function updateVisibleItems() {
  // 获取滚动位置
  const scrollTop = scrollContainer.value!.scrollTop

  const scrollState = {
    start: scrollTop - buffer,
    end: scrollTop + VIEWPORT_HEIGHT + buffer,
  }

  // 获取开始索引
  let startIndex = ~~(scrollState.start / ITEM_HEIGHT)
  if (startIndex < 0) startIndex = 0

  // 获取结束索引
  let endIndex = Math.ceil(scrollState.end / ITEM_HEIGHT)
  if (endIndex > stringArray.length) endIndex = stringArray.length

  const continuous = startIndex <= lastEndIndex && endIndex >= lastStartIndex

  if (!continuous) {
    // 不连续的滚动清空所有渲染项
    // 加上这个整体会流畅很多
    // 不加滚动时会有明显卡顿
    removeAndRecycleAllViews()
  } else {
    // !循环渲染池中的所有项排查需要移除和回收的项
    for (let i = 0; i < pool.value.length; i++) {
      const view = pool.value[i]
      if (view.nr.used) {
        const viewVisible = view.nr.index >= startIndex && view.nr.index < endIndex
        if (!viewVisible) {
          removeAndRecycleView(view)
        }
      }
    }
  }

  // 加载渲染项
  for (let i = startIndex; i < endIndex; i++) {
    const item = stringArray[i]
    let view = visibleItems.value.get(item)

    if (!view) {
      const recycleView = getRecycledView()
      if (!recycleView) {
        // 一旦 pool 里的项数量达到最大可视 + 缓冲区需求,之后滚动时总是复用 recyclePool 里的项,不会再新建
        // 这是 pool 能保持固定长度而不会无限增长的秘密
        view = createView(i, item)
      } else {
        view = recycleView
        // 复用更新内容和索引
        view.item = item
        view.nr.index = i
      }
      visibleItems.value.set(item, view)
    } else {
      if (view.item !== item) {
        view.item = item
      }
      if (!view.nr.used) {
        console.warn("Expected existing view's used flag to be true, got " + view.nr.used)
      }
    }

    view.position = i * ITEM_HEIGHT
  }

  lastStartIndex = startIndex
  lastEndIndex = endIndex
}

function handleScroll() {
  // 经典的 requestAnimationFrame 滚动节流模式,回调函数会被浏览器安排在下一次重绘前执行
  // 相比 setTimeout 可以减少卡顿感
  requestAnimationFrame(() => {
    updateVisibleItems()
  })
}

onMounted(() => {
  updateVisibleItems()
})
</script>

<style scoped>
.virtual-list {
  width: 300px;
  height: 265px;
  overflow-y: auto;
}

.virtual-list ul {
  position: relative;
  width: 100%;
  list-style: none;
  padding: 0;
  margin: 0;
}

.virtual-list ul li {
  position: absolute;
  top: 0;
  width: 100%;
  height: 53px;
  padding: 16px 16px;
  line-height: 20px;
  border-bottom: 1px solid #ddd;
}
</style>

一旦 pool 里的项数量达到最大可视 + 缓冲区需求,之后滚动时总是复用 recyclePool 里的项,不会再新建。这是 pool 能保持固定长度而不会无限增长的秘密。

高度动态变化

然后开始进阶实现列表项的高度是动态变化的情况:

<template>
  <div class="virtual-list" ref="scrollContainer" @scroll="handleScroll">
    <!-- 使用预估高度和已计算的列表项高度计算总高度 -->
    <div :style="{ height: `${totalHeight}px` }">
      <!-- 相比于使用 padding-top 填充高度,transform 是 GPU 加速的,不会引起重排,性能更佳 -->
      <ul ref="listContainer" :style="{ transform: `translateY(${translateY}px)` }">
        <li v-for="item in visibleItems" :key="item">
          {{ item }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, watch } from 'vue'

// 模拟 10000 条长度不同字符串的列表项,长度超出容器换行显示,从而产生高度变化
function generateRandomString(
  index: number,
  minLength: number = 20,
  maxLength: number = 120,
): string {
  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
  const length = Math.floor(Math.random() * (maxLength - minLength + 1)) + minLength
  return (
    Array.from({ length }, () =>
      characters.charAt(Math.floor(Math.random() * characters.length)),
    ).join('') + `-${index}`
  )
}

const stringArray: string[] = Array.from({ length: 10000 }, (_, index) =>
  generateRandomString(index),
)

// 预估每项高度
const ESTIMATED_HEIGHT = 53

// 预估可视列表项数量
const ESTIMATED_VISIBLE_COUNT = 5

// 总高度
const totalHeight = ref(0)

// 列表项高度缓存
// 第 i 项的高度
const heightMap = reactive<Map<string, number>>(new Map())

// 总高度缓存
// 第 0 项到第 i 项的高度和
const cumulativeHeights = ref<number[]>([])

// 滚动容器
const scrollContainer = ref<HTMLDivElement>()

// 列表容器
const listContainer = ref<HTMLUListElement>()

// 缓冲
const buffer = 10

// 可视列表项
const visibleItems = ref<string[]>([])

// 滚动位置
const translateY = ref(0)

watch(heightMap, () => {
  calculateHeight()
})

// 更新总高度
function calculateHeight() {
  cumulativeHeights.value = []
  let total = 0
  stringArray.forEach((item) => {
    const h = heightMap.get(item) ?? ESTIMATED_HEIGHT
    cumulativeHeights.value.push(total)
    total += h
  })
  totalHeight.value = total
}

watch(cumulativeHeights, () => {
  updateVisibleItems()
})

// 总高度缓存数组可看作排好序的数组
// 利用二分法查找开始索引
function getStartIndex(scrollTop: number) {
  let low = 0
  let high = cumulativeHeights.value.length - 1
  let ans = 0
  while (low <= high) {
    const mid = ~~((low + high) / 2)
    if (cumulativeHeights.value[mid] < scrollTop) {
      ans = mid
      low = mid + 1
    } else {
      high = mid - 1
    }
  }
  return ans
}

// 更新可视列表项
function updateVisibleItems() {
  const scrollTop = scrollContainer.value!.scrollTop

  // 获取开始索引
  const startIndex = getStartIndex(scrollTop)
  const start = Math.max(0, startIndex - buffer)

  // 获取滚动位置
  translateY.value = cumulativeHeights.value[start] ?? 0

  // 获取结束索引
  const endIndex = startIndex + ESTIMATED_VISIBLE_COUNT
  const end = Math.min(stringArray.length, endIndex + buffer)

  // 等待 DOM 渲染完成后再更新可视列表,防止 scrollTop 值发生跳动
  nextTick(() => {
    visibleItems.value = stringArray.slice(start, end)
  })
}

function observeDynamicList() {
  const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      if (mutation.addedNodes.length) {
        mutation.addedNodes.forEach((node) => {
          if (node instanceof HTMLLIElement) {
            const item = node.innerText
            if (!heightMap.has(item)) {
              heightMap.set(item, node.offsetHeight)
            }
          }
        })
      }
    })
  })

  observer.observe(listContainer.value!, {
    childList: true,
  })
}

// 滚动监听
function handleScroll() {
  // 经典的 requestAnimationFrame 滚动节流模式,回调函数会被浏览器安排在下一次重绘前执行
  // 相比 setTimeout 可以减少卡顿感
  requestAnimationFrame(() => {
    updateVisibleItems()
  })
}

onMounted(() => {
  observeDynamicList()
  updateVisibleItems()
})
</script>

<style>
.virtual-list {
  width: 300px;
  height: 265px;
  overflow-y: auto;
}

.virtual-list ul {
  width: 100%;
  list-style: none;
  padding: 0;
  margin: 0;
}

.virtual-list ul li {
  width: 100%;
  padding: 16px 16px;
  line-height: 20px;
  border-bottom: 1px solid #ddd;
  overflow-wrap: break-word;
}
</style>

以上实现的核心思路就是预估高度 + 高度测量 + 高度缓存 + 二分法查找 + 只渲染视口内的数据 + 伪造总高度

发现一个 Vue 的机制问题,在下面的代码中,watch 的第一个参数是函数 calculateHeight,然后 Vue 执行了这个函数并记住了函数其中的依赖,依赖变化自动执行了函数 calculateHeight,但 updateVisibleItems 其实永远不会被执行。这样做是很迷惑并且与官方指导不同的。

watch(calculateHeight, () => {
  updateVisibleItems()
})

以下是 vue-virtual-scroller 的核心实现思路:

<template>
  <div class="virtual-list" ref="scrollContainer" @scroll="handleScroll">
    <ul ref="listContainer" :style="{ height: `${totalHeight}px` }">
      <!-- 相比于使用 padding-top 填充高度,transform 是 GPU 加速的,不会引起重排,性能更佳 -->
      <!-- 引入稳定且唯一的 key 自增 uid -->
      <!-- 无论 ViewItem 的 item 内容如何变化,Vue 都能正确地追踪到这是同一个 DOM 节点,只需要更新其内容即可,而不会再因为 key 的重复而混淆。 -->
      <li
        v-for="view in pool"
        :key="view.nr.id"
        :style="[
          { transform: `translateY(${view.position}px)` },
          { visibility: view.nr.used ? 'visible' : 'hidden' },
        ]"
      >
        {{ view.item }}
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, markRaw, shallowReactive, computed, watch, nextTick } from 'vue'

interface NodeReference {
  id: number
  index: number
  used: boolean
}

interface ViewItem {
  item: string
  position: number
  nr: NodeReference
}

// 唯一 ID
let uid = 0

// 最小高度 53
const MIN_HEIGHT = 53

// 固定视口高度
const VIEWPORT_HEIGHT = 265

// 模拟 10000 条长度不同字符串的列表项,长度超出容器换行显示,从而产生高度变化
function generateRandomString(
  index: number,
  minLength: number = 20,
  maxLength: number = 120,
): string {
  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
  const length = Math.floor(Math.random() * (maxLength - minLength + 1)) + minLength
  return (
    Array.from({ length }, () =>
      characters.charAt(Math.floor(Math.random() * characters.length)),
    ).join('') + `-${index}`
  )
}

const stringArray: string[] = Array.from({ length: 10000 }, (_, index) =>
  generateRandomString(index),
)

// 高度缓存
const heights = ref<Record<string, number>>({})

// 高度列表
const itemsWithHeight = computed<{ item: string; height: number }[]>(() => {
  const res = []
  // 这里有一个全量循环
  for (let i = 0; i < stringArray.length; i++) {
    const item = stringArray[i]
    // 如果高度缓存发生变化要重新计算高度列表
    let height = heights.value[item]
    if (!height) {
      height = 0
    }
    res.push({
      item,
      height,
    })
  }
  return res
})

type CumulativeHeightRecord = Record<number, { accumulator: number; height?: number }>

// 累积高度缓存
const cumulativeHeights = computed<CumulativeHeightRecord>(() => {
  const cumulativeHeights: CumulativeHeightRecord = {
    '-1': { accumulator: 0 },
  }

  let accumulator = 0
  let current

  // 高度列表发生变化时重新计算累计高度缓存
  // 这里还有一个全量循环
  for (let i = 0; i < itemsWithHeight.value.length; i++) {
    current = itemsWithHeight.value[i].height || MIN_HEIGHT
    accumulator += current
    cumulativeHeights[i] = {
      accumulator,
      height: current,
    }
  }

  return cumulativeHeights
})

watch(itemsWithHeight, () => {
  updateVisibleItems()
})

watch(
  cumulativeHeights,
  () => {
    updateVisibleItems()
  },
  { deep: true },
)

// 总高度
const totalHeight = ref(0)

// 缓冲高度
const buffer = 265

// 可视列表项
const visibleItems = ref<Map<string, ViewItem>>(new Map())

// 渲染池
const pool = ref<ViewItem[]>([])

// 回收池
const recyclePool = ref<ViewItem[]>([])

// 滚动容器
const scrollContainer = ref<HTMLDivElement>()

// 上一次的开始和结束索引
let lastStartIndex = 0
let lastEndIndex = 0

// 回收列表项
function removeAndRecycleView(view: ViewItem) {
  recyclePool.value.push(view)
  view.nr.used = false
  view.position = -9999
  visibleItems.value.delete(view.item)
}

function removeAndRecycleAllViews() {
  visibleItems.value.clear()
  recyclePool.value = []
  for (let i = 0; i < pool.value.length; i++) {
    const view = pool.value[i]
    removeAndRecycleView(view)
  }
}

// 从回收池中获取列表项
function getRecycledView() {
  if (recyclePool.value.length) {
    const view = recyclePool.value.pop()
    view!.nr.used = true
    return view
  } else {
    return null
  }
}

// 创建渲染项
function createView(index: number, item: string): ViewItem {
  const nr = markRaw({
    id: uid++,
    index,
    used: true,
  })
  const view = shallowReactive({
    item,
    position: 0,
    nr,
  })
  pool.value.push(view)
  return view
}

// 更新可视列表项
function updateVisibleItems() {
  const count = itemsWithHeight.value.length

  // 获取滚动位置
  const scrollTop = scrollContainer.value!.scrollTop

  const scrollState = {
    start: scrollTop - buffer,
    end: scrollTop + VIEWPORT_HEIGHT + buffer,
  }

  // 二分法查询开始索引
  let low = 0
  let high = count - 1
  let startIndex = 0

  while (low <= high) {
    const mid = low + ~~((high - low) / 2)
    if (cumulativeHeights.value[mid].accumulator < scrollState.start) {
      startIndex = mid
      low = mid + 1
    } else {
      high = mid - 1
    }
  }

  // 设置总高度
  totalHeight.value = cumulativeHeights.value[count - 1].accumulator

  // 计算结束索引
  let endIndex

  for (
    endIndex = startIndex;
    endIndex < count && cumulativeHeights.value[endIndex].accumulator < scrollState.end;
    endIndex++
  );

  endIndex++

  const continuous = startIndex <= lastEndIndex && endIndex >= lastStartIndex

  if (!continuous) {
    // 不连续的滚动清空所有渲染项
    // 加上这个整体会流畅很多
    // 不加滚动时会有明显卡顿
    removeAndRecycleAllViews()
  } else {
    // !循环渲染池中的所有项排查需要移除和回收的项
    for (let i = 0; i < pool.value.length; i++) {
      const view = pool.value[i]
      if (view.nr.used) {
        const viewVisible = view.nr.index >= startIndex && view.nr.index < endIndex
        if (!viewVisible) {
          removeAndRecycleView(view)
        }
      }
    }
  }

  // 加载渲染项
  for (let i = startIndex; i < endIndex; i++) {
    const item = stringArray[i]
    let view = visibleItems.value.get(item)

    if (!view) {
      const recycleView = getRecycledView()
      if (!recycleView) {
        // 一旦 pool 里的项数量达到最大可视 + 缓冲区需求,之后滚动时总是复用 recyclePool 里的项,不会再新建
        // 这是 pool 能保持固定长度而不会无限增长的秘密
        view = createView(i, item)
      } else {
        view = recycleView
        view.item = item
        view.nr.index = i
      }
      visibleItems.value.set(item, view)
    } else {
      if (view.item !== item) {
        view.item = item
      }
      if (!view.nr.used) {
        console.warn("Expected existing view's used flag to be true, got " + view.nr.used)
      }
    }

    view.position = cumulativeHeights.value[i - 1].accumulator
  }

  lastStartIndex = startIndex
  lastEndIndex = endIndex

  nextTick(() => {
    // 每次都更新可视列表的正确高度
    updateAllVisibleHeights()
  })
}

function handleScroll() {
  // 经典的 requestAnimationFrame 滚动节流模式,回调函数会被浏览器安排在下一次重绘前执行
  // 相比 setTimeout 可以减少卡顿感
  requestAnimationFrame(() => {
    updateVisibleItems()
  })
}

const listContainer = ref<HTMLUListElement>()

function updateAllVisibleHeights() {
  const liElements = listContainer.value!.children
  for (let i = 0; i < liElements.length; i++) {
    const li = liElements[i] as HTMLLIElement
    const item = li.innerText
    if (!heights.value[item]) {
      heights.value[item] = li.offsetHeight
    }
  }
}

onMounted(() => {
  updateVisibleItems()
})
</script>

<style scoped>
.virtual-list {
  width: 300px;
  height: 265px;
  overflow-y: auto;
}

.virtual-list ul {
  position: relative;
  width: 100%;
  list-style: none;
  padding: 0;
  margin: 0;
}

.virtual-list ul li {
  position: absolute;
  top: 0;
  width: 100%;
  padding: 16px 16px;
  line-height: 20px;
  border-bottom: 1px solid #ddd;
  overflow-wrap: break-word;
}
</style>

引入稳定且唯一的 key 自增 uid,无论 ViewItem 的 item 内容如何变化,Vue 都能正确地追踪到这是同一个 DOM 节点,只需要更新其内容即可,而不会再因为 key 的重复而混淆。