不定高虚拟列表的一种实现

虚拟列表主要解决大数据量数据一次渲染性能差的问题。

之前写过一篇关于虚拟列表实现的文章:造轮子之不同场景下虚拟列表实现,主要讲了定高(高度统一和高度不统一两种情况)虚拟列表的实现,本文着重研究不定高虚拟列表的实现。在vue环境单页面项目下研究实现。

前文讲过虚拟列表的要做的事是确保性能的前提下,利用一定的技术模拟全数据一次性渲染后效果。

定高虚拟列表原理

绿色部分为containter,也就是父容器,它会有固定的高度。黄色部分为content,它是父容器的子元素。

当content的高度超过父容器的高度,就可以滚动内容区了,这就是一般滚动原理。

虚拟列表需要使用这个滚动原理。虚拟列表使用占位div,设置占位div的高度为所有列表数据的高度进而撑开containter,形成滚动条。

然后虚拟列表具体渲染过程中,只是渲染可视区也就是父容器区域

至于可视区域的内容滚动通过监听滚动条scroll事件,获取到滚动距离scrllTop,转换为可视区域的偏移位置,同时获取渲染数据的起始和结束索引,渲染指定段数据形成假象的滚动。

不定高内容数渲染

上一篇文章造轮子之不同场景下虚拟列表实现已经给出了定高虚拟列表的实现。不定高相对定高的难点在于数据没有渲染之前根本不知道数据的实际高度,解决方案理论上有

  1. 在屏幕外渲染,但消耗性能
  2. 预估高度先行渲染,然后获取真实高度并缓存

采用第一种方案显然是不完美的,所以采用第二个方案,这也是之前有人实现过的。

不定高假数据

为了更接近业务,这里使用vue-codemirror方式渲染数据,为vue-codemirror造假数据

js 复制代码
function generateRandomNumber () {
  const min = 100
  const max = 1000
  // 生成随机整数
  const randomNumber = Math.floor(Math.random() * (max - min + 1)) + min
  return randomNumber
}
function getRandomLetter () {
  const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
  const randomIndex = Math.floor(Math.random() * letters.length)
  const randomLetter = letters.charAt(randomIndex)
  return randomLetter
}
function generateString (length) {
  const minLength = 100
  const maxLength = 1000

  // 确保长度在最小和最大范围内
  if (length < minLength) {
    length = minLength
  } else if (length > maxLength) {
    length = maxLength
  }

  // 生成字符串
  const string = getRandomLetter().repeat(length)

  return string
}
const d = []
for (let i = 0; i < 500; i++) {
  const length = generateRandomNumber()
  d.push({
    data: generateString(length),
    index: i
  })
}

这里造了500条,具体是随机生成的字符串,字符串长度100-1000,字符从A-Z中选取。

温故定高虚拟列表

因为不定高虚拟列表有和定高虚拟列表相似之处,再来回顾一下之前定高(统一高度和不统一高度)的解决方案。这里只展示一下统一高度的,不统一高度的可以查看造轮子之不同场景下虚拟列表实现。统一高度组件代码

js 复制代码
<template>
  <div ref="list" class="render-list-container" @scroll="scrollEvent($event)">
    <!-- 占位div -->
    <div class="render-list-phantom" :style="{ height: listHeight + 'px' }"></div>
    <div class="render-list" :style="{ transform: getTransform }">
      <template
        v-for="item in visibleData"
      >
        <slot :value="item.value"  :height="itemSize + 'px'"  :index="item.id"></slot>
      </template>
    </div>
  </div>
</template>

<script>
export default {
  name: 'VirtualList',
  props: {
    // 所有列表数据
    listData: {
      type: Array,
      default: () => []
    },
    // 每项高度
    itemSize: {
      type: Number,
      default: 100
    }
  },
  computed: {
    // 列表总高度
    listHeight () {
      return this.listData.length * this.itemSize
    },
    // 可显示的列表项数
    visibleCount () {
      return Math.ceil(this.screenHeight / this.itemSize)
    },
    // 偏移量对应的style
    getTransform () {
      return `translate3d(0,${this.startOffset}px,0)`
    },
    // 获取真实显示列表数据
    visibleData () {
      return this.listData.slice(this.start, Math.min(this.end, this.listData.length))
    }
  },
  mounted () {
    this.screenHeight = this.$el.clientHeight
    this.end = this.start + this.visibleCount
  },
  data () {
    return {
      // 可视区域高度
      screenHeight: 0,
      // 偏移量
      startOffset: 0,
      // 起始索引
      start: 0,
      // 结束索引
      end: null
    }
  },
  methods: {
    scrollEvent () {
      // 当前滚动位置
      const scrollTop = this.$refs.list.scrollTop
      // 此时的开始索引
      this.start = Math.floor(scrollTop / this.itemSize)
      // 此时的结束索引
      this.end = this.start + this.visibleCount
      // 此时的偏移量
      this.startOffset = scrollTop - (scrollTop % this.itemSize)
    }
  }
}
</script>

<style scoped>
.render-list-container {
  overflow: auto;
  position: relative;
  -webkit-overflow-scrolling: touch;
  height: 200px;
}

.render-list-phantom {
  position: absolute;
  left: 0;
  right: 0;
  z-index: -1;
}

.render-list {
  text-align: center;
}

</style>

研究不定高虚拟列表组件

按照统一高度方式渲染

正如上面所说为了解决不定高内容高度不定的问题,采用

预估高度先行渲染,然后获取真实高度并缓存方案

所以给每条假数据一条预估高度,然后使用定高虚拟列表渲染数据,渲染数据代码

js 复制代码
<template>
  <div class="render-show">
    <div>
      <NoHasVirtualList :listData="data">
        <template slot-scope="{ item, height }">
          <codemirror
            class="unit"
            :style="{height: height}" 
            v-model="item.data"
            :options="cmOptions"
          ></codemirror>
        </template>
      </NoHasVirtualList>
    </div>
  </div>
</template>
  

设置codemirror组件高度固定。查看一下效果

问题很明显,由于codemirror组件设置固定高度,导致渲染内容挤到一起了。所以预估高度不是这样用的,预估高度的意义:它是一种高度占位,是一种占位是务必要修正的。

修正高度

为了修正这个高度,需要等待数据渲染后拿到真实高度,这个需求可以在vue生命周期函数updated实现,也可以通过IntersectionObserver实现。本文采用updated实现。

修正高度不仅修正每一条数据的高度,因为用来撑起可视区域的占位div高度也是根据预估高度计算的,所以占位div高度也需要更新,然后还需要更新偏移量。

具体在updated里获取真实元素大小,修改对应的尺寸缓存更新占位div高度(使用计算属性实现);更新真实偏移量

js 复制代码
  updated () {
    this.$nextTick(() => {
      // 获取真实元素大小,修改对应的尺寸缓存
      this.updateItemsSize()

      // 更新真实偏移量
      this.setStartOffset()
    })
  },
获取数据实际高度,修改对应尺寸缓存

创建计算属性_listData拷贝列表数据。目的尽量不修改传进来的listData列表数据,同时给渲染列表数据添加索引,实际是给渲染用的visibleCount添加唯一索引

js 复制代码
  computed: {
    _listData () {
      return this.listData.reduce((init, cur, index) => {
        init.push({
          // _转换后的索引
          _key: index,
          value: cur
        })
        return init
      }, [])
    },
   ...
  }

缓存每条数据的高度、以及数据坐标:用topbottom标记

js 复制代码
   // 初始化缓存
    initPositions () {
      this.positions = this._listData.map((d, index) => ({
        index,
        height: this.itemSize,
        top: index * this.itemSize,
        bottom: (index + 1) * this.itemSize
      }))
    },

上面计算属性_listData以及缓存每条数据均是服务于这一步:获取渲染数据实际高度,修改对应数据缓存尺寸

js 复制代码
    // 获取实际高度,修正内容高度
    updateItemsSize () {
      const nodes = this.$refs.items
      nodes.forEach((node) => {
        // 获取元素自身的属性
        const rect = node.getBoundingClientRect()
        const height = rect.height
        const index = +node.id // id就是_listData上的唯一索引
        const oldHeight = this.positions[index].height
        const dValue = oldHeight - height
        // 存在差值
        if (dValue) {
          this.positions[index].bottom = this.positions[index].bottom - dValue
          this.positions[index].height = height
          this.positions[index].over = true // TODO

          for (let k = index + 1; k < this.positions.length; k++) {
            this.positions[k].top = this.positions[k - 1].bottom
            this.positions[k].bottom = this.positions[k].bottom - dValue
          }
        }
      })
    },
更新列表总高度

获取数据实际高度,修改对应尺寸缓存目的之一是为了更新列表总高度

js 复制代码
  computed: {
    ...
    // 列表总高度
    listHeight () {
      return this.positions[this.positions.length - 1].bottom
    },
    ...
  },

上述代码中this.listHeight是一个计算属性,是占位div的高度。

js 复制代码
<template>
      <div
        ref="list"
        class="infinite-list-container"
        @scroll="scrollEvent($event)"
      >
        <!-- 占位div -->
        <div ref="phantom" class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
       ...
      </div>
  </template>
更新真实偏移量

获取数据实际高度,修改对应尺寸缓存目的之二是为了更新真实偏移量。

借助this.positions数组数据,通过设置this.startOffset,在传导到计算属性this.contentTransform更新偏移量

js 复制代码
<template>
    <div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)">
      <!-- 占位div -->
      <div
        class="infinite-list-phantom"
        :style="{ height: listHeight + 'px' }"
      ></div>

      <div
        ref="content"
        :style="{ transform: contentTransform }"
        class="infinite-list"
      >
      ....
      </div>
    </div>
  </template>
  ...
 computed: {
    ...
    // 偏移量对应的style
    contentTransform () {
      return `translateY(${this.startOffset}px)`
    },
     ...
   },
   ...
    // 更新偏移量
    setStartOffset () {
      if (this.start >= 1) {
        const size =
            this.positions[this.start].top -
            (this.positions[this.start - this.aboveCount]
              ? this.positions[this.start - this.aboveCount].top
              : 0)
        this.startOffset = this.positions[this.start - 1].bottom - size
      } else {
        this.startOffset = 0
      }
    }

滚动事件

滚动事件用以触发更新

js 复制代码
   // 滚动事件
    scrollEvent () {
      // 当前滚动位置
      const scrollTop = this.$refs.list.scrollTop
      // 更新滚动状态
      // 排除不需要计算的情况
      if (
        scrollTop > this.anchorPoint.bottom ||
          scrollTop < this.anchorPoint.top
      ) {
        // 此时的开始索引
        this.start = this.getStartIndex(scrollTop)
        // 此时的结束索引
        this.end = this.start + this.visibleCount
        // 更新偏移量
        this.setStartOffset()
      }
    }

其中this.anchorPoint是计算属性

js 复制代码
  computed: {
    ...
    anchorPoint () {
      return this.positions.length ? this.positions[this.start] : null
    }
    ...
  },

上述代码中之所以排除不需要计算的情况,需要解释一下。

真实的滚动就是滚动条滚动了多少,可视区就向上移动多少。但虚拟滚动不是。当起始索引发生变化时,渲染数据发生变化了,但渲染数据的高度不是连续的,所以需要动态的设置偏移量。当滚动时起始索引不发生变化时,因为数据变化是连续的,此时可以什么也不做,滚动显示的内容由浏览器控制。排除的部分就是索引没发生变化的情况

根据滚动高度获取起始索引方法this.getStartIndex

js 复制代码
  methods: {
    ...
    // 获取列表起始索引
    getStartIndex (scrollTop = 0) {
      // 二分法查找
      return this.binarySearch(this.positions, scrollTop)
    },
    // 二分法查找 用于查找开始索引
    binarySearch (list, value) {
      let start = 0
      let end = list.length - 1
      let tempIndex = null

      while (start <= end) {
        const midIndex = parseInt((start + end) / 2)
        const midValue = list[midIndex].bottom
        if (midValue === value) {
          return midIndex + 1
        } else if (midValue < value) {
          start = midIndex + 1
        } else if (midValue > value) {
          if (tempIndex === null || tempIndex > midIndex) {
            tempIndex = midIndex
          }
          end = end - 1
        }
      }
      return tempIndex
    },
    ...  
  }

效果查看以及优化

给滚动增加缓冲,缓冲就是多渲染几条,上方和下方渲染额外的数据,比如前后多渲染2条。增加计算属性aboveCountbelowCount,同时修改visibleData

js 复制代码
  computed: {
    ...
    aboveCount () {
      return Math.min(this.start, 2)
    },
    belowCount () {
      return Math.min(this.listData.length - this.end, 2)
    },
    visibleData () {
      const start = this.start - this.aboveCount
      const end = this.end + this.belowCount
      return this._listData.slice(start, end)
    }
  },

存在问题

即便是给滚动增加缓冲,过快滑动时依然会出现白屏现象,究其本质是滚动过快而真实dom更新赶不上它

总结

本文主要研究了不定高虚拟列表的一种实现。基本原理依然是原生滚动触发,渲染首先是预估高度,之后数据渲染后更新预估高度、更新占位div高度、更新偏移量。

另外就是对于滚动事件做限制,如果滚动高度恰好位于当前元素范围内不做处理。

另外对于数据更新除了可以使用vue的生命周期函数updated还可以使用IntersectionObserver实现。

后期计划:为了解决过快滑动导致的白屏现象,会将不定高虚拟列表与虚拟滚动结合。虚拟滚动前几天写过一篇实现方案:虚拟滚动实现

本项目代码地址:github.com/zhensg123/r...

本文完。

参考文章

「前端进阶」高性能渲染十万条数据(虚拟列表)

相关推荐
腾讯TNTWeb前端团队6 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰9 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪9 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪9 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy10 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom11 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom11 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom11 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom11 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom11 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试