Vue3实现定高虚拟列表、不定高虚拟列表

224 阅读9分钟

背景

实习中,某个组件直接请求了大量数据时,渲染了巨多的 dom 节点,造成的界面卡顿,需要进行性能优化。

原理

通过js计算,只渲染指定可视区域内的dom节点

实现方案

  • 通过监听滚动,仅实时渲染当前视口中,应该展示的数据
  • 通过 css 的 translateY ,修正,当实际渲染的数据发生变化时,渲染区域与可视区域偏移的问题
  • 加入缓冲数据,缓解白屏问题

相关概念

  • 渲染区:真实数据 dom 节点构成的区域
  • 视口区:可见区域
  • 滚动容器:会出现滚动条的 dom 节点

定高虚拟列表

初始化DOM结构

定高虚拟列表
<script lang="ts">
export default {
  name: 'StaticItemHeightVersion01',
}
</script>
<script setup lang="ts">
import { computed, onMounted, ref, toRefs } from 'vue'
const props = withDefaults(
  defineProps<{
    /**
     * 每一项的高度
     */
    itemHeight?: number
  }>(),
  {
    itemHeight: 120,
  }
)

const { itemHeight } = toRefs(props)

/**
 * 柱子节点高度: `总数据量*每一项的高度`
 *
 * 用于撑开滚动容器的高度
 */
const pillarDomHeight = computed<number>(() => {
  return itemHeight.value * allData.value.length
})
/**
 * 所有数据
 */
const allData = ref<string[]>([])
/**
 * 内容容器的y轴偏移量。当渲染区域第一个元素完全移到了可视区域之外时,需要重新计算startOffset,将第一个元素移动回可视区域
 */
const startOffset = ref<number>(0)
const styleTranslate = computed<string>(() => {
  return `transform:translate(0,${startOffset.value}px)`
})
/**
 * 当前视口第一个数据在allData数组的索引位置. 默认:0
 */
const start = ref<number>(0)
/**
 * 当前视口最后一个数据在allData数组的索引位置
 */
const end = computed(() => {
  return start.value + pageItemCount.value
})
/**
 * 当前视口需要显示的数据
 */
const renderData = computed<string[]>(() => {
  // 避免最后一个元素的数组下标超出实际的数组长度
  const realEnd = Math.min(end.value, allData.value.length)
  return allData.value.slice(start.value, realEnd)
})
/**
 * 滚动容器. 支持显示滚动条的容器。确定虚拟列表的可视区高度
 */
const scrollerContainerRef = ref<HTMLDivElement>()
/**
 * 滚动容器高度。采用计算属性方式动态获取滚动容器高度
 */
const scrollerContainerRefHeight = computed(() => {
  return scrollerContainerRef.value ? scrollerContainerRef.value.offsetHeight : 0
})
/**
 * 视口可显示的元素数量: 滚动容器高度/每一项的高度,然后对结果进行向上取整,然后再+1
 *
 * 为什么要进行向上取整?
 * 如:页面高度100px,单个元素30px,那么此时100/30等于3,还多了10px,那这10px实际应该显示第4个元素的一小部分,所以需要进行向上取整
 *
 * 为什么最后还要+1?
 * 如:页面高度100px,单个元素30px,根据向上取整的方式,我们已经将这个视口渲染出了4个元素,第4个元素只有10px在视口中,剩余20px在视口之外。
 * 如果此时我们拖动滚动条,拖动25px,此时第一个元素尚未完全移出视口,最后一个元素完全进入了视口,且还有5px空白。按照通常的想法,这里应该显示第5个元素的一小部分才对。
 * 因此,最后还需要+1
 */
const pageItemCount = computed<number>(() => {
  return Math.ceil(scrollerContainerRefHeight.value / itemHeight.value) + 1
})

/**
 * 模拟后端大数据获取,模拟网络延迟
 */
function loadData() {
  return new Promise<string[]>(resolve => {
    const tmpList: string[] = []
    for (let i = 0; i < 100000; i++) {
      tmpList.push(`数据${i}`)
    }
    setTimeout(() => {
      resolve(tmpList)
    }, 5000)
  })
}

onMounted(() => {
  // 不直接在mounted中异步转同步,而是在init方法进行异步转同步。避免界面因为数据加载慢,导致渲染阻塞
  init()
})

async function init() {
  allData.value = await loadData()
}
</script>

<template>
  <!-- 实际开发中虚拟列表通常是位于某个dom容器下,并占满这个dom容器的整个高度,这里就是模拟这种情况 -->
  <div class="outContainer">
    <!-- scrollerContainer为支持滚动条的容器,定义整个虚拟列表的高度 -->
    <div class="scrollerContainer" ref="scrollerContainerRef">
      <div class="pillarDom" :style="{ height: `${pillarDomHeight}px` }"></div>
      <div class="contentList" :style="styleTranslate">
        <div class="item" v-for="oneData in renderData">{{ oneData }}</div>
      </div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.outContainer {
  height: 350px;
  width: 100%;
}
.scrollerContainer {
  height: 100%;
  width: 100%;
  overflow: auto;
  position: relative;
  -webkit-overflow-scrolling: touch;
}
.pillarDom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}
.contentList {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}
.item {
  height: calc(v-bind(itemHeight) * 1px);
  line-height: calc(v-bind(itemHeight) * 1px);
  border-bottom: 8px solid green;
  width: 100%;
  // 这里同样很重要,盒模型必须为border-box,item元素的高度才不会因为border值而超出设置的高度
  box-sizing: border-box;
  background-color: orange;
  &:last-child {
    border-bottom: none;
  }
}
</style>

监听scrollerContainer滚动使数据随着滚动发生变化

function onScroll(evt: UIEvent) 
{ // 获取触发滚动事件的元素 const scrollDom = evt.target as HTMLDivElement 
if (!scrollDom) return 
// 获取滚动的距离
const { scrollTop } = scrollDom 
// 根据滚动的距离,计算此时视口顶部需要显示的第一个元素 
start.value = Math.floor(scrollTop / itemHeight.value) 
}

当渲染区域的数据发生变化时,修正内容容器的偏移量,使渲染区域处于正确的视口位置

const startOffset = ref<number>(0) 
const styleTranslate = computed<string>(() => { return `transform:translate(0,${startOffset.value}px)` })

} function onScroll(evt: UIEvent) { 
// 获取触发滚动事件的元素 const scrollDom = evt.target as HTMLDivElement 
if (!scrollDom) return 
// 获取滚动的距离 const { scrollTop } = scrollDom 
// 根据滚动的距离,计算此时视口顶部需要显示的第一个元素 
start.value = Math.floor(scrollTop / itemHeight.value) 
startOffset.value = start.value * itemHeight.value 
}

这样一个基本的定高虚拟列表就完成了,但是在快速滚动的时候,出现了白屏现象,该怎么解决这个问题呢?

加入缓冲数据,缓解白屏现象

/** * 当前视口需要显示的数据 */ 
const renderData = computed<ContentType[]>(() => { 
// 前面多缓冲一屏(避免滚动到顶部时,数组下标小于0) 
let realStart = Math.max(0, start.value - pageItemCount.value) 
// 后面也多换从一屏(避免最后一个元素的数组下标超出实际的数组长度) 
const realEnd = Math.min(end.value + pageItemCount.value, allData.value.length) 
return allData.value.slice(realStart, realEnd) })

不定高虚拟列表

初始化DOM结构

<script lang="ts">
export default {
  name: 'DynItemHeightVersion01',
}
</script>
<script setup lang="ts">
import Mock from 'mockjs'
import { computed, onMounted, ref } from 'vue'

interface ContentType {
  id: number
  title: string
  content: string
}

interface VitrualItem<T> {
  /**
   * 数据
   */
  data: T
  /**
   * 当前数据处在allData数组的索引位置
   */
  arrPos: number
  /**
   * 当前数据dom的top位置
   */
  startPos: number
  /**
   * 当前数据dom的bottom位置
   */
  endPos: number
  /**
   * 当前数据dom的高度(初始值为猜测高度【预估高度】)
   */
  height: number
}
/**
 * 猜测高度(预估高度)
 */
const maybeHeight = 100
/**
 * 所有数据
 */
const allData = ref<VitrualItem<ContentType>[]>([])
/**
 * 柱子节点高度: allData最后一个元素的endPos值
 *
 * 用于撑开滚动容器的高度
 */
const pillarDomHeight = computed<number>(() => {
  return allData.value.length > 0 ? allData.value[allData.value.length - 1].endPos : 0
})
/**
 * 内容列表容器dom
 */
const contentListRef = ref<HTMLDivElement>()
/**
 * 滚动容器. 支持显示滚动条的容器。确定虚拟列表的可视区高度
 */
const scrollerContainerRef = ref<HTMLDivElement>()
/**
 * 滚动容器高度(视口高度)。采用计算属性方式动态获取滚动容器高度
 */
const scrollerContainerRefHeight = computed(() => {
  return scrollerContainerRef.value ? scrollerContainerRef.value.offsetHeight : 0
})
/**
 * 当前视口第一个数据在allData数组的索引位置. 默认:0
 */
const start = ref<number>(0)
/**
 * 当前视口最后一个数据在allData数组的索引位置
 */
const end = computed<number>(() => {
  if (!allData.value || allData.value.length <= 0) return 0

  const tmpAllData = allData.value
  
  // 将start.value作为遍历allData的开始位置
  let endPos = start.value
  
  // contentDomTotalHeight存放从start位置开始的dom节点总高度
  let contentDomTotalHeight = tmpAllData[endPos].height
  
  // 获取视口高度
  const viewPortHeight = scrollerContainerRefHeight.value
  
  // 从start位置开始遍历allData的同时,统计数据dom节点的累计高度,直至累计高度超过了视口高度
  while (contentDomTotalHeight < viewPortHeight) {
    endPos++
    contentDomTotalHeight += tmpAllData[endPos].height
  }
  
  // 因为数组的slice方法是包头不包尾的所以还需要再endPos上+1,才会是预期的元素数量
  endPos += 1
  
  // 因为存在在某个元素位置开区间滚动的情况,此时该元素不会完全移出视口,但又使得视口多出了位置,因此要再+1,渲染下一个元素来占满视口区域
  return endPos + 1
})

/**
 * 内容容器的y轴偏移量。当渲染区域第一个元素完全移到了可视区域之外时,需要重新计算startOffset,将第一个元素移动回可视区域
 */
const startOffset = ref<number>(0)
const styleTranslate = computed<string>(() => {
  return `transform:translate(0,${startOffset.value}px)`
})
/**
 * 当前视口需要显示的数据
 */
const renderData = computed<VitrualItem<ContentType>[]>(() => {
  // 避免最后一个元素的数组下标超出实际的数组长度
  const realEnd = Math.min(end.value, allData.value.length)
  return allData.value.slice(start.value, realEnd)
})

function loadData() {
  return new Promise<ContentType[]>(resolve => {
    const data = Mock.mock({
      'list|10': [
        {
          // 属性 id 是一个自增数,起始值为 1,每次增 1
          'id|+1': 1,
          title: '@ctitle(10, 20)',
          content: '@cparagraph(1, 7)',
        },
      ],
    }) as { list: ContentType[] }
    console.log(data.list)
    resolve(data.list)
  })
}

async function init() {
  const tmpAllData = await loadData()
  allData.value = tmpAllData.map<VitrualItem<ContentType>>((item, idx) => ({
    data: item,
    arrPos: idx,
    startPos: maybeHeight * idx,
    endPos: maybeHeight * idx + maybeHeight,
    height: maybeHeight,
  }))
}

onMounted(() => {
  init()
})
</script>

<template>
  <!-- 实际开发中虚拟列表通常是位于某个dom容器下,并占满这个dom容器的整个高度,这里就是模拟这种情况 -->
  <div class="outContainer">
    <!-- scrollerContainer为支持滚动条的容器,定义整个虚拟列表的高度 -->
    <div class="scrollerContainer" ref="scrollerContainerRef">
      <div class="pillarDom" :style="{ height: `${pillarDomHeight}px` }"></div>
      <div class="contentList" :style="styleTranslate" ref="contentListRef">
        <div class="item" v-for="oneData in renderData" :key="oneData.data.id" :data-index="oneData.arrPos">
          <h6>{{ oneData.arrPos }} : {{ oneData.data.title }}</h6>
          <p>{{ oneData.data.content }}</p>
        </div>
      </div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.outContainer {
  height: 350px;
  width: 100%;
}
.scrollerContainer {
  height: 100%;
  width: 100%;
  overflow: auto;
  position: relative;
  -webkit-overflow-scrolling: touch;
}
.pillarDom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}
.contentList {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}
.item {
  border-bottom: 8px solid green;
  width: 100%;
  // 这里同样很重要,盒模型必须为border-box,item元素的高度才不会因为border值而超出设置的高度
  box-sizing: border-box;
  background-color: orange;
  padding: 5px 10px;
  &:last-child {
    border-bottom: none;
  }
}
</style>

update 之后重新计算当前每个元素的高度和位置信息

onUpdated(() => { 
const contentListDom = contentListRef.value 
if (!contentListDom) return 
const childrenElementArr = contentListDom.children 
const dataList = allData.value for (let i = 0; i < childrenElementArr.length; i++)
{ const childEle = childrenElementArr[i] as HTMLElement

// 获取当前数据dom节点的数据再allData数组中的索引位置 
const dataIndexStr = childEle.dataset['index'] 
if (!dataIndexStr) continue 
const dataIndex = parseInt(dataIndexStr)

// 从allData数据中获取到该数据 
const dataItem = dataList[dataIndex] 
if (!dataItem) continue 

// 获取元素的实际高度 
const { height } = childEle.getBoundingClientRect() 
const oldHeight = dataItem.height 

/* 计算当前数据dom元素的旧高度和当前高度的差值 如: oldHeight为100px,height为50px, 那么dffVal为 50px,那么 oldHeight - dffVal 为 50px oldHeight为50px,height为100px, 那么dffVal为 -50px,那么 oldHeight - dffVal 为 100px */ const dffVal = oldHeight - height if (dffVal != 0) { 
// 当前dom元素的实际高度与allData中记录的高度不一致,则更新高度以及元素位置信息 
dataItem.height = oldHeight - dffVal 
dataItem.endPos = dataItem.endPos - dffVal 

for (let j = dataIndex + 1; j < dataList.length; j++) { 
const jPosDataItem = dataList[j] 
// j位置的上一个位置的元素 
const jPrevPosDataItem = dataList[j - 1] 
jPosDataItem.startPos = jPrevPosDataItem.endPos 
jPosDataItem.endPos = jPosDataItem.startPos + jPosDataItem.height 
        } 
    } 
} 
allData.value = dataList })

监听滚动,并修改 start(让数据随着滚动而改变)

function onScroll(evt: UIEvent) { 
const scrollerContainerDom = evt.target as HTMLDivElement 
if (!scrollerContainerDom) return 
const { scrollTop } = scrollerContainerDom 
let idx = 0 
const dataList = allData.value 
let dataItem = dataList[idx] 
while (dataItem.endPos <= scrollTop) { 
idx++ dataItem = dataList[idx] } 
start.value = idx 
// console.log(start.value) }

监听滚动,并修正内容容器的偏移量

function onScroll(evt: UIEvent) { 
const scrollerContainerDom = evt.target as HTMLDivElement 
if (!scrollerContainerDom) return 
const { scrollTop } = scrollerContainerDom 
let idx = 0 
const dataList = allData.value 
let dataItem = dataList[idx] 
while (dataItem.endPos <= scrollTop) { 
idx++ 
dataItem = dataList[idx] } 
start.value = idx 
startOffset.value = allData.value[start.value].startPos }
</script>

这样,一个基本的不定高的虚拟列表已经实现,但是在滚动中出现了很明显的卡顿,使用Chrome浏览器的Performance选项卡进行分析,发现脚本分析占用了大部分的时间,其中update之后重新计算这个函数是问题所在,猜测是因为这个频繁的访问了响应式变量,于是便做了以下的优化

修正大数据量卡顿问题


<script lang="ts">
export default {
  name: 'DynItemHeightVersion06',
}
</script>
<script setup lang="ts">
import Mock from 'mockjs'
import { computed, markRaw, nextTick, onMounted, onUpdated, ref } from 'vue'

interface ContentType {
  id: number
  title: string
  content: string
  arrPos: number
}

interface ContentPosition {
  /**
   * 当前数据处在allData数组的索引位置
   */
  arrPos: number
  /**
   * 当前数据dom的top位置
   */
  startPos: number
  /**
   * 当前数据dom的bottom位置
   */
  endPos: number
  /**
   * 当前数据dom的高度(初始值为猜测高度【预估高度】)
   */
  height: number
}
/**
 * 猜测高度(预估高度)
 */
const maybeHeight = 100
/**
 * 所有数据
 */
const allData = ref<ContentType[]>([])
let positionDataArr: ContentPosition[] = []
/**
 * 柱子节点高度: allData最后一个元素的endPos值
 *
 * 用于撑开滚动容器的高度
 */
const pillarDomHeight = ref<number>(0)
/**
 * 内容列表容器dom
 */
const contentListRef = ref<HTMLDivElement>()
/**
 * 滚动容器. 支持显示滚动条的容器。确定虚拟列表的可视区高度
 */
const scrollerContainerRef = ref<HTMLDivElement>()
/**
 * 滚动容器高度(视口高度)。采用计算属性方式动态获取滚动容器高度
 */
const scrollerContainerRefHeight = computed(() => {
  return scrollerContainerRef.value ? scrollerContainerRef.value.offsetHeight : 0
})
/**
 * 当前视口第一个数据在allData数组的索引位置. 默认:0
 */
const start = ref<number>(0)
/**
 * 当前视口最后一个数据在positionDataArr数组的索引位置
 */
const end = computed<number>(() => {
  if (!allData.value || allData.value.length <= 0) return 0

  // 将start.value作为遍历positionDataArr的开始位置
  let endPos = start.value
  // contentDomTotalHeight存放从start位置开始的dom节点总高度
  let contentDomTotalHeight = positionDataArr[endPos].height
  // 获取视口高度
  const viewPortHeight = scrollerContainerRefHeight.value
  // 从start位置开始遍历positionDataArr的同时,统计数据dom节点的累计高度,直至累计高度超过了视口高度
  while (contentDomTotalHeight < viewPortHeight) {
    endPos++
    contentDomTotalHeight += positionDataArr[endPos].height
  }
  // 因为数组的slice方法是包头不包尾的所以还需要再endPos上+1,才会是预期的元素数量
  endPos += 1
  // 因为存在在某个元素位置开区间滚动的情况,此时该元素不会完全移出视口,但又使得视口多出了位置,因此要再+1,渲染下一个元素来占满视口区域
  return endPos + 1
})
/**
 * 内容容器的y轴偏移量。当渲染区域第一个元素完全移到了可视区域之外时,需要重新计算contentListOffset,将第一个元素移动回可视区域
 */
const contentListOffset = ref<number>(0)
const styleTranslate = computed<string>(() => {
  return `transform:translate(0,${contentListOffset.value}px)`
})
/**
 * 当前视口需要显示的数据
 */
const renderData = computed<ContentType[]>(() => {
  // 避免最后一个元素的数组下标超出实际的数组长度
  const realEnd = Math.min(end.value, allData.value.length)
  return allData.value.slice(start.value, realEnd)
})

function loadData() {
  return new Promise<ContentType[]>(resolve => {
    const data = Mock.mock({
      'list|10000': [
        {
          // 属性 id 是一个自增数,起始值为 1,每次增 1
          'id|+1': 1,
          title: '@ctitle(10, 20)',
          content: '@cparagraph(1, 7)',
        },
      ],
    }) as { list: ContentType[] }
    resolve(data.list)
  })
}

async function init() {
  const tmpArr = await loadData()
  allData.value = tmpArr.map<ContentType>((item, idx) => markRaw({ ...item, arrPos: idx }))
  positionDataArr = allData.value.map<ContentPosition>((_, idx) => ({
    arrPos: idx,
    startPos: maybeHeight * idx,
    endPos: maybeHeight * idx + maybeHeight,
    height: maybeHeight,
  }))
}

onMounted(() => {
  init()
})

function updateHeightAndPos() {
  const contentListDom = contentListRef.value
  if (!contentListDom) return

  const childrenElementArr = contentListDom.children
  for (let i = 0; i < childrenElementArr.length; i++) {
    const childEle = childrenElementArr[i] as HTMLElement
    // 获取当前数据dom节点的数据在positionDataArr数组中的索引位置
    const dataIndexStr = childEle.dataset['index']
    if (!dataIndexStr) continue

    const dataIndex = parseInt(dataIndexStr)
    // 从positionDataArr获取到当前dom元素的位置信息和高度信息
    const dataItem = positionDataArr[dataIndex]
    if (!dataItem) continue

    // 获取元素的实际高度
    const { height } = childEle.getBoundingClientRect()
    // const { offsetHeight: height } = childEle
    const oldHeight = dataItem.height
    /*
    计算当前数据dom元素的旧高度和当前高度的差值

    如:
    oldHeight为100px,height为50px, 那么dffVal为 50px,那么 oldHeight - dffVal 为 50px
    oldHeight为50px,height为100px, 那么dffVal为 -50px,那么 oldHeight - dffVal 为 100px
     */
    const dffVal = oldHeight - height
    if (dffVal != 0) {
      // 当前dom元素的实际高度与allData中记录的高度不一致,则更新高度以及元素位置信息
      dataItem.height = oldHeight - dffVal
      dataItem.endPos = dataItem.endPos - dffVal

      for (let j = dataIndex + 1; j < positionDataArr.length; j++) {
        const jPosDataItem = positionDataArr[j]
        // j位置的上一个位置的元素
        const jPrevPosDataItem = positionDataArr[j - 1]

        jPosDataItem.startPos = jPrevPosDataItem.endPos
        jPosDataItem.endPos = jPosDataItem.startPos + jPosDataItem.height
      }
    }
  }
  pillarDomHeight.value = positionDataArr.length > 0 ? positionDataArr[positionDataArr.length - 1].endPos : 0
}

onUpdated(() => {
  nextTick(() => {
    updateHeightAndPos()
  })
})

function onScroll(evt: UIEvent) {
  const scrollerContainerDom = evt.target as HTMLDivElement
  if (!scrollerContainerDom) return

  const { scrollTop } = scrollerContainerDom

  let idx = 0
  let dataItem = positionDataArr[idx]
  while (dataItem.endPos <= scrollTop) {
    idx++
    dataItem = positionDataArr[idx]
  }
  start.value = idx
  contentListOffset.value = positionDataArr[start.value].startPos
}
</script>

<template>
  <!-- 
    为什么 pillarDomHeight 值在滚动到最后再往回滚的时候还是变化的,
    按理说此时所有元素都真实渲染过一次的获取过一次真实高度,
    那 pillarDomHeight 取的是最后一个元素的endPos,那pillarDomHeight的值应该不变了才对呀?
  
    因为css样式中:有个设置 &:last-child {border-bottom: none;}, 那么就会导致某个元素处于最后一个元素时没有border-bottom,
    而处于非最后一个元素时有border-bottom,所以数据项dom的高度在这个层面也是动态变化的

    那人可能还会想,还是不对呀,你不是给item元素设置box-sizing: border-box;吗?这个盒模型当有border的时候不是也不会撑开元素高度吗?
    是的,但那得元素本身有设置明确的高度的情况,如果元素本身没有明确的设置高度,元素的实际高度依然会被border撑开

    如何验证你上面的说法?
    将 &:last-child {border-bottom: none;} 注释掉,再滚动到底部,再来回滚动,你就会发现 pillarDomHeight 已经是一个固定值了
  -->
  {{ pillarDomHeight }}
  <!-- 实际开发中虚拟列表通常是位于某个dom容器下,并占满这个dom容器的整个高度,这里就是模拟这种情况 -->
  <div class="outContainer">
    <!-- scrollerContainer为支持滚动条的容器,定义整个虚拟列表的高度 -->
    <div class="scrollerContainer" ref="scrollerContainerRef" @scroll="onScroll">
      <div class="pillarDom" :style="{ height: `${pillarDomHeight}px` }"></div>
      <div class="contentList" :style="styleTranslate" ref="contentListRef">
        <div class="item" v-for="oneData in renderData" :key="oneData.id" :data-index="oneData.arrPos">
          <h6>{{ oneData.arrPos }} : {{ oneData.title }}</h6>
          <p>{{ oneData.content }}</p>
        </div>
      </div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.outContainer {
  height: 350px;
  width: 100%;
}
.scrollerContainer {
  height: 100%;
  width: 100%;
  overflow: auto;
  position: relative;
  -webkit-overflow-scrolling: touch;
}
.pillarDom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}
.contentList {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}
.item {
  border-bottom: 8px solid green;
  width: 100%;
  // 这里同样很重要,盒模型必须为border-box,item元素的高度才不会因为border值而超出设置的高度
  box-sizing: border-box;
  background-color: orange;
  padding: 5px 10px;
  &:last-child {
    border-bottom: none;
  }
}
</style>

使用二分查找优化获取 start 值的方式

/**
 * 通过二分查找来获取start值
 *
 * @param   {ContentPosition[]}  _positionDataArr  [_positionDataArr description]
 * @param   {number}             scrollTop         [scrollTop description]
 *
 * @return  {[]}                                   [return description]
 */
function findStartByBinarySearch(_positionDataArr: ContentPosition[], scrollTop: number) {
  let startIdx = 0
  let endIdx = _positionDataArr.length - 1
  let resultIdx: number | undefined
  while (startIdx <= endIdx) {
    // Math.trunc 去除小数部分,只取整数部分. 取startIdx 到 endIdx的中间索引号
    const middleIdx = Math.trunc((startIdx + endIdx) / 2)
    // 获取中间索引号对应元素的位置信息
    const middleEle = _positionDataArr[middleIdx]
    // 获取中间索引号对应元素的底部位置
    const middleEleEndPos = middleEle.endPos
    if (middleEleEndPos === scrollTop) {
      // 当前滚动高度等于中间索引号对应元素的底部位置,则start为中间索引号的下一个位置
      return middleIdx + 1
    } else if (middleEleEndPos < scrollTop) {
      // 当前滚动高度大于中间索引号对应元素的底部位置,则调整查找区间为右区间
      startIdx = middleIdx + 1
    } else if (middleEleEndPos > scrollTop) {
      // 当前滚动高度大于中间索引号对应元素的底部位置,则调整查找区间为左区间
      if (resultIdx === undefined || resultIdx > middleIdx) {
        // 存储元素 middleEleEndPos>scrollTop 元素的最小数组索引号
        resultIdx = middleIdx
      }
      // 调整查找区间为左区间
      endIdx = middleIdx - 1
    }
  }
  return resultIdx
}