# 虚拟滚动 -- vue实现方式

一、元素固定高度的虚拟列表

<!-- 使用页面 page.vue -->
<template>
  <FixedSizeList
    className="list"
    :height="200"
    :width="200"
    :itemSize="50"
    :itemCount="1000"
  />
</template>
<script setup>
import FixedSizeList from './components/FixedSizeList.vue'
</script>
<!-- 容器组件 FixedSizeList.vue-->
<script setup>
import { ref } from 'vue'
import Row from './Row.vue'
const props = defineProps(['height', 'width', 'itemSize', 'itemCount'])
const scrollOffset = ref(0)

// 外部容器高度
const containerStyle = {
  position: 'relative',
  width: props.width + 'px',
  height: props.height + 'px',
  overflow: 'auto',
}

// 1000个元素撑起盒子的实际高度
const contentStyle = {
  height: props.itemSize * props.itemCount + 'px',
  width: '100%',
}

const getCurrentChildren = () => {
  // 可视区起始索引
  const startIndex = Math.floor(scrollOffset.value / props.itemSize)

  // 上缓冲区起始索引
  const finialStartIndex = Math.max(0, startIndex - 2)

  // 可视区能展示的元素的最大个数
  const numVisible = Math.ceil(props.height / props.itemSize)

  // 下缓冲区结束索引
  const endIndex = Math.min(props.itemCount - 1, startIndex + numVisible + 2)

  const items = []
  // 根据上面计算的索引值,不断添加元素给container
  for (let i = finialStartIndex; i < endIndex; i++) {
    const itemStyle = {
      position: 'absolute',
      height: props.itemSize + 'px',
      width: '100%',
      // 计算每个元素在container中的top值
      top: props.itemSize * i + 'px',
      idx: i,
    }
    items.push(itemStyle)
  }
  return items
}

const currentChildren = ref([])
currentChildren.value = getCurrentChildren()
// 当触发滚动就重新计算
const scrollHandle = (event) => {
  const { scrollTop } = event.currentTarget
  scrollOffset.value = scrollTop
  currentChildren.value = getCurrentChildren()
}
</script>

<template>
  <div :style="containerStyle" :onScroll="scrollHandle">
    <div :style="contentStyle">
      <Row
        v-for="item in currentChildren"
        :key="item.idx"
        :index="item.idx"
        :style="item"
      />
    </div>
  </div>
</template>

<!-- 内容组件 Row.vue-->
<script setup>
// 一、元素固定高度的虚拟列表
const props = defineProps(['index', 'style'])
</script>

<template>
  <div :style="style">
    {{ `${index} 🥇 ` }}
  </div>
</template>

二、元素不定高度的虚拟列表

<!-- 使用页面 page.vue -->
<template>
  <VariableSizeList
    className="list"
    :height="200"
    :width="200"
    :itemSize="getItemSize"
    :itemCount="1000"
  />
</template>
<script setup>
import FixedSizeList from './components/FixedSizeList.vue'
const rowSizes = new Array(1000)
  .fill(true)
  .map(() => 25 + Math.round(Math.random() * 55))
const getItemSize = (index) => rowSizes[index]
</script>


<!-- 容器组件 FixedSizeList.vue-->
<script setup>
import { ref } from 'vue'
import Row from './Row.vue'

// 元数据
const measuredData = {
  measuredDataMap: {},
  lastMeasuredItemIndex: -1,
}

const estimatedHeight = (defaultEstimatedItemSize = 50, itemCount) => {
  let measuredHeight = 0
  const { measuredDataMap, lastMeasuredItemIndex } = measuredData
  // 计算已经获取过真实高度的项的高度之和
  if (lastMeasuredItemIndex >= 0) {
    const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex]
    measuredHeight = lastMeasuredItem.offset + lastMeasuredItem.size
  }
  // 未计算过真实高度的项数
  const unMeasuredItemsCount =
    itemCount - measuredData.lastMeasuredItemIndex - 1
  // 预测总高度
  const totalEstimatedHeight =
    measuredHeight + unMeasuredItemsCount * defaultEstimatedItemSize
  return totalEstimatedHeight
}

const getItemMetaData = (props, index) => {
  const { itemSize } = props
  const { measuredDataMap, lastMeasuredItemIndex } = measuredData
  // 如果当前索引比已记录的索引要大,说明要计算当前索引的项的size和offset
  if (index > lastMeasuredItemIndex) {
    let offset = 0
    // 计算当前能计算出来的最大offset值
    if (lastMeasuredItemIndex >= 0) {
      const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex]
      offset += lastMeasuredItem.offset + lastMeasuredItem.size
    }
    // 计算直到index为止,所有未计算过的项
    for (let i = lastMeasuredItemIndex + 1; i <= index; i++) {
      const currentItemSize = itemSize(i)
      measuredDataMap[i] = { size: currentItemSize, offset }
      offset += currentItemSize
    }
    // 更新已计算的项的索引值
    measuredData.lastMeasuredItemIndex = index
  }
  return measuredDataMap[index]
}

const getStartIndex = (props, scrollOffset) => {
  let index = 0
  while (true) {
    const currentOffset = getItemMetaData(props, index).offset
    if (currentOffset >= scrollOffset) return index
    index++
  }
}

const getEndIndex = (props, startIndex) => {
  const { height } = props
  // 获取可视区内开始的项
  const startItem = getItemMetaData(props, startIndex)
  // 可视区内最大的offset值
  const maxOffset = startItem.offset + height
  // 开始项的下一项的offset,之后不断累加此offset,知道等于或超过最大offset,就是找到结束索引了
  let offset = startItem.offset + startItem.size
  // 结束索引
  let endIndex = startIndex
  // 累加offset
  while (offset <= maxOffset) {
    endIndex++
    const currentItem = getItemMetaData(props, endIndex)
    offset += currentItem.size
  }
  return endIndex
}

const getRangeToRender = (props, scrollOffset) => {
  const { itemCount } = props
  const startIndex = getStartIndex(props, scrollOffset)
  const endIndex = getEndIndex(props, startIndex)
  return [
    Math.max(0, startIndex - 2),
    Math.min(itemCount - 1, endIndex + 2),
    startIndex,
    endIndex,
  ]
}

const props = defineProps([
  'height',
  'width',
  'itemCount',
  'itemSize',
  'itemEstimatedSize',
])

const scrollOffset = ref(0)

// 外部容器高度
const containerStyle = {
  position: 'relative',
  width: props.width + 'px',
  height: props.height + 'px',
  overflow: 'auto',
  willChange: 'transform',
}

// 1000个元素撑起盒子的实际高度
const contentStyle = {
  height: estimatedHeight(props.itemEstimatedSize, props.itemCount) + 'px',
  width: '100%',
}

const getCurrentChildren = () => {
  const [startIndex, endIndex, originStartIndex, originEndIndex] =
    getRangeToRender(props, scrollOffset.value)
  const items = []
  // 根据上面计算的索引值,不断添加元素给container
  for (let i = startIndex; i < endIndex; i++) {
    const item = getItemMetaData(props, i)
    const itemStyle = {
      position: 'absolute',
      height: item.size + 'px',
      width: '100%',
      top: item.offset + 'px',
      idx: i,
    }
    items.push(itemStyle)
  }
  return items
}

const currentChildren = ref([])
currentChildren.value = getCurrentChildren()
// 当触发滚动就重新计算
const scrollHandle = (event) => {
  const { scrollTop } = event.currentTarget
  scrollOffset.value = scrollTop

  currentChildren.value = getCurrentChildren()
}
</script>

<template>
  <div id="container" :style="containerStyle" :onScroll="scrollHandle">
    <div id="content" :style="contentStyle">
      <Row
        v-for="item in currentChildren"
        :key="item.idx"
        :index="item.idx"
        :style="item"
      />
    </div>
  </div>
</template>

<!-- 内容组件 Row.vue-->
<script setup>
// 一、元素固定高度的虚拟列表
const props = defineProps(['index', 'style'])
</script>

<template>
  <div :style="style">
    {{ `${index} 🥇 ` }}
  </div>
</template>

三、元素动态高度的虚拟列表

<!-- 使用页面 page.vue -->
<template>
  <FixedSizeListPlus
    class="list"
    :height="200"
    :width="200"
    :itemCount="items.length"
    :raw="getOneOfData"
  />
</template>

<script setup>
import FixedSizeListPlus from './components/FixedSizeListPlus.vue'
function getData(fixedHeight = 50) {
  const textStr = `谁说简历没亮点?来了,虚拟滚动的3种实现方式~ React实现方式`
  const items = []
  const itemCount = 1000
  for (let i = 0; i < itemCount; i++) {
    const style = {
      width: '100%',
      height: fixedHeight,
    }
    items.push({
      style: style,
      i,
      value: textStr.slice(parseInt(Math.random() * 30)),
    })
  }
  return items
}

const items = getData()

const getOneOfData = ({ index }) => {
  return items[index]
}
</script>


<!-- 容器组件 FixedSizeListPlus.vue-->
<script setup>
import { ref, onMounted } from 'vue'
import Row from './Row.vue'

// 元数据
const measuredData = {
  measuredDataMap: {},
  lastMeasuredItemIndex: -1,
}

const estimatedHeight = (defaultEstimatedItemSize = 50, itemCount) => {
  let measuredHeight = 0
  const { measuredDataMap, lastMeasuredItemIndex } = measuredData
  // 计算已经获取过真实高度的项的高度之和
  if (lastMeasuredItemIndex >= 0) {
    const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex]
    measuredHeight = lastMeasuredItem.offset + lastMeasuredItem.size
  }
  // 未计算过真实高度的项数
  const unMeasuredItemsCount =
    itemCount - measuredData.lastMeasuredItemIndex - 1
  // 预测总高度
  const totalEstimatedHeight =
    measuredHeight + unMeasuredItemsCount * defaultEstimatedItemSize
  return totalEstimatedHeight
}

const getItemMetaData = (props, index) => {
  const { itemSize } = props
  const { measuredDataMap, lastMeasuredItemIndex } = measuredData
  // 如果当前索引比已记录的索引要大,说明要计算当前索引的项的size和offset
  if (index > lastMeasuredItemIndex) {
    let offset = 0
    // 计算当前能计算出来的最大offset值
    if (lastMeasuredItemIndex >= 0) {
      const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex]
      offset += lastMeasuredItem.offset + lastMeasuredItem.size
    }
    // 计算直到index为止,所有未计算过的项
    for (let i = lastMeasuredItemIndex + 1; i <= index; i++) {
      const currentItemSize = itemSize ? itemSize(i) : 50
      measuredDataMap[i] = { size: currentItemSize, offset }
      offset += currentItemSize
    }
    // 更新已计算的项的索引值
    measuredData.lastMeasuredItemIndex = index
  }
  return measuredDataMap[index]
}

const getStartIndex = (props, scrollOffset) => {
  let index = 0
  while (true) {
    const currentOffset = getItemMetaData(props, index).offset
    if (currentOffset >= scrollOffset) return index
    index++
  }
}

const getEndIndex = (props, startIndex) => {
  const { height } = props
  // 获取可视区内开始的项
  const startItem = getItemMetaData(props, startIndex)
  // 可视区内最大的offset值
  const maxOffset = startItem.offset + height
  // 开始项的下一项的offset,之后不断累加此offset,知道等于或超过最大offset,就是找到结束索引了
  let offset = startItem.offset + startItem.size
  // 结束索引
  let endIndex = startIndex
  // 累加offset
  while (offset <= maxOffset) {
    endIndex++
    const currentItem = getItemMetaData(props, endIndex)
    offset += currentItem.size
  }
  return endIndex
}

const getRangeToRender = (props, scrollOffset) => {
  const { itemCount } = props
  const startIndex = getStartIndex(props, scrollOffset)
  const endIndex = getEndIndex(props, startIndex)
  return [
    Math.max(0, startIndex - 2),
    Math.min(itemCount - 1, endIndex + 2),
    startIndex,
    endIndex,
  ]
}

const props = defineProps([
  'height',
  'width',
  'itemCount',
  'itemSize',
  'itemEstimatedSize',
  'raw',
])

const scrollOffset = ref(0)

// 外部容器高度
const containerStyle = {
  position: 'relative',
  width: props.width + 'px',
  height: props.height + 'px',
  overflow: 'auto',
  willChange: 'transform',
}

// 1000个元素撑起盒子的实际高度
const contentStyle = {
  height: estimatedHeight(props.itemEstimatedSize, props.itemCount) + 'px',
  width: '100%',
}

const getCurrentChildren = () => {
  const [startIndex, endIndex, originStartIndex, originEndIndex] =
    getRangeToRender(props, scrollOffset.value)
  const items = []
  // 根据上面计算的索引值,不断添加元素给container
  for (let i = startIndex; i < endIndex; i++) {
    const item = getItemMetaData(props, i)
    const itemStyle = {
      position: 'absolute',
      width: '100%',
      top: item.offset + 'px',
      idx: i,
    }
    items.push(itemStyle)
  }
  return items
}

const currentChildren = ref([])
currentChildren.value = getCurrentChildren()

// =============与不等高的区别:👇===============
onMounted(() => {
  currentChildren.value = getCurrentChildren()
})

// 增加 sizeChangeHandle:渲染新的内容时,更新当前dom的高度,并重新计算所有的偏移量offset
const sizeChangeHandle = (index, domNode) => {
  const height = domNode.offsetHeight
  const { measuredDataMap, lastMeasuredItemIndex } = measuredData

  const itemMetaData = measuredDataMap[index]
  itemMetaData.size = height

  let offset = 0
  for (let i = 0; i <= lastMeasuredItemIndex; i++) {
    const itemMetaData = measuredDataMap[i]
    itemMetaData.offset = offset
    offset += itemMetaData.size
  }
}
// ==============与不等高的区别 end:===============

// 当触发滚动就重新计算
const scrollHandle = (event) => {
  const { scrollTop } = event.currentTarget
  scrollOffset.value = scrollTop

  currentChildren.value = getCurrentChildren()
}
</script>

<template>
  <div id="container" :style="containerStyle" @scroll="scrollHandle">
    <div id="content" :style="contentStyle">
      <Row
        v-for="item in currentChildren"
        :key="item.idx"
        :index="item.idx"
        :style="item"
        :onSizeChange="sizeChangeHandle"
        :raw="raw"
      />
    </div>
  </div>
</template>

<style scoped>
#container #content > div:nth-child(odd) {
  background: rgba(90, 118, 211, 0.5);
}
</style>

<!-- 内容组件 Row.vue-->
<script setup>
// 一、元素固定高度的虚拟列表
const props = defineProps(['index', 'style', 'raw', 'onSizeChange'])

// =============与不等高的区别:👇===============
const domRef = ref(null)
onMounted(() => {
  const { index, onSizeChange } = props
  onSizeChange?.(index, domRef.value)
})
// =============与不等高的区别:end===============
</script>

<template>
  <div ref="domRef" :style="style">
    {{ `${index} 🥇 ${raw?.({ index }).value || ''}` }}
  </div>
</template>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值