Vue 虚拟列表:vue-virtual-scroller 深度解读

vue-virtual-scroller 提供了以下几个组件:

RecycleScroller 是一个仅呈现列表中可见项目的组件。 它还重用组件和 dom 元素,以尽可能提高效率和性能。

DynamicScroller 是一个包装 RecycleScroller 组件实现的,用于在列表项的大小不是固定的场景,在事先不知道列表项的大小的情况,该组件可以在列表滚动期间计算出列表项的大小。

DynamicScrollerItem 必须包装在 DynamicScroller 中使用以处理列表项大小计算。

IdState 是一个 mixin,可以简化 RecycleScroller 内重用组件的本地状态管理。

RecycleScroller 组件

RecycleScroller 是一个虚拟滚动组件,仅渲染可见项。 当用户滚动时,RecycleScroller 会重用所有组件和 DOM 节点以保持最佳性能。

基本使用

使用作用域插槽来渲染列表中的每一项

html 复制代码
<template>
  <RecycleScroller
    class="scroller"
    :items="list"
    :item-size="32"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="user">
      {{ item.name }}
    </div>
  </RecycleScroller>
</template>

<script>
export default {
  props: {
    list: Array,
  },
}
</script>

<style scoped>
.scroller {
  height: 100%;
}

.user {
  height: 32%;
  padding: 0 12px;
  display: flex;
  align-items: center;
}
</style>

注意事项

  • ⚠️ 您需要设置 virtual-scroller 元素和 items 元素的大小(可以使用 css 来设置)。 除非您使用可变大小模式,否则所有列表项都应具有相同的高度(或水平模式下的宽度)以防止样式异常。

  • ⚠️如果 item 是对象,则 RecycleScroller 组件需要能够识别它们。 默认情况下,它将在 item 上查找 id 字段。 如果您使用其他字段名称,可以使用 keyField 属性进行配置。

  • 在 RecycleScroller 中不建议使用函数式组件,因为组件会被重用,反而会变慢。

    • Tips:在 RecycleScroller 中,组件会被重用,意味着每次滚动时,已经渲染过的组件会被复用,并且只会更新其内容。如果在 RecycleScroller 中使用函数式组件,每次组件被复用时,由于函数式组件是无状态的,其内部状态都会被重置为初始状态。这就导致了每次复用组件时都需要重新计算和管理组件的内部状态,从而增加了计算和更新的开销,使渲染变慢。因此,不建议在 RecycleScroller 中使用函数式组件。
  • 列表项组件对 item prop 的更新要是响应式的,即在不重新创建列表项组件的情况下进行更新(可以使用计算属性和侦听器来实现对 prop 的响应式更新)

  • 你不需要在列表内容上设置key属性(但是你应该在所有嵌套的元素上设置key属性,以防止加载时出现闪烁问题)。

    • 在 Vue 中,使用 key 属性可以帮助 Vue 识别元素的身份,从而更好地进行渲染和更新。但是,在使用 vue-virtual-scroller 插件时,由于它会根据实际可见的列表项来动态渲染,并且会进行滚动和重用,因此不需要给列表项设置 key 属性。

    • 然而,对于嵌套在列表项中的元素,由于它们可能在滚动过程中被动态加载,取消绑定和重新加载,需要设置key属性可以保证元素正确更新,以防止加载时出现闪烁的问题。

  • 浏览器对 DOM 元素有大小限制,这意味着当前虚拟滚动条无法显示超过 500k 的列表项,具体取决于浏览器。

  • 由于列表项的 DOM 元素被重用,建议使用提供的hover类来定义悬停样式,而不是使用:hover状态选择器(例如,.vue-recycle-scroller__item-view.hover或.hover .some-element-inside-the-item-view)。

它是如何工作的

  • RecycleScroller创建了一个视图池(pools of views),用于将可见的列表项渲染给用户。这个池中的视图会被重复使用。

  • 视图(view)持有已渲染的列表项,并在它所在的视图池内被重用。也就是说,当一个列表项离开可见区域时,其视图会被重新利用。

  • 对于每种类型的列表项,都会创建一个新的视图池,以确保相同的组件和 DOM 树被用于相同类型的列表项。这样做的目的是为了提高性能,避免重复渲染相同类型的列表项。

  • 视图可以在其离开可视区范围时被停用,并随时可以被重新使用来展示新的可见的列表项。这一机制有助于减少 DOM 操作和渲染的开销

总的来说,RecycleScroller 通过创建视图池,并在内部对视图进行重用和动态激活/停用,来提高在滚动时渲染项目的效率,从而优化前端性能。

以下是垂直模式下 RecycleScroller 的内部结构:

html 复制代码
<RecycleScroller>
  <!-- Wrapper element with a pre-calculated total height -->
  <wrapper
    :style="{ height: computedTotalHeight + 'px' }"
  >
    <!-- Each view is translated to the computed position -->
    <view
      v-for="view of pool"
      :style="{ transform: 'translateY(' + view.computedTop + 'px)' }"
    >
      <!-- Your elements will be rendered here -->
      <slot
        :item="view.item"
        :index="view.nr.index"
        :active="view.nr.used"
      />
    </view>
  </wrapper>
</RecycleScroller>

当用户在 RecycleScroller 内滚动时,视图主要只是被移动以填充新的可见空间,并更新默认插槽属性。通过这种方式,我们最大程度地减少了组件/元素的创建和销毁,并且利用了 Vue 虚拟 DOM算法的全部优势来优化 DOM

Props

  • items ,指定要在滚动区域中显示的项目列表。

  • direction,滚动的方向,可选值为 verticalhorizontal ,默认值为 vertical

  • itemSize (默认值:null),项目的展示高度(或水平模式下的宽度),以像素为单位,用于计算滚动区域的大小和位置。如果将其设置为null(默认值),则将使用可变大小模式

  • gridItems,在同一行上显示 gridItems 项,创建一个网格布局。您必须为 itemSize 属性设置一个值来使用此属性(不支持动态大小)。

  • itemSecondarySize: 当 gridItems 被设置时,网格中项目的像素大小(在纵向模式下为宽度,在横向模式下为高度)。如果未设置itemSecondarySize,则将使用 itemSize 的值。

  • minItemSize: 如果项目的高度(或横向模式下的宽度)未知,则使用的最小尺寸。

  • sizeField(默认为 'size'):在可变尺寸模式下用于获取项目尺寸的字段。

  • typeField(默认为 'type'):用于区分列表中不同类型组件的字段。对于每种不同的类型,将创建一组可重用的项目的资源池。

  • keyField(默认为 'id'):用于标识项目并优化管理已渲染视图的字段。

    • 简单来说,keyField 是用于标识项目并优化已渲染视图管理的字段。在渲染列表时,每个项目都需要一个唯一标识符,以便能够准确地跟踪和管理它们。默认情况下,keyField 的值为'id',意味着每个项目都应具有一个名为'id'的属性作为其唯一标识符。通过使用正确的唯一标识符,keyField 可以帮助优化更新和渲染已经呈现的视图,提高性能和效率。
  • pageMode (默认为 false): 启用页面模式。

  • prerender (默认值为 0):为服务器端渲染(SSR)预渲染固定数量的项目。

  • buffer (默认值 200):在滚动可见区域的边缘增加的像素量,以提前渲染列表项。

  • emitUpdate (默认值 false): 每当虚拟滚动条内容更新时发出 update 事件(可能会影响性能)。

  • listClass (默认值:''):添加到项目列表包装器的自定义类。

  • itemClass (默认值:''):添加到每个列表项的自定义类。

  • listTag(默认值:div):列表包装元素的 html 标签

  • itemTag(默认值:div):列表项的 html 标签(默认插槽内容的直接父级)

Events

  • resize:当虚拟列表组件的大小发生改变时触发的事件。

  • visible:当虚拟列表组件可见时触发的事件

  • hidden:当虚拟列表组件隐藏时触发的事件

  • update (startIndex, endIndex, visibleStartIndex, visibleEndIndex):仅当 emitUpdate 属性为 true 时生效,每次视图更新时会触发的事件

  • scroll-start:当第一个列表项渲染时触发的事件

  • scroll-end:当最后一个列表项渲染时触发的事件

默认作用域插槽的属性

  • item:在视图中渲染的列表项。

  • index:每个列表项在 items 数组中的位置。

  • active:视图是否处于激活状态。激活状态的视图被认为是可见的,并且由 RecycleScroller 进行定位。非激活状态的视图被认为是不可见的,并且对用户隐藏。如果视图处于非激活状态,则应跳过任何与渲染相关的计算。

其它插槽

html 复制代码
<main>
  <slot name="before"></slot>
  <wrapper>
    <!-- Reused view pools here -->
    <slot name="empty"></slot>
  </wrapper>
  <slot name="after"></slot>
</main>

例子:

html 复制代码
<RecycleScroller
  class="scroller"
  :items="list"
  :item-size="32"
>
  <template #before>
    Hey! I'm a message displayed before the items!
  </template>

  <template v-slot="{ item }">
    <div class="user">
      {{ item.name }}
    </div>
  </template>
</RecycleScroller>

页面模式

页面模式扩展 virtual-scroller 并使用页面视口来计算哪些项目是可见的。 这样,您就可以在前面或后面带有 HTML 元素(例如页眉和页脚)的大页面中使用它。 将 page-mode 属性设置为 true

html 复制代码
<header>
  <menu></menu>
</header>

<RecycleScroller page-mode>
  <!-- ... -->
</RecycleScroller>

<footer>
  Copyright 2017 - Cat
</footer>

可变大小模式

⚠️对于很大数据量的场景,这种模式性能可能很差,谨慎使用

如果未设置 itemSize 属性或将其设置为null,虚拟滚动器将切换到可变大小模式。然后,您需要在 item 对象上公开一个 number 类型的字段,该字段记录 item 元素的大小。

⚠️您仍然需要使用 CSS 正确设置项目的大小(例如使用样式类)。

使用 sizeField 属性(默认为 'size' )来设置滚动器使用的字段,该字段用于获取每个项目的大小。

例如:

javascript 复制代码
const items = [
  {
    id: 1,
    label: 'Title',
    size: 64,
  },
  {
    id: 2,
    label: 'Foo',
    size: 32,
  },
  {
    id: 3,
    label: 'Bar',
    size: 32,
  },
]

Buffer

您可以在虚拟滚动器上设置 buffer 属性(以像素为单位),扩展可视区的范围。例如,如果您设置了1000 像素的缓冲区,虚拟滚动器将开始渲染距离滚动器可见区域底部 1000 像素以下的项目,并保留距离可见区域顶部 1000 像素以上的项目。

默认值为 200

html 复制代码
<RecycleScroller :buffer="200" />

服务端渲染

prerender 属性可以在虚拟滚动器内设置为服务器渲染的项目数量:

html 复制代码
<RecycleScroller
  :items="items"
  :item-size="42"
  :prerender="10"
>

DynamicScroller 组件

这与RecycleScroller的工作原理相同,但它可以渲染具有未知大小的列表项!

基本使用

html 复制代码
<template>
  <DynamicScroller
    :items="items"
    :min-item-size="54"
    class="scroller"
  >
    <template v-slot="{ item, index, active }">
      <DynamicScrollerItem
        :item="item"
        :active="active"
        :size-dependencies="[
          item.message,
        ]"
        :data-index="index"
      >
        <div class="avatar">
          <img
            :src="item.avatar"
            :key="item.avatar"
            alt="avatar"
            class="image"
          >
        </div>
        <div class="text">{{ item.message }}</div>
      </DynamicScrollerItem>
    </template>
  </DynamicScroller>
</template>

<script>
export default {
  props: {
    items: Array,
  },
}
</script>

<style scoped>
.scroller {
  height: 100%;
}
</style>

注意事项

  • minItemSize 是必需的,用于初始渲染项目。

  • DynamicScroller 无法自行检测列表项大小的变化,但是您可以通过在 DynamicScrollerItem 上添加 size-dependencies 的值来实现。

  • 您不需要在列表项中设置 size 字段

Props

继承所有 RecycleScroller 组件的 props

  • 不建议更改 sizeField 属性,因为所有大小管理都是在组件内部完成的。

Events

继承所有 RecycleScroller 组件的事件

默认作用域插槽

继承所有 RecycleScroller 默认作用域插槽

其它插槽

继承所有 RecycleScroller 组件的其它插槽

DynamicScrollerItem 组件

该组件应该包裹在 DynamicScroller 中。

Props

  • item(必填项):在滚动器中渲染的数据项。

  • active(必填项):在 RecycleScroller 中是否将保持的视图设置为激活状态。这将防止不必要的列表项的大小的重新计算。

  • sizeDependencies:可以影响列表项的尺寸的值。该属性将被监听,如果其中一个值发生变化,尺寸将重新计算。推荐使用 sizeDependencies 而不是 watchData

  • watchData(默认值:false):深度监听 item 的变化,以重新计算尺寸(不推荐,可能会影响性能)。

  • tag(默认值:'div'):用于指定渲染元素的 html 标签。

  • emitResize(默认值:false):每次尺寸重新计算时发出 resize 事件(可能会影响性能)。

Events

  • resize:仅当 emitResize 属性为 true 时,每次尺寸重新计算时发出的事件。

IdState

这是一个方便的 mixin,可以在 RecycleScroller 中渲染的组件中替换 data

为什么这有用?

由于 RecycleScroller 中的组件是被重复使用的,你不能直接使用 Vue 的标准 data 属性,否则它们将与列表中的不同项目共享! 相反,IdState 会提供一个 idState 对象,它等效于 $data,但它与具有唯一标识符的单个项目相关联(你可以使用 idProp 参数更改哪个字段)。

例子

在这个例子中,我们使用 itemid 来为每个 item 创建一个具有"作用域"的状态。

html 复制代码
<template>
  <div class="question">
    <p>{{ item.question }}</p>
    <button @click="idState.replyOpen = !idState.replyOpen">Reply</button>
    <textarea
      v-if="idState.replyOpen"
      v-model="idState.replyText"
      placeholder="Type your reply"
    />
  </div>
</template>

<script>
import { IdState } from 'vue-virtual-scroller'

export default {
  mixins: [
    IdState({
      // You can customize this
      idProp: vm => vm.item.id,
    }),
  ],

  props: {
    // Item in the list
    item: Object,
  },

  // This replaces data () { ... }
  idState () {
    return {
      replyOpen: false,
      replyText: '',
    }
  },
}
</script>

参数

idProp(默认值:vm => vm.item.id):组件上的字段名称(例如:'id')或返回 id 的函数。

vue-virtual-scroller 源码分析

components\RecycleScroller.vue 文件分析

html 复制代码
<template>
  <div v-observe-visibility="handleVisibilityChange">
  </div>
</template>

v-observe-visibility 是能够检测页面元素是否可见的自定义指令,是 vue-observe-visibility 包提供的能力。

html 复制代码
<template>
  <ResizeObserver @notify="handleResize" />
</template>

ResizeObserver 组件是由 vue-resize 包提供的,可以用于监测 dom 元素大小调整,当 dom 元素的大小发生了变化后,该组件会触发 notify 事件。更多可以参考 vue-resize深度解读vue-resize

html 复制代码
<template>
    <!-- 滚动事件的默认行为 (scrolling) 将立即发生而非等待 `onScroll` 完成 -->
    <!-- 以防其中包含 `event.preventDefault()` -->
    <div
      @scroll.passive="handleScroll"
    >
    <!-- 代码省略 -->
    </div>
</template>

.passive ,修饰符一般用于触摸事件的监听器,可以用来改善移动端设备的滚屏性能。参考 事件修饰符

$slots ,表示父组件传入插槽的对象。可用于检测是否存在插槽,为 Vue 组件提供了获取插槽内容的能力。组件实例 | Vue.js

html 复制代码
<div
  v-if="$slots.before"
  ref="before"
  class="vue-recycle-scroller__slot"
>
  <slot
    name="before"
  />
</div>

用于放置在列表前展示的内容的具名插槽

html 复制代码
<div
  v-if="$slots.after"
  ref="after"
  class="vue-recycle-scroller__slot"
>
  <slot
    name="after"
  />
</div>

用于放置在列表后展示的内容的具名插槽

html 复制代码
<component
>
  <!-- 代码省略 -->
  <slot
    name="empty"
  />
</component>

用于放置空状态占位的具名插槽


整个 vue-virtual-scroller 组件的主要原理集中在 updateVisibleItems 方法,该方法会在 itemspageModesizesgridItemsitemSecondarySize 变化时调用。

js 复制代码
watch: {
  items () {
    this.updateVisibleItems(true)
  },

  pageMode () {
    this.applyPageMode()
    this.updateVisibleItems(false)
  },

  sizes: {
    handler () {
      this.updateVisibleItems(false)
    },
    deep: true,
  },

  gridItems () {
    this.updateVisibleItems(true)
  },

  itemSecondarySize () {
    this.updateVisibleItems(true)
  },
},

同时 updateVisibleItems 方法也会在调整页面大小、滚动、页面初始化(mountedcreated 生命周期)中调用。

js 复制代码
created () {
  // 省略部分代码
  this.updateVisibleItems(false)
}
js 复制代码
mounted () {
  // 省略部分代码
  this.updateVisibleItems(true)
}
js 复制代码
handleResize () {
  this.$emit('resize')
  if (this.ready) this.updateVisibleItems(false)
}
js 复制代码
handleScroll (event) {
  // 省略代码
  const { continuous } = this.updateVisibleItems(false, true)
}

updateVisibleItems 整体分析(忽略细节)

  1. 初始化相关变量

  2. 根据滚动区域的高(宽)度,计算需要展示列表的区间,即计算出可视区的起始索引(startIndex)和终止索引(endIndex

  3. startIndexendIndex 之间的数据更新到 pool 数组

updateVisibleItems 整体思路拆解:

关键在于如何根据可视区域的高度和各项列表的高度,快速(高性能)地计算出需要在可视区展示的开始索引和结束索引。

RecycleScroller 组件渲染的列表数据是 pool 数组

html 复制代码
<ItemView
  v-for="view of pool"
  ref="items"
  :key="view.nr.id"
  :view="view"
  :item-tag="itemTag"
>
  <!-- 忽略一些代码 -->
</ItemView>

pool 数组的长度是根据滚动区域的大小计算的,当虚拟列表可视区占满了列表项后,pool 数组的大小就不会改变了

一旦 pool 的长度被塞满后,里面的每一项的数据的引用都不会改变,改变的只是每个数据项中的 itemnr.index 等属性的值

js 复制代码
if (view) {
  view.item = item
  view.nr.index = i
  view.nr.key = key
  if (view.nr.type !== type) {
    console.warn("Reused view's type does not match pool's type")
  }
}

pool 中的每一项数据都可以在 views$_views) 中找到与之对应的数据,并且它们的引用相同,因此改动其中一项,另外一项也会跟着改变

js 复制代码
createView (pool, index, item, key, type) {
  const nr = markRaw({
    id: uid++,
    index,
    used: true,
    key,
    type,
  })
  const view = shallowReactive({
    item,
    position: 0,
    nr,
  })
  // 将 view push 进 pool
  pool.push(view)
  return view
}
// 省略了细节代码
view = views.get(key)
view = this.createView(pool, i, item, key, type)
// 将 view set 进 views
views.set(key, view)

$_recycledPools 是复用池,根据 type 存储不在可视区的视图。每次滚动先把超出可视区的丢到 $_recycledPools,丢完之后。进行startIndex 和 endIndex 之间的可视区遍历,在新增 view 出现的时候优先在 $_recycledPools 中找,找到就取出来。找不到则走 createView

js 复制代码
// 以下代码均省略代码细节

removeAndRecycleView (view) {
  const type = view.nr.type
  const recycledPool = this.getRecycledPool(type)
  recycledPool.push(view)
  // 将 view 设置为不可见的
  view.nr.used = false
  view.position = -9999
  this.$_views.delete(view.nr.key)
}


for (let i = 0, l = pool.length; i < l; i++) {
  // 视图回收
  view = pool[i]
  if (view.nr.used) {
    const viewVisible = view.nr.index >= startIndex && view.nr.index < endIndex
    const viewSize = itemSize || sizes[i].size
    if (!viewVisible || !viewSize) {
      this.removeAndRecycleView(view)
    }
  }
}

for (let i = startIndex; i < endIndex; i++) {
  view = this.getRecycledView(type)

  if (!view) {
    view = this.getRecycledView(type)
    if (view) {
      view.item = item
    } else {
      view = this.createView(pool, i, item, key, type)
    }    
  }
}

createView (pool, index, item, key, type) {
  const nr = markRaw({
    id: uid++,
    index,
    used: true,
    key,
    type,
  })
  const view = shallowReactive({
    item,
    position: 0,
    nr,
  })
  pool.push(view)
  return view
}

为了提升计算的性能,提前在计算属性 sizes 维护了记录了高度与累计高度的数组,避免了后续的重复计算

js 复制代码
sizes () {
  if (this.itemSize === null) {
    const sizes = {
      '-1': { accumulator: 0 },
    }
    const items = this.items
    const field = this.sizeField
    const minItemSize = this.minItemSize
    let computedMinSize = 10000
    let accumulator = 0
    let current
    for (let i = 0, l = items.length; i < l; i++) {
      current = items[i][field] || minItemSize
      if (current < computedMinSize) {
        computedMinSize = current
      }
      accumulator += current
      sizes[i] = { accumulator, size: current }
    }
    // eslint-disable-next-line
    this.$_computedMinItemSize = computedMinSize
    return sizes
  }
  return []
}

相关变量初始化:

ts 复制代码
updateVisibleItems (itemsChanged, checkPositionDiff = false) {
  // 列表项的高/宽度
  const itemSize = this.itemSize
  // 在同一行上显示 gridItems 项,创建一个网格布局
  const gridItems = this.gridItems || 1
  // 当 gridItems 被设置时,网格中项目的像素大小(在纵向模式下为宽度,在横向模式下为高度)。如果未设置 itemSecondarySize ,则将使用 itemSize 的值。
  const itemSecondarySize = this.itemSecondarySize || itemSize
  // 项目的最小尺寸
  const minItemSize = this.$_computedMinItemSize
  // 用于区分列表中不同类型组件的字段。对于每种不同的类型,将创建一组可重用的项目的资源池
  const typeField = this.typeField
  // 如果是简单数据 keyField 设置为 null ,否则取用户传入的 keyField
  const keyField = this.simpleArray ? null : this.keyField
  const items = this.items
}

minItemSize 为项目的最小尺寸,在 computed 属性 sizes 中计算得出。

在计算属性 sizes 中,会遍历需要渲染的数组 items,然后从数组项中取出最小的尺寸赋值给 $_computedMinItemSize

js 复制代码
sizes () {
  if (this.itemSize === null) {
    const sizes = {
      '-1': { accumulator: 0 },
    }
    const items = this.items
    const field = this.sizeField
    const minItemSize = this.minItemSize
    let computedMinSize = 10000
    let accumulator = 0
    let current
    for (let i = 0, l = items.length; i < l; i++) {
      current = items[i][field] || minItemSize
      if (current < computedMinSize) {
        computedMinSize = current
      }
      accumulator += current
      sizes[i] = { accumulator, size: current }
    }
    // eslint-disable-next-line
    this.$_computedMinItemSize = computedMinSize
    return sizes
  }
  return []
},
js 复制代码
// 获取当前可视区的范围,getScroll 根据 scrollerTop 等计算
const scroll = this.getScroll()
  • shallowReactive() ,可以创建一个浅层响应式对象。它只会对根级别的属性进行响应式转换,不会对嵌套属性进行递归转换。这样可以减少依赖追踪和触发响应的开销。在某些场景下,我们可能不需要对所有属性进行深层次的响应式处理,使用 shallowReactive 可以提升性能。 响应式 API:进阶 | Vue.js

  • markRaw() ,将一个对象标记为不可被转为代理。返回该对象本身。响应式 API:进阶 | Vue.js

    • 当呈现带有不可变数据源的大型列表时,跳过代理转换可以提高性能。

这两个函数可以在某些特定场景下提供更细粒度的控制,并且帮助我们在性能和功能上做出更好的权衡。

js 复制代码
import { shallowReactive, markRaw } from 'vue'

createView (pool, index, item, key, type) {
  const nr = markRaw({
    id: uid++,
    index,
    used: true,
    key,
    type,
  })
  const view = shallowReactive({
    item,
    position: 0,
    nr,
  })
  pool.push(view)
  return view
}

startIndexendIndex 的计算分为两种情况

  1. 动态大小模式(Variable size mode,列表项的高/宽度不是固定的)

  2. 固定大小模式(Fixed size mode,列表项的高/宽度是固定的)

在动态大小模式的情况下,为了快速找到 startIndexendIndex 使用了二分查找算法

js 复制代码
let h
let a = 0
let b = count - 1
let i = ~~(count / 2)
let oldI

// Searching for startIndex
do {
  oldI = i
  h = sizes[i].accumulator
  if (h < scroll.start) {
    a = i
  } else if (i < count - 1 && sizes[i + 1].accumulator > scroll.start) {
    b = i
  }
  i = ~~((a + b) / 2)
} while (i !== oldI)
i < 0 && (i = 0)
startIndex = i

固定大小模式的场景计算 startIndexendIndex 较为简单,简单地说就是滚动区域的高/宽度除以每一个列表项的高/宽度。

js 复制代码
startIndex = ~~(scroll.start / itemSize * gridItems)
const remainer = startIndex % gridItems
startIndex -= remainer
endIndex = Math.ceil(scroll.end / itemSize * gridItems)

applyPageMode 方法分析:

applyPageMode 主要是调用了两个函数 addListenersremoveListeners

js 复制代码
applyPageMode () {
  if (this.pageMode) {
    this.addListeners()
  } else {
    this.removeListeners()
  }
}

removeListeners 取消事件监听和删除相关 dom ,防止内存泄漏

js 复制代码
removeListeners () {
  if (!this.listenerTarget) {
    return
  }

  this.listenerTarget.removeEventListener('scroll', this.handleScroll)
  this.listenerTarget.removeEventListener('resize', this.handleResize)

  this.listenerTarget = null
}

如果是页面模式,则会相应添加页面的事件监听:

js 复制代码
addListeners () {
  this.listenerTarget = this.getListenerTarget()
  this.listenerTarget.addEventListener('scroll', this.handleScroll, supportsPassive
    ? {
        passive: true,
      }
    : false)
  this.listenerTarget.addEventListener('resize', this.handleResize)
},

其中 getListenerTarget 函数用于获取监听的 dom 对象,其中的核心,则是 getScrollParent 函数。

js 复制代码
getListenerTarget () {
  let target = getScrollParent(this.$el)
  // Fix global scroll target for Chrome and Safari
  if (window.document && (target === window.document.documentElement || target === window.document.body)) {
    target = window
  }
  return target
}

$el 指向 Vue 组件实例的 DOM 根节点。可以使用 $el 属性来添加/删除类名、修改元素的文本内容、设置样式等。

$el 属性只有在 Vue 实例真正挂载到 DOM 元素后才会被设置。因此,如果在 Vue 实例挂载之前访问 $el 属性,将会返回undefined组件实例 | Vue.js

getScrollParent 函数的主要作用则是找到列表的第一个有滚动条的父 DOM 元素,如果没有则是 document 节点,即 html 元素。由于 getScrollParent 是找到第一个有 scroll 样式的父元素,因此在 page-mode 模式下,虚拟列表组件的父元素尽量不要手动设置任何 overflow 样式,否则会出现列表渲染异常或者无法出现页面级别的滚动条

js 复制代码
export function getScrollParent (node) {
  if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
    return
  }

  const ps = parents(node.parentNode, [])

  for (let i = 0; i < ps.length; i += 1) {
    if (scroll(ps[i])) {
      return ps[i]
    }
  }

  return document.scrollingElement || document.documentElement
}

判断元素是否有滚动条主要是使用以下函数配合

js 复制代码
// 递归找到所有的父 DOM 元素
function parents (node, ps) {
  if (node.parentNode === null) { return ps }

  return parents(node.parentNode, ps.concat([node]))
}

// 获取 node 相关样式中的 prop 属性值
const style = function (node, prop) {
  return getComputedStyle(node, null).getPropertyValue(prop)
}

// 获取 overflow 、overflow-y、overflow-x 的属性值
const overflow = function (node) {
  return style(node, 'overflow') + style(node, 'overflow-y') + style(node, 'overflow-x')
}

const regex = /(auto|scroll)/
// 判断是否有滚动属性
const scroll = function (node) {
  return regex.test(overflow(node))
}

components\ItemView.vue 文件分析

ItemView 组件的功能较简单,使用作用域插槽,将数据传递给父组件

html 复制代码
<template>
  <component
    :is="itemTag"
    class="vue-recycle-scroller__item-view"
  >
    <slot
      :item="view.item"
      :index="view.nr.index"
      :active="view.nr.used"
    />
  </component>
</template>
<script>
export default {
  props: {
    view: Object,
    itemTag: String,
  },
}
</script>

components\DynamicScroller.vue 与 components\DynamicScrollerItem.vue文件分析

DynamicScroller 可以渲染列表项高/宽度未知的情况。同时由于无法提前知道列表项的高/宽度,会出现渲染列表的时候一开始列表项没有高/宽度,等列表项有内容的时候才有高/宽度而造成的页面闪烁的情况。

DynamicScroller 组件是通过封装 RecycleScroller 组件得来

html 复制代码
<template>
  <RecycleScroller
    ref="scroller"
    :items="itemsWithSize"
    :min-item-size="minItemSize"
    :direction="direction"
    key-field="id"
    :list-tag="listTag"
    :item-tag="itemTag"
    v-bind="$attrs"
    @resize="onScrollerResize"
    @visible="onScrollerVisible"
  >
    <template #default="{ item: itemWithSize, index, active }">
      <slot
        v-bind="{
          item: itemWithSize.item,
          index,
          active,
          itemWithSize
        }"
      />
    </template>
  </RecycleScroller>
</template>

keyField 此处写死为 idkeyField 用于标识项目并优化管理已渲染视图的字段。

DynamicScroller 组件渲染的列表数据是其中的计算属性 itemsWithSize,其返回值 result 中的 sizeDynamicScrollerItem 组件内部计算得出。

js 复制代码
itemsWithSize () {
  const result = []
  const { items, keyField, simpleArray } = this
  const sizes = this.vscrollData.sizes
  const l = items.length
  for (let i = 0; i < l; i++) {
    const item = items[i]
    const id = simpleArray ? i : item[keyField]
    let size = sizes[id]
    if (typeof size === 'undefined' && !this.$_undefinedMap[id]) {
      size = 0
    }
    result.push({
      item,
      id,
      size,
    })
  }
  return result
}

updateSize() 计算列表项 size 的入口函数

js 复制代码
mounted() {
  this.updateSize()
}
updateSize () {
  if (this.finalActive) {
    if (this.$_pendingSizeUpdate !== this.id) {
      this.computeSize(this.id)
    }
  }
}

computeSize() 获取列表项的 offsetWidthoffsetHeight

js 复制代码
computeSize (id) {
  this.$nextTick(() => {
    if (this.id === id) {
      const width = this.$el.offsetWidth
      const height = this.$el.offsetHeight
      this.applyWidthHeight(width, height)
    }
  })
}

applyWidthHeight() 区分水平、垂直模式

js 复制代码
applyWidthHeight (width, height) {
  const size = ~~(this.vscrollParent.direction === 'vertical' ? height : width)
  if (size && this.size !== size) {
    this.applySize(size)
  }
}

applySize() 将 size 传递给父组件的 vscrollData

js 复制代码
applySize (size) {
  this.vscrollData.sizes[this.id] = size
}

综合上面 4 个函数分析可知,DynamicScrollerItem 计算列表项 size 的方法就是等内容充满列表项的 dom 元素后,读取其 offsetWidthoffsetHeight,然后赋值给 vscrollData

计算属性 finalActive ,用于判断 DynamicScrollerItem 、DynamicScroller 组件是否已挂载

js 复制代码
finalActive () {
  return this.active && this.vscrollData.active
}

DynamicScroller、DynamicScrollerItem 通过 provide/inject 进行通信。

$_resizeObserver 是 ResizeObserver 对象,通过 provide 传递给 DynamicScrollerItem。在 DynamicScroller 中通过该 ResizeObserver 对象监听 DynamicScrollerItem 大小的变化,当 DynamicScrollerItem 大小变化后,通过调用 $_vs_onResize 方法,将最新的 size 保存到 vscrollData 中。

inlineSize ,简单地说就是 dom 的宽度;blockSize,简单地说就是 dom 的高度。ResizeObserverEntry.contentBoxSize

js 复制代码
provide () {
  if (typeof ResizeObserver !== 'undefined') {
    this.$_resizeObserver = new ResizeObserver(entries => {
      requestAnimationFrame(() => {
        if (!Array.isArray(entries)) {
          return
        }
        for (const entry of entries) {
          if (entry.target && entry.target.$_vs_onResize) {
            let width, height
            if (entry.borderBoxSize) {
              const resizeObserverSize = entry.borderBoxSize[0]
              width = resizeObserverSize.inlineSize
              height = resizeObserverSize.blockSize
            } else {
              // @TODO remove when contentRect is deprecated
              width = entry.contentRect.width
              height = entry.contentRect.height
            }
            entry.target.$_vs_onResize(entry.target.$_vs_id, width, height)
          }
        }
      })
    })
  }

  return {
    vscrollData: this.vscrollData,
    vscrollParent: this,
    vscrollResizeObserver: this.$_resizeObserver,
  }
},
js 复制代码
inject: [
  'vscrollData',
  'vscrollParent',
  'vscrollResizeObserver',
]

$_vs_onResize 是在 DynamicScrollerItem 中定义的

js 复制代码
observeSize () {
  this.$el.$_vs_onResize = this.onResize
},

DynamicScroller 与 DynamicScrollerItem 也通过事件总线进行通信,其中利用了小型的 JavaScript 事件总线库 mitt。它提供了一种简洁的方式来创建和管理事件及其对应的监听器。

js 复制代码
import mitt from 'mitt'

this.$_events = mitt()

this.$_events.emit('vscroll:update', { force: false })

DynamicScroller 通过 provide 将自身实例传递给 DynamicScrollerItem ,在 DynamicScrollerItem 通过 DynamicScroller 的实例获取 $_events ,监听相应的事件

js 复制代码
this.vscrollParent.$_events.on('vscroll:update', this.onVscrollUpdate)

禁用一个组件自动地继承 attribute,你可以在组件选项中设置 inheritAttrs: false禁用 Attributes 继承

总结

vue-virtual-scroller 的实现很精妙,其中有非常多在日常开发中可以借鉴的点

  • 仅在内部使用,并且不需要响应式特性的数据,可以直接在 this 中赋值,不要在 data 中声明,这样可以减少响应式数据的包装,从而提升性能
js 复制代码
created () {
  this.$_startIndex = 0
  this.$_endIndex = 0
  // Visible views by their key
  this.$_views = new Map()
  // Pools of recycled views, by view type
  this.$_recycledPools = new Map()
  this.$_scrollDirty = false
  this.$_lastUpdateScrollPosition = 0
}
  • 使用 ResizeObserver 更加先进的 API 监听 dom 的大小的变化,而不是监听 resize 事件
js 复制代码
<ResizeObserver @notify="handleResize" />
js 复制代码
if (typeof ResizeObserver !== 'undefined') {
  this.$_resizeObserver = new ResizeObserver(entries => {
    requestAnimationFrame(() => {
      if (!Array.isArray(entries)) {
        return
      }
      for (const entry of entries) {
        if (entry.target && entry.target.$_vs_onResize) {
          let width, height
          if (entry.borderBoxSize) {
            const resizeObserverSize = entry.borderBoxSize[0]
            width = resizeObserverSize.inlineSize
            height = resizeObserverSize.blockSize
          } else {
            // @TODO remove when contentRect is deprecated
            width = entry.contentRect.width
            height = entry.contentRect.height
          }
          entry.target.$_vs_onResize(entry.target.$_vs_id, width, height)
        }
      }
    })
  })
}
  • 使用 requestAnimationFrame 做滚动事件的节流。

使用 requestAnimationFrame 做节流比 setTimeout 好很多,一方面可以充分利用高性能、高刷新率设备,从而带来更流畅的交互体验,另一方面可以避免 setTimeout 处理回调函数时间不准确的问题,同时,在页面处理非活动状态时,requestAnimationFrame 回调函数会自动停止,从而提升性能和电池寿命。使用 requestAnimationFrame 替代 throttle 优化页面性能【前端性能】高性能滚动 scroll 及页面渲染优化 - ChokCoco - 博客园

js 复制代码
handleScroll (event) {
  if (!this.$_scrollDirty) {
    this.$_scrollDirty = true
    if (this.$_updateTimeout) return

    const requestUpdate = () => requestAnimationFrame(() => {
      this.$_scrollDirty = false
      const { continuous } = this.updateVisibleItems(false, true)

      // It seems sometimes chrome doesn't fire scroll event :/
      // When non continous scrolling is ending, we force a refresh
      if (!continuous) {
        clearTimeout(this.$_refreshTimout)
        this.$_refreshTimout = setTimeout(this.handleScroll, this.updateInterval + 100)
      }
    })

    requestUpdate()
  }
}
  • 使用 Map 数据结构代替数组的循环查找,Map 比数组有更高的查找性能。在没法使用 Map 数据结构的情况下尽量使用高效率的查找算法,例如:二分法。
js 复制代码
// 省略细节代码
this.$_views = new Map()
const views = this.$_views
js 复制代码
// 省略细节代码
let h
let a = 0
let b = count - 1
let i = ~~(count / 2)
let oldI

// Searching for startIndex
do {
  oldI = i
  h = sizes[i].accumulator
  if (h < scroll.start) {
    a = i
  } else if (i < count - 1 && sizes[i + 1].accumulator > scroll.start) {
    b = i
  }
  i = ~~((a + b) / 2)
} while (i !== oldI)
i < 0 && (i = 0)
startIndex = i
  • 巧用 shallowReactive()markRaw() API ,减少数据依赖追踪和触发响应的开销,从而提高组件的性能
js 复制代码
import { shallowReactive, markRaw } from 'vue'

createView (pool, index, item, key, type) {
  const nr = markRaw({
    id: uid++,
    index,
    used: true,
    key,
    type,
  })
  const view = shallowReactive({
    item,
    position: 0,
    nr,
  })
  pool.push(view)
  return view
}
  • 推荐使用 mitt 创建事件总线做简单的事件管理,因为它非常小,可以减少资源占用。

参考

vue-virtual-scroller - npm

vue-virtual-scroller源码分析

相关推荐
百万蹄蹄向前冲1 小时前
2024不一样的VUE3期末考查
前端·javascript·程序员
alikami1 小时前
【若依】用 post 请求传 json 格式的数据下载文件
前端·javascript·json
wakangda2 小时前
React Native 集成原生Android功能
javascript·react native·react.js
吃杠碰小鸡2 小时前
lodash常用函数
前端·javascript
emoji1111112 小时前
前端对页面数据进行缓存
开发语言·前端·javascript
一个处女座的程序猿O(∩_∩)O2 小时前
vue3 如何使用 mounted
前端·javascript·vue.js
迷糊的『迷』2 小时前
vue-axios+springboot实现文件流下载
vue.js·spring boot
User_undefined2 小时前
uniapp Native.js原生arr插件服务发送广播到uniapp页面中
android·javascript·uni-app
web135085886352 小时前
uniapp小程序使用webview 嵌套 vue 项目
vue.js·小程序·uni-app
麦兜*2 小时前
轮播图带详情插件、uniApp插件
前端·javascript·uni-app·vue