虚拟列表的实现

tao
发布于2025-07-22 | 更新于2025-07-25

虚拟列表是在前端开发中针对超长列表数据展示进行按需渲染的优化方案,在 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">
    <!-- 使用预估高度和已计算的列表项高度计算总高度 -->
    <div :style="{ height: `${totalHeight}px` }">
      <!-- 相比于使用 padding-top 填充高度,transform 是 GPU 加速的,不会引起重排,性能更佳 -->
      <ul :style="{ transform: `translateY(${translateY}px)` }">
        <li
          v-for="(item, index) in visibleItems"
          :key="item"
          :ref="(el) => el && measureItem(el as HTMLElement, index + start)"
        >
          {{ item }}
        </li>
      </ul>
    </div>
  </div>
</template>

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

// 模拟 10000 条长度不同字符串的列表项,长度超出容器换行显示,从而产生高度变化
function generateRandomString(
  index: number,
  minLength: number = 20,
  maxLength: number = 120,
): string {
  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef ghijklmnopqrstuvwxyz0123456789'
  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 = new Map<number, number>()

// 总高度缓存
// 第 0 项到第 i 项的高度和
let cumulativeHeights: number[] = []

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

// 缓冲偏移量
const offset = 10

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

// 滚动监听
let ticking = false

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

// 开始索引
const start = ref(0)

// 计算列表项高度
function measureItem(el: HTMLElement, index: number) {
  const height = el.offsetHeight
  if (heightMap.get(index) !== height) {
    heightMap.set(index, height)
    calculateHeight()
  }
}

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

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

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

  // 获取开始索引
  const startIndex = getStartIndex(scrollTop)
  start.value = startIndex - offset <= 0 ? 0 : startIndex - offset

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

  // 获取结束索引
  const endIndex = startIndex + ESTIMATED_VISIBLE_COUNT
  const end = endIndex + offset >= stringArray.length ? stringArray.length : endIndex + offset

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

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

onMounted(() => {
  scrollContainer.value!.addEventListener('scroll', handleScroll)
  handleScroll()
})

onBeforeUnmount(() => {
  scrollContainer.value!.removeEventListener('scroll', handleScroll)
})
</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>

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