Skip to content
On this page

虚拟滚动

虚拟滚动
主要解决数据量较大时,滚动加载的问题。

效果

思路

主要是通过 start/end 不断的切割数据,然后渲染到页面中。

页面布局如下图所示:

INFO

滚动条的高度是 应该渲染所有数据的真实高度, 里面的内容高度是固定的,只是通过不断的切割数据去填充页面,dom节点的数量是不变的。


难点就在于确定 start/end 的值。

当 item 的高度已知时


基础

比如我要渲染 8 个item

  • 初始化时:

初始值 为 start = 0,end = 8,那么 data源数据.slice(0,8)

  • 当用户滚动滚动条时:

获取滚动高度,start = 滚动高度/item高度,end = start + 8, data 此时为 源数据.slice(start,end)
通过不断的滚动,不断的更新start/end

用代码简单表示为:

ts
// 单个 item的高度
const size = ref(40)
const start = ref(0)
const end = computed(()=>start + 8);
onMounted(() => {
    // 内容区域的高度,渲染 8 个 item
  viewport.value!.style.height = 8 * size.value + 'px'
    // 设置滚动条的高度,这样才能滚动
  scrollBar.value!.style.height = originData.length * size.value + 'px'
})
// 发生滚动时
viewport.value.addEventListener("scroll",function(){
  let scrollTop = viewport.value!.scrollTop;
  start.value = Math.floor(scrollTop / size.value)
 })
// 渲染到页面的数据
const visiableData = computed(() => {
  return originData.slice(start.value,end.value)
})

此时虽然可以运行,但是为了保证用户的体验性,我们需要在页面上加一些前景数据和尾部预留数据,增大预留面积,使得滚动时不会有空白数据

优化

定义前景预留数据个数和尾部预留数据个数

ts
// 前景预留数据个数
const prevCount = computed(() => {
  return Math.min(start.value, 8)
})

// 尾部预留数据个数
const nextCount = computed(() => {
  return Math.min(end.value, originData.length - end.value)
})

那么此时的数据切割就变成了

ts
// 增大预留面积
  const visiableData = computed(() => {
  // start 值往前移动
  // end 值往后移动
  let _start = start.value - prevCount.value
  let _end = end.value + nextCount.value
  return originData.slice(_start, _end)
})

由于数据量变大,要修改 dom 结构,内容区域改为 绝对定位,通过改变 top 值来控制显示数据

vue
<div class="absolute top-0 w-full" :style="{ top: offset + 'px' }">
        
</div>
<script>
offset.value = start.value * size.value - prevCount.value * size.value;
</script>

当item 的高度不定时

虽然高度不定,但是要给出一个大致的高度做一个初渲染,等到真实dom渲染后,再动态的改变高度

缓存高度数据

先临时记录 height/top/bottom 数据

ts
const size = ref(40)
let positions = reactive<any[]>([])
  onMounted(() => {
    positions =  originData.map((_, index) => ({
      height: size.value,
      top: index * size.value,
      bottom: (index + 1) * size.value
    }))
  })

当发生改变时,会触发 onUpdate 方法,此时我们需要真实元素的大小位置,并且更改positions 中的数据

更新位置

onUpdated 回调中, 使用 getBoundingClientRect 获取元素的高度,并更改 positions 中的数据

ts
const updatePosition = async () => {
  let nodes = itemsRefNode.value
  nodes && nodes.length && nodes.forEach((node) => {
    // 最新的高度
    let { height } = node.getBoundingClientRect()
    // 元素的唯一标识 id
    let id = + (node.getAttribute("vid") ?? 0) - 0;
    // 原来的高度
    let oldHeight = positions[id].height;
    // 高度差
    let diffHeight = oldHeight - height
    if (diffHeight) {
      // 更新高度
      positions[id].height = height
      positions[id].bottom = positions[id].bottom - diffHeight 
      // 后面所有人都需要增加高度
      for (let i = id + 1; i < positions.length; i++) {
        // 上一个的bottom 就是下一个的 top
        positions[i].top = positions[i - 1].bottom
        positions[i].bottom = positions[i].bottom - diffHeight
      }
    }
  })
  scrollBar.value!.style.height = positions[positions.length - 1].bottom + 'px'
}

源码

vue
<template>
  <!-- 视口 -->
  <div>
    <div class="relative overflow-y-auto rounded-md mx-auto h-auto bg-red-100" ref="viewport" @scroll="handleScroll">
      <!-- 滚动条 -->
      <div ref="scrollBar"></div>
      <!-- 真实位置 -->
      <div class="absolute top-0 w-full" :style="{ top: offset + 'px' }">
        <div class="bg-blue-400 text-white text-center py-4 mt-2 first:mt-0 rounded" v-for="(item, index) in visiableData"
          :index="item.id" :vid="item.id" ref="itemsRefNode">
          {{ item.value }}
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, nextTick, onMounted, onUpdated, PropType, reactive, ref } from 'vue';
import Mock from "mockjs";
const start = ref(0)
const end = computed(() => start.value + 8)
const offset = ref(0)
const size = ref(40)

const originData = Array.from({ length: 60 }, (_, i) => ({
  id: i,
  value: Mock.mock('@cparagraph()')
}))

const virable = ref(false);
const visiableData = computed(() => {
  // 增大预留面积
  // start 值往前移动
  // end 值往后移动
  let _start = start.value - prevCount.value
  let _end = end.value + nextCount.value
  return originData.slice(_start, _end)
})

/**
 * @description 前景预留
 */
const prevCount = computed(() => {
  return Math.min(start.value, 8)
})

/**
 * @description 尾部预留
 * @description 如果 传入 的 items 的数量小于 要 预留(remain)的个数,使用 预留个数
 */
const nextCount = computed(() => {
  return Math.min(end.value, originData.length - end.value)
})

const itemsRefNode = ref<HTMLDivElement[] | null>(null)
const scrollBar = ref<HTMLDivElement | null>(null)

let positions = reactive<any[]>([])

const updatePosition = async () => {
  let nodes = itemsRefNode.value
  nodes && nodes.length && nodes.forEach((node) => {
    let { height } = node.getBoundingClientRect()
    let id = + (node.getAttribute("vid") ?? 0) - 0;

    let oldHeight = positions[id].height;
    let diffHeight = oldHeight - height
    if (diffHeight) {
      positions[id].height = height
      positions[id].bottom = positions[id].bottom - diffHeight // 顶部增加了
      // 后面所有人都需要增加高度
      for (let i = id + 1; i < positions.length; i++) {
        positions[i].top = positions[i - 1].bottom
        positions[i].bottom = positions[i].bottom - diffHeight
      }
    }
  })
  scrollBar.value!.style.height = positions[positions.length - 1].bottom + 'px'
}

onUpdated(() => {
  if (!virable.value) return
  updatePosition()
})

const getIndex = (value: number) => {

  let start = 0, end = positions.length - 1, temp: null | number = null;

  // 二分法
  while (start < end) {
    let middleIndex = parseInt(String((start + end) / 2))
    let middleValue = positions[middleIndex].bottom
    if (middleValue == value) {
      return middleIndex
    } else if (middleValue < value) {
      start = middleIndex + 1
    } else {
      /**    
       *  @examle [1,2,5,6,10,20,50] value = 40 ,返回 50 
       *  
       */
      if (temp == null || temp > middleIndex) {
        temp = middleIndex  // 找到范围
      }
      end = middleIndex - 1
    }
  }
  return temp
}

const viewport = ref<HTMLDivElement | null>(null)

const handleScroll = () => {
  let scrollTop = viewport.value!.scrollTop;
  if (virable.value) {
    // 滚动的距离,计算需要从哪个 item 开始
    start.value = getIndex(scrollTop) || 0

    offset.value = positions[start.value - prevCount.value] ? positions[start.value - prevCount.value].top : 0;

  } else {
    start.value = Math.floor(scrollTop / size.value)
    // 需要把预留出来的偏移量 减去
    // 因为滚动的时候 start 提前了,会有一段时间重复数据
    offset.value = start.value * size.value - prevCount.value * size.value;
  }
}

const cacheList = () => {
  // 先暂时记录一个 缓存高度数组列表
  positions = originData.map((_, index) => ({
    height: size.value,
    top: index * size.value,
    bottom: (index + 1) * size.value
  }))
}


onMounted(() => {
  // 视口高度 是  视口的items 个数 * 每一个的高度  大约值
  viewport.value!.style.height = 8 * size.value + 'px'
  // 设置滚动条的高度,这样才能滚动
  scrollBar.value!.style.height = originData.length * size.value + 'px'
  if (virable.value) {
    cacheList()
  }
})
</script>