长数据列表虚拟滚动实现以及注意事项

108 阅读4分钟

在现代 Web 应用中,长列表或海量数据渲染是常见场景,如商品列表、消息列表或后台表格。直接渲染全部数据会导致页面卡顿甚至浏览器崩溃。虚拟滚动应运而生,通过只渲染可视区域的 DOM 元素,实现高性能渲染。本文旨在帮助开发者理解虚拟滚动的原理、掌握实现流程,并在实际项目中提升页面性能与用户体验。

下面我将会阐述实现虚拟滚动的一些步骤和注意事项

虚拟滚动的实现步骤

再次之前我们可以将基础样式写好并且旁边加上一个调试栏目 test_page.png

1. 准备数据与容器

1.1 获取完整数据列表

即将需要渲染的数据放进一个数组中,后续增添数据可以直接push进来,初始仅添加测试数据的状态如下

1.2 确定每个卡片项高度itemHeight
const calcItemHeight = (item, itemWidth) => {
  // 基础高度
  let height = 200 // 图片高度
  
  // 根据文本内容计算额外高度
  const titleHeight = Math.ceil((item.book_name || '').length / 20) * 20 + 20
  const authorHeight = 20
  const typeHeight = 20
  const priceHeight = 24
  const descriptionHeight = Math.ceil((item.book_description || '').length / 30) * 16 + 20
  const sellerHeight = 16
  const statusHeight = 20
  
  height += titleHeight + authorHeight + typeHeight + priceHeight + descriptionHeight + 
            sellerHeight + statusHeight + 50 // padding
  
  return Math.max(height, 320) // 最小高度320px
}
1.3 获取滚动容器的高度containerHeight

用ref获取到容器dom

  <div class="virtual-scroller-container" ref="containerRef">
    <!-- 容器内容 -->
  </div>
</template>

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

// 容器引用
const containerRef = ref(null)

// 滚动处理
const handleScroll = () => {
  if (!containerRef.value) return
  
  const { scrollTop: currentScrollTop, scrollHeight: currentScrollHeight, clientHeight } = containerRef.value
  
  // 更新调试信息
  scrollTop.value = currentScrollTop
  scrollHeight.value = currentScrollHeight
  containerHeight.value = clientHeight
  
  console.log('滚动位置:', currentScrollTop, '总高度:', currentScrollHeight, '可视高度:', clientHeight)
  
  // 检查是否需要加载更多数据
  if (currentScrollHeight - (currentScrollTop + clientHeight) < 100 && hasMore.value && !loading.value) {
    console.log('滚动触底,开始加载更多数据')
    loadMore()
  }
}
</script>
1.4 创建 占位容器,高度 = itemHeight * itemCount,用于模拟整个列表
  <!-- 虚拟滚动占位器 -->
  <div class="virtual-placeholder" :style="{ height: `${itemHeight * items.length}px` }"></div>
  
  <!-- 实际渲染的可视区域项 -->
  <div class="visible-items" :style="{ transform: `translateY(${startOffset}px)` }">
    <!-- 渲染可视区域的项 -->
    <div v-for="item in visibleItems" :key="item.id" class="item">
      <!-- 项内容 -->
    </div>
  </div>
</template>

2.计算可视区域索引

2.1获取当前滚动位置

计算位置并预更新数据

const handleScroll = () => {
  if (!containerRef.value) return
  
  const { scrollTop: currentScrollTop, scrollHeight: currentScrollHeight, clientHeight } = containerRef.value
  
  // 更新调试信息
  scrollTop.value = currentScrollTop  // 当前滚动位置
  scrollHeight.value = currentScrollHeight
  containerHeight.value = clientHeight
  
  console.log('滚动位置:', currentScrollTop, '总高度:', currentScrollHeight, '可视高度:', clientHeight)
  
  // 检查是否需要加载更多数据
  if (currentScrollHeight - (currentScrollTop + clientHeight) < 100 && hasMore.value && !loading.value) {
    console.log('滚动触底,开始加载更多数据')
    loadMore()
  }
}

2.2计算可视区域开始索引

起始索引是向下取整的,结束索引是向上取整的,即高度小于一张卡片的高度时视作一张

const startIndex = Math.floor(scrollTop / itemHeight)

// 计算可视区域结束索引
const endIndex = Math.min(startIndex + Math.ceil(containerHeight / itemHeight), items.length - 1)

2.3计算可视条数

const visibleCount = Math.ceil(containerHeight / itemHeight)

2.4增加缓冲条数 buffer,防止快速滚动出现空白

const buffer = 5  // 缓冲条数
const bufferedStartIndex = Math.max(0, startIndex - buffer)
const bufferedEndIndex = Math.min(items.length - 1, endIndex + buffer)

3.提取可渲染数据

const visibleItems = items.slice(bufferedStartIndex, bufferedEndIndex + 1)

// 计算偏移量
const startOffset = bufferedStartIndex * itemHeight

4.绑定滚动事件

onMounted(() => {
  fetchData(1)
  nextTick(() => {
    setupIntersectionObserver()
    // 添加滚动监听器作为备用
    if (containerRef.value) {
      containerRef.value.addEventListener('scroll', handleScroll)
    }
    // 测试滚动功能
    console.log('容器高度:', containerRef.value?.clientHeight)
    console.log('容器滚动高度:', containerRef.value?.scrollHeight)
    
    // 定期更新渲染数量
    setInterval(updateRenderedCount, 1000)
  })
})

onUnmounted(() => {
  if (observer.value) {
    observer.value.disconnect()
  }
  if (containerRef.value) {
    containerRef.value.removeEventListener('scroll', handleScroll)
  }
})

成品完成示例图如下,可以看到渲染卡片比例仅占20%,假如继续下滑滚轮获取更多数据,比例还会进一步降低,因为我们只渲染了可见范围(视窗+缓冲)部分的卡片,极大的节省了性能,降低了开销 final_page.png

5.高级优化建议

5.1 使用 Intersection Observer API 作为滚动事件的补充:

  <div ref="scrollContainer" class="scroll-container">
    <div :style="{ height: totalHeight + 'px', position: 'relative' }">
      <div
        v-for="item in visibleData"
        :key="item.id"
        :style="{ position: 'absolute', top: item.top + 'px', height: itemHeight + 'px' }"
      >
        {{ item.name }}
      </div>
      <!-- 底部观察器 -->
      <div ref="bottomObserver" class="bottom-observer"></div>
    </div>
  </div>
</template>

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

const bottomObserver = ref(null)
const scrollContainer = ref(null)

const hasMore = ref(true)
const loading = ref(false)

function loadMore() {
  if (!hasMore.value || loading.value) return
  loading.value = true
  console.log('触底加载更多数据...')
  setTimeout(() => {
    loading.value = false
  }, 1000)
}

let observer = null

onMounted(() => {
  observer = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting && hasMore.value && !loading.value) {
        loadMore()
      }
    },
    {
      root: scrollContainer.value,
      threshold: 0.1 // 当底部元素 10% 进入可视区时触发
    }
  )
  observer.observe(bottomObserver.value)
})

onUnmounted(() => {
  observer?.disconnect()
})
</script>

<style>
.scroll-container {
  height: 500px;
  overflow-y: auto;
  border: 1px solid #ccc;
}
.bottom-observer {
  height: 1px;
}
</style>

 

5.2 动态高度计算:对于内容高度不固定的项,实现动态高度计算和缓存。

  <div ref="scrollContainer" class="scroll-container" @scroll="handleScroll">
    <div :style="{ height: totalHeight + 'px', position: 'relative' }">
      <div
        v-for="(item, i) in visibleData"
        :key="item.id"
        ref="itemRefs"
        :data-index="startIndex + i"
        class="item"
        :style="{ position: 'absolute', top: itemPositions[startIndex + i] + 'px' }"
      >
        {{ item.text }}
      </div>
    </div>
  </div>
</template>

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

const data = ref(Array.from({ length: 1000 }, (_, i) => ({ id: i, text: '内容' + i })))
const scrollContainer = ref(null)
const itemRefs = ref([])
const itemHeights = reactive({}) // 缓存每个元素高度
const itemPositions = reactive({}) // 累积偏移表
const containerHeight = 500

const scrollTop = ref(0)
const itemCount = data.value.length

// 动态计算高度与偏移
function recalcPositions() {
  let acc = 0
  for (let i = 0; i < itemCount; i++) {
    const height = itemHeights[i] || 40 // 默认高度
    itemPositions[i] = acc
    acc += height
  }
}

// 可视数据计算
const visibleData = ref([])
let startIndex = 0

function updateVisible() {
  let accHeight = 0
  for (let i = 0; i < itemCount; i++) {
    accHeight += itemHeights[i] || 40
    if (accHeight >= scrollTop.value) {
      startIndex = i
      break
    }
  }
  visibleData.value = data.value.slice(startIndex, startIndex + 20)
}

// 滚动事件
const handleScroll = () => {
  scrollTop.value = scrollContainer.value.scrollTop
  updateVisible()
}

onMounted(async () => {
  await nextTick()
  // 动态测量元素高度
  itemRefs.value.forEach((el) => {
    const index = Number(el.dataset.index)
    itemHeights[index] = el.offsetHeight
  })
  recalcPositions()
  updateVisible()
})
</script>

<style>
.scroll-container {
  height: 500px;
  overflow-y: auto;
  border: 1px solid #ccc;
}
.item {
  background: #f6f8fa;
  margin-bottom: 8px;
  padding: 8px;
}
</style>

5.3 防抖处理:对滚动事件进行防抖处理,避免频繁计算。

export function debounce(fn, delay = 100) {
  let timer = null
  return function (...args) {
    clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, args)
    }, delay)
  }
}