vue.js

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript类库 > vue.js > Vue3虚拟列表

Vue3实现虚拟列表的示例代码

作者:E_Yen

虚拟列表是一种优化长列表渲染的技术,它可以在保持流畅性的同时,渲染大量的数据,本文主要介绍了如何通过Vue3实现一个虚拟列表,感兴趣的可以了解下

前言

本文的虚拟列表基于上一篇动态高度虚拟列表原理解析中的核心代码和Vue3实现,上文是看懂思考&设计部分内容的前提条件。

使用

安装

npm install @e.yen/virtual-scroll-vue

在main.js中显式导入或者在组件中按需导入

// main.ts
import VirtualScroll from '@e.yen/virtual-scroll-vue'
app.use(VirtualScroll)

// 或者
// AnyComponent.vue
import { VirtualScroll } from '@e.yen/virtual-scroll-vue'

导入样式

// main.ts
import '@e.yen/virtual-scroll-vue/dist/style.css'

Github仓库:virtual-scroll-vue

props参数

参数类型默认值是否必须描述
itemsVirtualScrollItem[]-✔️列表数据
placeholderVirtualScrollItem-最小子项的模拟数据
startPosition[number, number][0, 0]列表初始位置
preservednumber-子项的最小高度
paddingnumber100预渲染区域高度
type VirtualScrollItem = {
  key?: any
  height?: number // 可以指定元素高度,具有最高优先级
  [k: string | symbol]: any
}

注意preserved 的优先级高于 placeholder

expose方法

方法参数返回值类型描述
scrolldelta: number, duration?: number-滚动指定距离
transportnewStartPosition: [number, number]-传送到指定位置
getPosition-[number, number]获取列表当前位置

注意事项

让子项拥有唯一的key

由于列表基于v-for渲染子项,因此为子项拥有唯一的key能够大幅度提升性能表现:

const items = [
  {
    key: 'ABC',
  },
  {
    key: 'BCD',
  },
]

任何时候都不要使最小高度为0

由于元素在被渲染之前无法确认其高度,因此列表依赖于子项目的最小高度确定渲染索引范围。虽然能够通过 placeholder 将最小高度设为0,但这会导致列表渲染后续所有子项:

<!-- preserved默认具有最小值5px,设为0不会有任何效果 -->
<VirtualScroll :items="data" :preserved="0">
  <template #default="{ item }">
    <!-- 子项目结构 -->
  </template>
</VirtualScroll>

<!-- 通过placeholder将最小高度设为0,会导致列表一次性渲染所有元素 -->
<VirtualScroll :items="data" :placeholder="{}">
  <template #default="{ item }">
    <!-- 高度为0的子项目 -->
    <div></div>
  </template>
</VirtualScroll>

不要向起始元素之前添加新数据

由于列表的渲染索引范围由起始元素索引、起始元素偏移量和最小项目高度共同决定,因此向起始元素之前的位置添加新元素会导致意料之外的结果:

// 假设 startIndex 为 1
// 向items头部添加新数据,会导致列表渲染的起始元素变为旧数组中索引为 0 的项
items.unshift({
  key: 'CDE',
})

不要修改预渲染区内的元素高度

具体来说是不要修改靠近列表排列起始方向一侧的元素高度(例如列表从上往下排列,则不要修改上方预渲染区域内的元素高度)

不是强制要求的,相反,列表仍能正常工作,但是对于具有过渡效果的高度变化,受制于 ResizeObserver 的滞后性,列表可能出现微小的抖动导致用户体验变差

仅在触屏设备上使用

虽然列表支持滚轮滚动,但是暂不支持滚动条,在PC等非触屏设备上应考虑使用分页

示例

列表会自动获取可视区域大小,宽高默认为 100%,建议通过 .virtual-scroll_container 进行覆盖,或者在组件外部包裹一个容器

列表会将要渲染的数据通过默认作用域插槽传递出来,天然支持动态高度

<script setup lang="ts">
import { ref } from 'vue'
import DynamicItem from '@/components/DynamicItem/DynamicItem.vue'
import { VirtualScroll, type VirtualScrollInstance } from '@e.yen/virtual-scroll-vue'
import {
  generateRandomFirstWord,
  generateRandomWord,
  lorem,
} from '@/utils/helper'
const defaultItem = { name: 'ab', comment: 'abc', index: -1 }
const items = ref(
  new Array(10000).fill(0).map((_, i) => ({
    key: i.toString(),
    name:
      generateRandomFirstWord() +
      (Math.random() > 0.5
        ? ' ' + generateRandomWord(Math.floor(Math.random() * 8) + 2)
        : ''),
    comment: lorem(Math.floor(Math.random() * 5) + 1),
    index: i,
  })),
)

const vlist = ref<VirtualScrollInstance>()
function lighteningScroll(delta: number) {
  vlist.value!.scroll(delta)
}
</script>

<template>
  <div class="page">
    <div class="scroll_container">
      <VirtualScroll
        ref="vlist"
        :items="items"
        :placeholder="defaultItem"
        :start-position="[1000, 0]"
        :padding="0"
      >
        <template #default="{ item }">
          <DynamicItem
            :index="item.index"
            :name="item.name"
            :comment="item.comment"
          ></DynamicItem>
        </template>
      </VirtualScroll>
    </div>
    <button @click="lighteningScroll(-100000)">向上极速滚动测试</button>
    <button @click="lighteningScroll(100000)">向下极速滚动测试</button>
  </div>
</template>

思考&设计

虚拟列表的关键在于如何获取列表项高度,确定了每项的高度,就能确定渲染多少个元素。即如何获取列表项高度决定了虚拟列表的实际表现,在这里给出三个思路:

1.固定步长

在浏览器每一帧渲染之前进行判断,若虚拟列表中的元素不足以占满整个可视区域且仍有未被渲染的后续元素,则将渲染结束的索引后移 n 位。

2.预渲染 + 固定步长

原理与上述思路没有区别,优缺点与上面一致,可以认为是在计算元素是否足以占满可视区域时,将参与计算的可视区域进行扩大,从而让列表提前渲染元素。能够在一定程度上缓解空白问题,但治标不治本,当以更快的速度滚动(比如通过代码触发)时仍会出现空白页。

3.预渲染 + 高度预测

观察发现,出现空白页的根本原因是无法确定究竟最多还需要多少个元素才能占满可视区域,为此,可以通过每项的最小高度预测最多需要向后渲染多少个子项,从而保证始终有足够的元素占满可视区域。

预测实现

高度预测主要有两种实现方式:

错位处理

维持前文的约定:

起始位置 startPosition[startIndex, offset] 二元组构成

渲染信息 renderInfo[viewHeight, paddingHeight, listHeight] 三元组构成

函数 move(startPosition, delta, renderInfo, getHeight) => void 通过起始位置、移动距离、渲染信息和高度获取函数计算本次移动后的新起始位置

高度预测会导致快速滚动时出现到达的位置与预期不同的错位问题。粗略地看,当一帧内列表滚动到了未渲染区域,就会转为使用预测高度继续计算下一帧的起始位置,预测高度与实际高度不一致时就会导致列表“移动过头”。举个例子,假设某个未被渲染的元素实际高度为 110px,但在计算时将其视为 100px,那么剩余滚动距离就多了 10px,这是造成错位的根本原因。

既然知道了问题,那么研究其发生条件变得十分重要:

根据移动函数 move 的计算方式得知:

单次移动中,如果:

就可能导致错位

由于虚拟列表是由起始位置决定的,因此向上滚动时的错位将是致命的。原因是计算新的 offset 时使用了预测的高度,但实际高度大于预测高度,导致后续所有元素都下移。在这里使用了自定义指令 + ResizeObserver的方式解决,处理过程分为3步:

v-auto-record 在元素被挂载时,缓存本次计算时使用的高度

v-watch-size 在元素高度被缓存后调用 elementResize 进行处理

elementResize 根据情况更新高度缓存,以及选择修改 offset 或重新渲染

// vAutoRecord.ts
export default <Directive>{
  mounted(el, binding) {
    if (binding.arg && binding.arg === 'mounted') binding.value?.(el)
  },
  unmounted(el, binding) {
    if (binding.arg && binding.arg === 'unmounted') binding.value?.(el)
  },
}

// vWatchSize.ts
export default <Directive>{
  mounted(el, binding) {
    // ! nextTick保证vWatchSize在vAutoRecord之后执行
    nextTick(() => {
      if (binding.value instanceof Function) binding.value(el)
      el.observer = new ResizeObserver(() => {
        if (binding.value instanceof Function) binding.value(el)
      })
      el.observer.observe(el)
    })
  },
  beforeUnmount(el) {
    if (el.observer) {
      el.observer.disconnect()
      delete el.observer
    }
  },
}
<li
  v-for="(i, index) in renderRange"
  :key="props.items[i].key || index"
  class="virtual-scroll_item"
  v-watch-size="el => elementResize(i, el)"
  v-auto-record:mounted="el => elementMap.set(i, el)"
  v-auto-record:unmounted="() => elementMap.delete(i)"
>
  <slot :item="props.items[i]"></slot>
</li>
const elementResize = (index: number, element: HTMLElement) => {
  const cur = element.getBoundingClientRect().height
  const pre = getHeight(index) // 取出高度缓存
  let isInPaddingRange = false
  if (cur === pre) return

  // 判断高度变化的元素是否在预加载区间
  let offset = startPosition.value[1]
  let itemIndex = startPosition.value[0]
  let height = getHeight(itemIndex)
  while (height >= 0 && offset > 0) {
    if (itemIndex === index) {
      isInPaddingRange = true
      break
    }
    offset -= height
    height = getHeight(++itemIndex)
  }

  // 更新高度缓存
  updateHeight(index)

  if (isInPaddingRange) {
    // 如果高度变化的元素在预加载区间内,将offset加上高度变化量
    startPosition.value[1] += cur - pre
  } else if (cur < pre) {
    // 如果高度变化的元素不在预加载区间内,重新渲染
    renderTrigger.value = !renderTrigger.value
  }
}

至于向下滚动时的错位问题,这是高度预测的固有局限,因此没有很好的解决方法,一种可能的蒙混过关的解决方式是:快速滚动时用户无法分辨页面上到底呈现了什么,可以在滚动结束的下一帧立即将起始位置修改为目标位置,实现向下快速滚动到指定位置的错觉。但如果在列表项中出现了编号这样容易让小把戏穿帮的内容,可能需要考虑用 transport 定制滚动效果。

以上就是Vue3实现虚拟列表的示例代码的详细内容,更多关于Vue3虚拟列表的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文