简单固定列表个澳督 虚拟滚动

Vue 3 虚拟滚动列表实现方案

一、什么是虚拟滚动?

当列表数据量很大时(如 10000 条),直接渲染所有 DOM 节点会导致页面卡顿。虚拟滚动的核心思想是:只渲染可视区域内的元素 ,通过控制 startIndexendIndex 来决定渲染范围。

核心原理

复制代码
┌─────────────────────────────┐
│     phantom (撑开总高度)       │  ← 总高度 = itemCount × itemHeight
│  ┌───────────────────────┐  │
│  │   上方不可见区域         │  │
│  ├───────────────────────┤  │
│  │  可见区域               │  │  ← 只渲染 startIndex ~ endIndex
│  │  (startIndex ~ endIndex)│  │
│  ├───────────────────────┤  │
│  │   下方不可见区域         │  │
│  └───────────────────────┘  │
└─────────────────────────────┘

关键计算

  • startIndex = Math.floor(scrollTop / itemHeight) - bufferSize
  • endIndex = startIndex + visibleCount + bufferSize
  • offset = startIndex * itemHeight(用 translateY 把可见项推到正确位置)

二、方案一:固定高度 + 原生滚动

最基础的虚拟滚动实现,所有 item 高度固定。

vue 复制代码
<template>
  <div class="virtual-scroll-demo">
    <h1>虚拟滚动列表示例</h1>
    <p>总数据量: {{ items.length }} 条</p>

    <div class="virtual-list-container" ref="containerRef" @scroll="handleScroll">
      <div class="virtual-list-phantom" :style="{ height: `${totalHeight}px` }">
        <div class="virtual-list-content" :style="{ transform: `translateY(${offset}px)` }">
          <div v-for="item in visibleItems" :key="item.index" class="virtual-list-item">
            Item #{{ item.index + 1 }} - {{ item.data }}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

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

const ITEM_HEIGHT = 40
const CONTAINER_HEIGHT = 400
const BUFFER_SIZE = 3

const items = Array.from({ length: 10000 }, (_, i) => ({
  index: i,
  data: `这是第 ${i + 1} 条数据`
}))

const totalHeight = computed(() => items.length * ITEM_HEIGHT)
const containerRef = ref(null)
const scrollTop = ref(0)

const startIndex = computed(() => {
  const start = Math.floor(scrollTop.value / ITEM_HEIGHT)
  return Math.max(0, start - BUFFER_SIZE)
})

const endIndex = computed(() => {
  const visibleCount = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT)
  const start = Math.floor(scrollTop.value / ITEM_HEIGHT)
  const end = start + visibleCount + BUFFER_SIZE
  return Math.min(items.length - 1, end)
})

const offset = computed(() => {
  return startIndex.value * ITEM_HEIGHT
})

const visibleItems = computed(() => {
  return items.slice(startIndex.value, endIndex.value + 1)
})

const handleScroll = () => {
  if (containerRef.value) {
    scrollTop.value = containerRef.value.scrollTop
  }
}
</script>

<style scoped>
.virtual-scroll-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}
.virtual-list-container {
  height: 400px;
  overflow-y: auto;
  border: 2px solid #ccc;
  border-radius: 4px;
  position: relative;
}
.virtual-list-phantom {
  position: relative;
  width: 100%;
}
.virtual-list-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}
.virtual-list-item {
  height: 40px;
  line-height: 40px;
  padding: 0 16px;
  border-bottom: 1px solid #eee;
  background: white;
}
.virtual-list-item:hover {
  background: #f5f5f5;
}
</style>

三、方案二:固定高度 + Better-Scroll

用 Better-Scroll 替换原生滚动,核心虚拟滚动逻辑不变。

与原生滚动的区别

原生滚动 Better-Scroll
滚动容器 overflow: auto BScroll 实例
监听滚动 @scroll + scrollTop scroll 事件 + pos.y
撑开高度 phantom div 同样用 phantom div

关键点

  • probeType: 3:必须在动画帧期间也触发 scroll 事件(probeType: 2 在惯性滚动时不触发)
  • pos.y 是负值,需要取绝对值
vue 复制代码
<template>
  <div class="virtual-scroll-demo">
    <h1>虚拟滚动列表示例 (Better-Scroll)</h1>
    <p>总数据量: {{ items.length }} 条</p>

    <div class="virtual-list-container" ref="containerRef">
      <div class="virtual-list-phantom" :style="{ height: `${totalHeight}px` }">
        <div class="virtual-list-content" :style="{ transform: `translateY(${offset}px)` }">
          <div v-for="item in visibleItems" :key="item.index" class="virtual-list-item">
            Item #{{ item.index + 1 }} - {{ item.data }}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import BScroll from 'better-scroll'

const ITEM_HEIGHT = 40
const CONTAINER_HEIGHT = 400
const BUFFER_SIZE = 3

const items = Array.from({ length: 10000 }, (_, i) => ({
  index: i,
  data: `这是第 ${i + 1} 条数据`
}))

const totalHeight = computed(() => items.length * ITEM_HEIGHT)
const containerRef = ref(null)
const scrollTop = ref(0)
let bsInstance = null

const startIndex = computed(() => {
  const start = Math.floor(scrollTop.value / ITEM_HEIGHT)
  return Math.max(0, start - BUFFER_SIZE)
})

const endIndex = computed(() => {
  const visibleCount = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT)
  const start = Math.floor(scrollTop.value / ITEM_HEIGHT)
  const end = start + visibleCount + BUFFER_SIZE
  return Math.min(items.length - 1, end)
})

const offset = computed(() => {
  return startIndex.value * ITEM_HEIGHT
})

const visibleItems = computed(() => {
  return items.slice(startIndex.value, endIndex.value + 1)
})

onMounted(() => {
  bsInstance = new BScroll(containerRef.value, {
    probeType: 3, // 实时派发滚动事件(包括惯性滚动动画期间)
    click: true,
    scrollY: true,
    bounce: false
  })

  bsInstance.on('scroll', (pos) => {
    scrollTop.value = Math.abs(pos.y)
  })
})

onUnmounted(() => {
  if (bsInstance) {
    bsInstance.destroy()
  }
})
</script>

<style scoped>
.virtual-scroll-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}
.virtual-list-container {
  height: 400px;
  overflow: hidden;
  border: 2px solid #ccc;
  border-radius: 4px;
  position: relative;
}
.virtual-list-phantom {
  position: relative;
  width: 100%;
}
.virtual-list-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}
.virtual-list-item {
  height: 40px;
  line-height: 40px;
  padding: 0 16px;
  border-bottom: 1px solid #eee;
  background: white;
}
.virtual-list-item:hover {
  background: #f5f5f5;
}
</style>

四、方案三:固定高度 + 上拉加载更多

在虚拟滚动基础上,检测滚动到底部时加载更多数据。

核心逻辑

  1. 滚动时检测 scrollHeight - scrollTop - clientHeight < threshold
  2. 触发 loadMore(),用 loading 状态防止重复触发
  3. 新数据 push 到数组尾部,phantom 总高度自动更新
vue 复制代码
<template>
  <div class="virtual-scroll-demo">
    <h1>虚拟滚动 - 上拉加载更多</h1>
    <p>当前数据量: {{ items.length }} 条 {{ loading ? '(加载中...)' : '' }}</p>

    <div class="virtual-list-container" ref="containerRef" @scroll="handleScroll">
      <div class="virtual-list-phantom" :style="{ height: `${totalHeight}px` }">
        <div class="virtual-list-content" :style="{ transform: `translateY(${offset}px)` }">
          <div v-for="item in visibleItems" :key="item.index" class="virtual-list-item">
            Item #{{ item.index + 1 }} - {{ item.data }}
          </div>
        </div>
      </div>
      <div v-if="loading" class="loading-tip">加载中...</div>
    </div>
  </div>
</template>

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

const ITEM_HEIGHT = 40
const CONTAINER_HEIGHT = 400
const BUFFER_SIZE = 3
const LOAD_THRESHOLD = 100
const PAGE_SIZE = 20

const items = ref(
  Array.from({ length: 50 }, (_, i) => ({
    index: i,
    data: `这是第 ${i + 1} 条数据`
  }))
)
const scrollTop = ref(0)
const loading = ref(false)
const containerRef = ref(null)

const totalHeight = computed(() => items.value.length * ITEM_HEIGHT)

const startIndex = computed(() => {
  const start = Math.floor(scrollTop.value / ITEM_HEIGHT)
  return Math.max(0, start - BUFFER_SIZE)
})

const endIndex = computed(() => {
  const visibleCount = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT)
  const start = Math.floor(scrollTop.value / ITEM_HEIGHT)
  const end = start + visibleCount + BUFFER_SIZE
  return Math.min(items.value.length - 1, end)
})

const offset = computed(() => startIndex.value * ITEM_HEIGHT)

const visibleItems = computed(() => {
  return items.value.slice(startIndex.value, endIndex.value + 1)
})

const handleScroll = () => {
  if (!containerRef.value) return
  scrollTop.value = containerRef.value.scrollTop

  const { scrollTop: st, scrollHeight, clientHeight } = containerRef.value
  if (scrollHeight - st - clientHeight < LOAD_THRESHOLD && !loading.value) {
    loadMore()
  }
}

const loadMore = () => {
  loading.value = true
  setTimeout(() => {
    const start = items.value.length
    const newItems = Array.from({ length: PAGE_SIZE }, (_, i) => ({
      index: start + i,
      data: `这是第 ${start + i + 1} 条数据`
    }))
    items.value.push(...newItems)
    loading.value = false
  }, 500)
}
</script>

<style scoped>
.virtual-scroll-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}
.virtual-list-container {
  height: 400px;
  overflow-y: auto;
  border: 2px solid #ccc;
  border-radius: 4px;
  position: relative;
}
.virtual-list-phantom {
  position: relative;
  width: 100%;
}
.virtual-list-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}
.virtual-list-item {
  height: 40px;
  line-height: 40px;
  padding: 0 16px;
  border-bottom: 1px solid #eee;
  background: white;
}
.virtual-list-item:hover {
  background: #f5f5f5;
}
.loading-tip {
  text-align: center;
  padding: 10px;
  color: #999;
  font-size: 14px;
}
</style>

五、方案四:不定高度 + 上拉加载更多

核心难点

固定高度时,startIndex = scrollTop / itemHeight 可以直接算出。不定高度时,无法直接计算,需要:

  1. 前缀和数组positions[i] = 第 i 项顶部的 y 坐标
  2. 二分查找 :在前缀和数组中找到 scrollTop 对应的索引
  3. ResizeObserver:渲染后测量真实高度,更新缓存
  4. 位置补偿 :高度变化时调整 scrollTop,防止跳动

实现流程

复制代码
1. 初始化:每项给预估高度(60px),构建前缀和数组
2. 滚动时:二分查找找到 startIndex
3. 渲染后:ResizeObserver 测量真实高度
4. 更新:用真实高度替换预估高度,重新计算前缀和
5. 补偿:如果真实高度和预估不同,调整 scrollTop 防止跳动
vue 复制代码
<template>
  <div class="virtual-scroll-demo">
    <h1>虚拟滚动 - 不定高度 + 上拉加载</h1>
    <p>当前数据量: {{ items.length }} 条 {{ loading ? '(加载中...)' : '' }}</p>

    <div class="virtual-list-container" ref="containerRef" @scroll="handleScroll">
      <div class="virtual-list-phantom" :style="{ height: `${totalHeight}px` }">
        <div class="virtual-list-content" :style="{ transform: `translateY(${offset}px)` }">
          <div v-for="item in visibleItems" :key="item.index" class="virtual-list-item"
            :ref="el => setItemRef(el, item.index)">
            Item #{{ item.index + 1 }}
            <div class="item-content">{{ item.data }}</div>
          </div>
        </div>
        <div v-if="loading" class="loading-tip">加载中...</div>
      </div>
    </div>
  </div>
</template>

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

const ESTIMATED_HEIGHT = 60
const CONTAINER_HEIGHT = 400
const BUFFER_SIZE = 3
const LOAD_THRESHOLD = 100
const PAGE_SIZE = 20

const items = ref(
  Array.from({ length: 50 }, (_, i) => ({
    index: i,
    data: `这是第 ${i + 1} 条数据,内容长度不同:${'x'.repeat(Math.floor(Math.random() * 100) + 20)}`
  }))
)
const scrollTop = ref(0)
const loading = ref(false)
const containerRef = ref(null)

// 高度缓存:index → 实际高度
const heightCache = new Map()
// 前缀和数组:positions[i] = 第 i 项顶部的 y 坐标
const positions = ref([])

const initPositions = () => {
  const pos = [0]
  for (let i = 0; i < items.value.length; i++) {
    const height = heightCache.get(i) || ESTIMATED_HEIGHT
    pos.push(pos[i] + height)
  }
  positions.value = pos
}
initPositions()

const totalHeight = computed(() => positions.value[positions.value.length - 1] || 0)

// 二分查找
const findStartIndex = (scrollPos) => {
  const pos = positions.value
  let low = 0, high = pos.length - 1
  while (low <= high) {
    const mid = Math.floor((low + high) / 2)
    if (pos[mid] < scrollPos) low = mid + 1
    else high = mid - 1
  }
  return high >= 0 ? high : 0
}

const startIndex = computed(() => {
  const start = findStartIndex(scrollTop.value)
  return Math.max(0, start - BUFFER_SIZE)
})

const endIndex = computed(() => {
  const start = findStartIndex(scrollTop.value)
  let end = start
  const pos = positions.value
  const maxScroll = scrollTop.value + CONTAINER_HEIGHT
  while (end < items.value.length && pos[end] < maxScroll) end++
  return Math.min(items.value.length - 1, end + BUFFER_SIZE)
})

const offset = computed(() => positions.value[startIndex.value] || 0)

const visibleItems = computed(() => {
  return items.value.slice(startIndex.value, endIndex.value + 1)
})

const handleScroll = () => {
  if (!containerRef.value) return
  scrollTop.value = containerRef.value.scrollTop

  const { scrollTop: st, scrollHeight, clientHeight } = containerRef.value
  if (scrollHeight - st - clientHeight < LOAD_THRESHOLD && !loading.value) {
    loadMore()
  }
}

const loadMore = () => {
  loading.value = true
  setTimeout(() => {
    const start = items.value.length
    const newItems = Array.from({ length: PAGE_SIZE }, (_, i) => ({
      index: start + i,
      data: `这是第 ${start + i + 1} 条数据,内容长度不同:${'x'.repeat(Math.floor(Math.random() * 100) + 20)}`
    }))
    items.value.push(...newItems)
    loading.value = false
  }, 500)
}

// 监听 items 变化,只观察新增元素
let prevLength = items.value.length
watch(() => items.value.length, (newLength) => {
  initPositions()
  nextTick(() => {
    for (let i = prevLength; i < newLength; i++) {
      const el = itemRefs.get(i)
      if (el && !el.dataset.observed) {
        el.dataset.index = i
        el.dataset.observed = 'true'
        resizeObserver?.observe(el)
      }
    }
    prevLength = newLength
  })
})

// 设置 item ref
const itemRefs = new Map()
const setItemRef = (el, index) => {
  if (el) itemRefs.set(index, el)
  else itemRefs.delete(index)
}

// ResizeObserver 测量真实高度
let resizeObserver = null
onMounted(() => {
  resizeObserver = new ResizeObserver((entries) => {
    let needUpdate = false
    for (const entry of entries) {
      const index = parseInt(entry.target.dataset.index)
      if (isNaN(index)) continue

      const height = entry.target.offsetHeight
      const oldHeight = heightCache.get(index)
      if (oldHeight !== height) {
        heightCache.set(index, height)
        needUpdate = true
      }
    }

    if (needUpdate) {
      const oldScrollTop = scrollTop.value
      const oldStartIndex = startIndex.value
      const oldOffset = positions.value[oldStartIndex] || 0

      initPositions()

      // 位置补偿:防止跳动
      const newOffset = positions.value[oldStartIndex] || 0
      const diff = newOffset - oldOffset
      if (containerRef.value && diff !== 0) {
        containerRef.value.scrollTop = oldScrollTop + diff
        scrollTop.value = oldScrollTop + diff
      }
    }
  })

  nextTick(() => {
    itemRefs.forEach((el, index) => {
      if (el) {
        el.dataset.index = index
        el.dataset.observed = 'true'
        resizeObserver.observe(el)
      }
    })
  })
})

onUnmounted(() => {
  if (resizeObserver) resizeObserver.disconnect()
})
</script>

<style scoped>
.virtual-scroll-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}
.virtual-list-container {
  height: 400px;
  overflow-y: auto;
  border: 2px solid #ccc;
  border-radius: 4px;
  position: relative;
}
.virtual-list-phantom {
  position: relative;
  width: 100%;
}
.virtual-list-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}
.virtual-list-item {
  padding: 10px 16px;
  border-bottom: 1px solid #eee;
  background: white;
}
.virtual-list-item:hover {
  background: #f5f5f5;
}
.item-content {
  margin-top: 5px;
  color: #666;
  font-size: 14px;
  word-break: break-all;
}
.loading-tip {
  text-align: center;
  padding: 10px;
  color: #999;
  font-size: 14px;
}
</style>

六、常见问题

1. translateY 的作用是什么?

contentabsolute 定位在 phantom 内部的。phantom 随容器滚动上移了 scrollTop,所以 translateY(offset) 需要把 content 推到 startIndex * itemHeight 的位置,让 item 在视口中出现在正确位置。

2. Buffer 的作用是什么?

Buffer 是额外多渲染的几项,藏在视口外。当用户快速滚动时,buffer 项可以立即顶上,避免白屏。

3. 为什么 offset = startIndex * ITEM_HEIGHT

因为 startIndex 包含了 buffer,content 块的第一项是 buffer 项。translateY(startIndex * itemHeight) 把 buffer 项推到正确位置,buffer 项在视口上方被裁掉,可见项从视口顶部开始正确显示。

4. Better-Scroll 为什么要用 probeType: 3

probeType: 2 在惯性滚动(momentum)动画期间不会 触发 scroll 事件,只有 probeType: 3 才会在每一帧都触发,保证虚拟滚动在惯性滚动时也能正确更新。