实用的VUE系列——我们怎么用vue实现一个虚拟滚动插件?

597 阅读14分钟

声明:本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

虚拟滚动 是前端领域的一个很常见的技术方案,它出现在我们工作中的方方面面,面试要考业务要用性能要优化, 数据要展示

都都离不开虚拟滚动 的背影

于是,最近闲来无事,浅浅的研究了一下, 不成想技术原理还挺深奥

细究之后,总结为这篇文章,跟这位jym 汇报一下

虚拟滚动有哪些应用场景

说起虚拟滚动的应用场景,我们还是要追溯到问题的本质解答问题。

我们为什么要使用虚拟滚动?

答案很简单,不用虚拟滚动页面他卡啊

有的jym 就开始问了,为啥会卡呢?

那我就要从丘处机路过牛家村开始了

我们知道,页面之所以卡顿是,是因为同时渲染的元素太多了

image.png

大家 可以发现,一个select标签,只是简单的渲染上千条数文案数据

image.png

他就耗时接近两秒,于是,我们在这两秒内,我们就无法进行任何操作,

具体为什么无法操作,这属于浏览器工作原理的范畴。我们就不再废唾沫星子了

如果有兴趣,可以去这重学前端(五)——谈谈前端性能优化

简而言之,就是单线程的特性,导致js执行和渲染是互斥的,无法同时进行,只能将js 放入eventloop 队列中,延后执行

于是有了这些片汤话的铺垫,我们就能很简单的得出结论 :大连数据的列表都可以使用虚拟滚动

接下来,他的应用场景就能呼之欲出了,比如在web页面中,table表格listselecttree等通用场景,都是虚拟滚动的需要应用的地方。

虚拟滚动原理

我们在之前的 推导中找到了需要应用的地方, 接下来就该怎样应用了,也就是 虚拟滚动原理

其实虚拟滚动 听起来很玄乎,仿佛是一个高大上的技术方案,其实他的原理很简单,

在目前的行业实践中,主要有两个方向

  • 1、根据滑动位置,动态加载内容
  • 2、只渲染可视区域的列表项,非可见区域的**不渲染

别急我们一个一个讲

1、根据滑动位置,动态加载内容

这是一个非常讨巧的方案, 因为他的原理很有意思,你不是说,同时加载卡吗?

那我一点点加载分批来不就解决了吗?

element-plus Infinite Scroll 就是这种方案,是一个指令插件,使用方式很简单

  <ul v-infinite-scroll="load" class="infinite-list" style="overflow: auto">
        <li v-for="i in count" :key="i" class="infinite-list-item">{{ i }}</li>
   </ul>

其实,本质上来说,他是什么类型的插件无所谓,形式不重要,内容才重要!!

我们来简单的研究一下他的内容(也就是源码)

// @ts-nocheck
import { nextTick } from 'vue'
import { isFunction } from '@vue/shared'
import { throttle } from 'lodash-unified'
import {
  getOffsetTopDistance,
  getScrollContainer,
  throwError,
} from '@element-plus/utils'

import type { ComponentPublicInstance, ObjectDirective } from 'vue'
// 一些常量
export const SCOPE = 'ElInfiniteScroll'
export const CHECK_INTERVAL = 50
export const DEFAULT_DELAY = 200
export const DEFAULT_DISTANCE = 0
// 默认属性
const attributes = {
  delay: {
    type: Number,
    default: DEFAULT_DELAY,
  },
  distance: {
    type: Number,
    default: DEFAULT_DISTANCE,
  },
  disabled: {
    type: Boolean,
    default: false,
  },
  immediate: {
    type: Boolean,
    default: true,
  },
}
// 类型定义
type Attrs = typeof attributes
type ScrollOptions = { [K in keyof Attrs]: Attrs[K]['default'] }
type InfiniteScrollCallback = () => void
type InfiniteScrollEl = HTMLElement & {
  [SCOPE]: {
    container: HTMLElement | Window
    containerEl: HTMLElement
    instance: ComponentPublicInstance
    delay: number // export for test
    lastScrollTop: number
    cb: InfiniteScrollCallback
    onScroll: () => void
    observer?: MutationObserver
  }
}
// 获取一下外部传入的属性
const getScrollOptions = (
  el: HTMLElement,
  instance: ComponentPublicInstance
): ScrollOptions => {
  return Object.entries(attributes).reduce((acm, [name, option]) => {
    const { type, default: defaultValue } = option
    const attrVal = el.getAttribute(`infinite-scroll-${name}`)
    let value = instance[attrVal] ?? attrVal ?? defaultValue
    value = value === 'false' ? false : value
    value = type(value)
    acm[name] = Number.isNaN(value) ? defaultValue : value
    return acm
  }, {} as ScrollOptions)
}
// 销毁 dom 监听
const destroyObserver = (el: InfiniteScrollEl) => {
  const { observer } = el[SCOPE]

  if (observer) {
    observer.disconnect()
    delete el[SCOPE].observer
  }
}
// 滚动条事件
const handleScroll = (el: InfiniteScrollEl, cb: InfiniteScrollCallback) => {
  // 取出实例中的内容
  const { container, containerEl, instance, observer, lastScrollTop } =
    el[SCOPE]
  // 同样的获取属性
  const { disabled, distance } = getScrollOptions(el, instance)
  // 拿到他的容器高度,滚动的总共高度,举例顶部的举例
  const { clientHeight, scrollHeight, scrollTop } = containerEl
  // 获取这一次滚动了多少
  const delta = scrollTop - lastScrollTop
  // 保存当前这次滚动距离,方便下一次计算
  el[SCOPE].lastScrollTop = scrollTop

  // 判断一些特殊情况,如果往上滑,或者禁用的时候,就不处理
  if (observer || disabled || delta < 0) return

  let shouldTrigger = false
  // 如果绑定的指令就是容器
  if (container === el) {
    // 计算是否需要执行函数
    shouldTrigger = scrollHeight - (clientHeight + scrollTop) <= distance
  } else {
    // 如果绑定的指令不是容器,那么就用另一种计算方式
    const { clientTop, scrollHeight: height } = el
    const offsetTop = getOffsetTopDistance(el, containerEl)
    shouldTrigger =
      scrollTop + clientHeight >= offsetTop + clientTop + height - distance
  }
  // 如果判断出来的距离需要加载新数据
  if (shouldTrigger) {
    // 那么就执行下拉获取新数据
    cb.call(instance)
  }
}

function checkFull(el: InfiniteScrollEl, cb: InfiniteScrollCallback) {
  // 从SCOPE 中取出实例
  const { containerEl, instance } = el[SCOPE]
  // 判断禁用情况
  const { disabled } = getScrollOptions(el, instance)
  // 如果有禁用 视口高度等于0 等情况,那就直接回退
  if (disabled || containerEl.clientHeight === 0) return
  // 然后就判断,如果滑动宽度比 视口还小
  if (containerEl.scrollHeight <= containerEl.clientHeight) {
    // 那就说明可能要执行一次函数了 拉取下一页
    cb.call(instance)
  } else {
    // 否要就要清除监听
    // 移除监听的原因是因为,他出现滚动条了,就可以执行滚动事件了
    // 不在需要靠监听 dom 变动来解决问题
    destroyObserver(el)
  }
}
// 核心代码在这
// 指令型插件,很多生命周期
const InfiniteScroll: ObjectDirective<
  InfiniteScrollEl,
  InfiniteScrollCallback
> = {
  // dom初始化执行
  async mounted(el, binding) {
    //取出回调函数
    const { instance, value: cb } = binding
    // 兜底判断
    if (!isFunction(cb)) {
      throwError(SCOPE, "'v-infinite-scroll' binding value must be a function")
    }
    // 防止没有dom 出问题,用nextTick 处理一下
    await nextTick()
    // 拿到其中的一些默认配置
    const { delay, immediate } = getScrollOptions(el, instance)
    //  获取滚动条层的dom容器
    const container = getScrollContainer(el, true)
    // 判断容器是不是window 因为如果是window的话,就必须啊找到他下头的第一个节点
    // 因为window 是不能滚动的
    const containerEl =
      container === window
        ? document.documentElement
        : (container as HTMLElement)
    const onScroll = throttle(handleScroll.bind(null, el, cb), delay)

    if (!container) return
    // 绑定环境,因为在页面中可能会有很多个虚拟滚动实例
    // 所以我们要将每个实例保存起来方便后续取用
    // 这里的技巧就是绑定在el上,后续给大家说好处
    el[SCOPE] = {
      instance,
      container,
      containerEl,
      delay,
      cb,
      onScroll,
      lastScrollTop: containerEl.scrollTop,
    }
    //immediate 表示是否立即执行加载
    if (immediate) {
      // 如果立即执行,那么就监听dom 变化
      const observer = new MutationObserver(
        throttle(checkFull.bind(null, el, cb), CHECK_INTERVAL)
      )
      // 保存实例
      el[SCOPE].observer = observer
      // 启动监听,针对当前el 下方的所有dom 变动
      observer.observe(el, { childList: true, subtree: true })
      // 执行检查函数,主要就是为了判断是不是到底了,包括每次监听dom 变化也是这个原因
      // 这个方法就是为了防止我没有盛满容器,有不能出发scroll 事件,从而,用的兜底策略
      // 利用监听dom 变化来多次监听从而多次执行获取新内容函数
      checkFull(el, cb)
    }
    // 绑定滑动时间,实时计算距离,是否需要下拉新内容
    container.addEventListener('scroll', onScroll)
  },
  //dom 卸载
  unmounted(el) {
    if (!el[SCOPE]) return
    const { container, onScroll } = el[SCOPE]

    container?.removeEventListener('scroll', onScroll)
    destroyObserver(el)
  },
  // dom 更新
  async updated(el) {
    // 如果没有实例就不管了
    if (!el[SCOPE]) {
      await nextTick()
    } else {
      // 如果有的话,要重新检查一下
      const { containerEl, cb, observer } = el[SCOPE]
      if (containerEl.clientHeight && observer) {
        checkFull(el, cb)
      }
    }
  },
}

export default InfiniteScroll

其实上述代码中洋洋洒洒写了这么多,其实主要就干了一个事情

利用滚动条的scroll 事件,判断滚动是否到底,如果到底则动态加载新数据,如此而已

之前我说过,他的优秀之处,不是形式,而是内容,因为他内部做了大量的兼容,以及小妙招对于我们日常的开发大有裨益

  • 1、 指令实例保存方式
  • 2、 利用MutationObserver保证兜底策略

指令实例保存方式

这是一个非常新颖的方案,在这之前,我们知道指令内部是无法保存实例的,如果当指令初始化之后,指令外部想要使用指令初始化之后的实例,我们大多数人的常规操作,将实例 挂载到全局,而这么一来就会有个问题,如果我有多个实例呢?

所以这个插件的保存实例 方式就很巧妙,将内容挂载到 dom 上,既解决了实例保存的问题,有解决了多指令获取实例的问题。

我们想要使用实例的时候只需要

//html
<ul ref="infiniteRef" v-infinite-scroll="load" class="infinite-list" style="overflow: auto">
<li v-for="i in count" :key="i" class="infinite-list-item">{{ i }}</li>
</ul>

// js
const infiniteRef = ref(null);
// 从 dom 中取实例
contentRef.value.ElInfiniteScroll.observer()

利用MutationObserver保证兜底策略

MutationObserver 我就不再赘述了,他是一个监听 domapi 但从来没有人会想到利用MutationObserver 去主动更新 dom 这其实就是一个创新升级,希望在我们搬砖的项目中,可以借鉴。

第二种方案指的是渲染可视区域的列表项,非可见区域的不渲染

2、只渲染可视区域的列表项,非可见区域的不渲染

Kapture 2024-08-05 at 16.11.04.gif

上图我们可以发现,不过表格怎么变化,dom 就那么几个

朴实无华,主旨很简单, 分治思想 我们只管当前只一片就行

很多人可能不太理解,那么我就用一个图,来给大家生动的展示一下

image.png

尽力了,原谅我骥某人才疏学浅,只能画成这样了,各位jym 凑活看吧

画的虽然有点ugly,但表达的东西,相信大家都能看懂

我们只是将一部分视口看到的内容展示出来, 其他的假装展示了,反正你也看不见

可接下来问题来了,我展示是展示了,可我要滑动页面,怎么更新视口内容呢?

如何更新视口内容

如何更新视口,其实本质上就是我们如何能让那几十个我们能看得见的 dom 永远在视口处活动就可以

那应该怎么做呢? 我们首先可以确定两点

  • 1、一定要监听滚动事件scroll
  • 2、当滚动条滚动的时候,要移动相应内容到视口上来

ok,一拍即合,我们来实现一下

<script setup lang="ts">
import { ref } from 'vue'
const paddingTop = ref(0)
const scroll = (e) => {
  if (e.target.scrollTop % 200 < 20) {
    paddingTop.value = e.target.scrollTop
  }
}
</script>

<template>
  <div class="warp" @scroll="scroll">
    <div class="scroll-box" :style="`padding-top:${paddingTop}px`">
      <div class="test-item">这是测试 item</div>
      <div class="test-item">这是测试 item</div>
      <div class="test-item">这是测试 item</div>
      <div class="test-item">这是测试 item</div>
      <div class="test-item">这是测试 item</div>
      <div class="test-item">这是测试 item</div>
      <div class="test-item">这是测试 item</div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.warp {
  height: 300px;
  width: 500px;
  border: 1px solid #000;
  overflow: auto;
  margin-top: 100px;
  margin-left: 100px;
  .scroll-box {
    height: 6000px;
  }
}
</style>

以上代码中,就是我们利用滚动事件,事实将内容放到视口中,这里我用的是padding 解决的问题,当然你也可以用 css3的transition搞定,无所谓。

还是那句话,形式不重要,内容才重要

Kapture 2024-08-06 at 16.47.43.gif

一图胜千言

有了这个基础,我们就可以进行接下来的一步,动态改变 item 内容,这里我自己写可能有点班门弄斧,我们从网上选取了一个库来参考一下,当然,基于国际惯例,我也臭不要脸的给他 fork 下来并且有详细的注释

有兴趣可以移步virtual-list

接下来我们就来浅浅的分析一下

首先他的使用方式很简单

    // data-sources 传入数据
    // data-component 传入list组件(其实我觉得用插槽更好)
  <VirtualList
          class="list-dynamic scroll-touch"
          :data-key="'id'"
          :data-sources="items"
          :data-component="Item"
          :estimate-size="80"
          :item-class="'list-item-dynamic'"
  />

虚拟滚动源码分析

聊起源码分析,很多人总是想将源码净收眼底,这其实是一个看源码的误区

毕竟老话说得好,不能既要又要,因为在源码中的大多数内容都是为了兼容和兜底用的,对于我们的业务帮助可能并不大

我们看源码其实本质上说,就是领会精神(也就是核心原理)

于是,回到当前问题上来也是一样, 我们只需要关注他源码中怎样根据滚动替换内容即可

至于那些兜底和兼容逻辑,随他去吧,因为他只是在当下的场景下的一个不得不的一个做法

放到的的业务中可能并不适用

就好比很多人在看历史的时候,总以为可以以史为鉴

其实,很多人不知道的是所谓的以史为鉴,鉴的不过是自己的偏见

所有的历史事件的发生,都有他不得不发生的理由,盲目瞎学,学的也只是你自己的偏见

额,好像有点跑题了。。。。

我们回到正题,研究他怎么动态更新内容

开源库和我们普通代码的区别就是,他要被别人指指点点,所以封装一般都成了所有开源库的标配

于是,本库就直接封装了虚拟滚动的的内容

代码如下:

/**
 * virtual list core calculating center
 *
 * @format
 */

const DIRECTION_TYPE = {
  FRONT: 'FRONT', // scroll up or left
  BEHIND: 'BEHIND', // scroll down or right
}
const CALC_TYPE = {
  INIT: 'INIT',
  FIXED: 'FIXED', // 固定 item宽度
  DYNAMIC: 'DYNAMIC', // 动态 item 宽度
}
const LEADING_BUFFER = 2
// 虚拟滚动实例本质上就是提供了一些封装方法和初始状态

export default class Virtual {
  constructor(param, callUpdate) {
    // 初始化启动
    this.init(param, callUpdate)
  }

  init(param, callUpdate) {
    // 传入参数
    this.param = param
    // 回调函数
    this.callUpdate = callUpdate

    // 总展示item,注意是一步步的展示的
    this.sizes = new Map()
    // 展示的总高度,为了计算平均值的
    this.firstRangeTotalSize = 0
    // 根据上述变量计算平均高度
    this.firstRangeAverageSize = 0
    // 上一次的滑动到过的index
    this.lastCalcIndex = 0
    // 固定的高度的 item的高度
    this.fixedSizeValue = 0
    // item 类型,是动态高度,还是非动态高度
    this.calcType = CALC_TYPE.INIT

    // 滑动距离,为了算 padding 的大小
    this.offset = 0
    // 滑动方向
    this.direction = ''

    // 创建范围空对象,保存展示的开始展示位置,结束展示位置,
    this.range = Object.create(null)
    // 先初始化一次
    if (param) {
      this.checkRange(0, param.keeps - 1)
    }

    // benchmark test data
    // this.__bsearchCalls = 0
    // this.__getIndexOffsetCalls = 0
  }

  destroy() {
    this.init(null, null)
  }

  // 返回当前渲染范围
  // 其实就是深拷贝
  getRange() {
    const range = Object.create(null)
    range.start = this.range.start
    range.end = this.range.end
    range.padFront = this.range.padFront
    range.padBehind = this.range.padBehind
    return range
  }

  isBehind() {
    return this.direction === DIRECTION_TYPE.BEHIND
  }

  isFront() {
    return this.direction === DIRECTION_TYPE.FRONT
  }

  // 返回起始索引偏移
  getOffset(start) {
    return (
      (start < 1 ? 0 : this.getIndexOffset(start)) + this.param.slotHeaderSize
    )
  }

  updateParam(key, value) {
    if (this.param && key in this.param) {
      // if uniqueIds change, find out deleted id and remove from size map
      if (key === 'uniqueIds') {
        this.sizes.forEach((v, key) => {
          if (!value.includes(key)) {
            this.sizes.delete(key)
          }
        })
      }
      this.param[key] = value
    }
  }

  // 按id保存每个item
  saveSize(id, size) {
    this.sizes.set(id, size)

    //我们假设大小类型在开始时是固定的,并记住第一个大小值
    //如果下次提交保存时没有与此不同的大小值
    //我们认为这是一个固定大小的列表,否则是动态大小列表
    // 他这个套路很巧妙他给每一列的高度判断一下
    // 如果相同那么就默认为是相同的高度,如果不同那么默认为不同的高度
    if (this.calcType === CALC_TYPE.INIT) {
      this.fixedSizeValue = size
      this.calcType = CALC_TYPE.FIXED
    } else if (
      this.calcType === CALC_TYPE.FIXED &&
      this.fixedSizeValue !== size
    ) {
      this.calcType = CALC_TYPE.DYNAMIC
      // it's no use at all
      delete this.fixedSizeValue
    }

    // 仅计算第一个范围内的平均大小
    // 如果是动态高度的情况下
    if (
      this.calcType !== CALC_TYPE.FIXED &&
      typeof this.firstRangeTotalSize !== 'undefined'
    ) {
      // 如果已经获取高度的数据比展示的总数据小的时候才计算
      if (
        this.sizes.size <
        Math.min(this.param.keeps, this.param.uniqueIds.length)
      ) {
        this.firstRangeTotalSize = [...this.sizes.values()].reduce(
          (acc, val) => acc + val,
          0,
        )
        // 计算出来一个平均高度
        this.firstRangeAverageSize = Math.round(
          this.firstRangeTotalSize / this.sizes.size,
        )
      } else {
        // 拿到平均高度了,就干掉总高度
        delete this.firstRangeTotalSize
      }
    }
  }

  // in some special situation (e.g. length change) we need to update in a row
  // try goiong to render next range by a leading buffer according to current direction
  handleDataSourcesChange() {
    let start = this.range.start

    if (this.isFront()) {
      start = start - LEADING_BUFFER
    } else if (this.isBehind()) {
      start = start + LEADING_BUFFER
    }

    start = Math.max(start, 0)

    this.updateRange(this.range.start, this.getEndByStart(start))
  }

  // when slot size change, we also need force update
  handleSlotSizeChange() {
    this.handleDataSourcesChange()
  }

  // 滚动计算范围
  handleScroll(offset) {
    // 计算方向 也就是是朝上还是朝下滑动
    this.direction =
      offset < this.offset ? DIRECTION_TYPE.FRONT : DIRECTION_TYPE.BEHIND
    // 保存当前offset 距离,为了判断下次是朝上还是朝下
    this.offset = offset

    if (!this.param) {
      return
    }

    if (this.direction === DIRECTION_TYPE.FRONT) {
      // 如果是朝上滑动
      this.handleFront()
    } else if (this.direction === DIRECTION_TYPE.BEHIND) {
      // 如果是朝下滑动
      this.handleBehind()
    }
  }

  // ----------- public method end -----------

  handleFront() {
    const overs = this.getScrollOvers()
    // should not change range if start doesn't exceed overs
    if (overs > this.range.start) {
      return
    }

    // move up start by a buffer length, and make sure its safety
    const start = Math.max(overs - this.param.buffer, 0)
    this.checkRange(start, this.getEndByStart(start))
  }

  handleBehind() {
    // 获取偏移量 所对饮的 list
    const overs = this.getScrollOvers()
    // 如果在缓冲区内滚动,范围不应改变 ,range是在每次滑动出缓冲区的时候更改
    if (overs < this.range.start + this.param.buffer) {
      return
    }
    // 也就是当overs 大于当前的缓冲内容了,也就是到头了
    //我们就开始启动检查机制,重新确定range
    // 其实就是开辟新的缓冲区
    this.checkRange(overs, this.getEndByStart(overs))
  }

  // 根据当前滚动偏移量返回传递值
  getScrollOvers() {
    // 如果插槽标头存在,我们需要减去它的大小,为了兼容
    const offset = this.offset - this.param.slotHeaderSize
    if (offset <= 0) {
      return 0
    }

    // 固定高度的 itm 很好办,直接用偏移量除以单独的宽度就行,就能得出挪上去了几个元素
    if (this.isFixedType()) {
      return Math.floor(offset / this.fixedSizeValue)
    }
    // 非固定高度就麻烦了
    let low = 0
    let middle = 0
    let middleOffset = 0
    let high = this.param.uniqueIds.length
    // 接下来就要有一套算法来解决问题了,求偏移了几个
    while (low <= high) {
      console.log(low, high)
      // this.__bsearchCalls++
      //他这个算法应该属于二分法,通过二分法去求最接近偏移量的 list条数
      // 获取二分居中内容,其实有可能跟总high 一样
      middle = low + Math.floor((high - low) / 2)
      // 获取居中条数的总偏移量
      middleOffset = this.getIndexOffset(middle)
      // 如果偏移量,等于当前偏移量
      if (middleOffset === offset) {
        // 中间 位置数据返回
        return middle
        // 还是利用二分法去找逐渐缩小距离
      } else if (middleOffset < offset) {
        low = middle + 1
      } else if (middleOffset > offset) {
        high = middle - 1
      }
    }
    // 最后是在没找到,就也是无限接近了
    // 因为如果只有大于才会给 while 干掉
    // 也就是在干掉的一瞬间他一定是最接近 offset 的那个值,并且根据动态高度,所形成的 list 条数
    // 之所以-- 是因为 while不行了,所以,我们要回到他行的时候
    return low > 0 ? --low : 0
  }

  //返回给定索引的滚动偏移量,这里可以进一步提高效率吗?
  //虽然通话频率很高,但它只是数字的叠加
  getIndexOffset(givenIndex) {
    // 如果没有就返回 0 偏移量
    if (!givenIndex) {
      return 0
    }
    // 初始偏移量
    let offset = 0
    let indexSize = 0
    // 遍历元素内容
    for (let index = 0; index < givenIndex; index++) {
      // this.__getIndexOffsetCalls++
      // 获取他们的高度
      indexSize = this.sizes.get(this.param.uniqueIds[index])
      // 获取他准确的偏移量,只有前一部分有后续就没有了,所以就要按照前头计算的平均计算量去计算
      // 后续如果滑动完了,那么就会找到,能事实更正
      offset =
        offset +
        (typeof indexSize === 'number' ? indexSize : this.getEstimateSize())
    }

    // 记住上次计算指标 这里计算是为了后续比较的时候用的
    // 因为有可能往上滑或者往下滑,所以每次要比较一下取最大值
    this.lastCalcIndex = Math.max(this.lastCalcIndex, givenIndex - 1)
    // 或者跟总元素个数比较取最小的也就是 lastCalcIndex 不能比总元素数量小,这个math.min
    // 之所以要取小,是为了兼容, lastCalcIndex 可能大于最大数量的情况
    //console.log(this.lastCalcIndex, this.getLastIndex())
    // 经过实践发现,好像前者永远不会大于后者,这个取值好像没用
    this.lastCalcIndex = Math.min(this.lastCalcIndex, this.getLastIndex())
    return offset
  }

  // is fixed size type
  isFixedType() {
    return this.calcType === CALC_TYPE.FIXED
  }

  // return the real last index
  getLastIndex() {
    return this.param.uniqueIds.length - 1
  }

  //在某些情况下,范围被打破,我们需要纠正它
  //然后决定是否需要更新到下一个范围
  checkRange(start, end) {
    const keeps = this.param.keeps
    const total = this.param.uniqueIds.length

    // 小于keep的数据,全部呈现
    // 就是条数太少了,就没有必要搞烂七八糟的计算了
    if (total <= keeps) {
      start = 0
      end = this.getLastIndex()
    } else if (end - start < keeps - 1) {
      // 如果范围长度小于keeps,则根据end进行校正
      start = end - keeps + 1
    }
    // 如果范围有问题,那么就需要重新更新范围
    if (this.range.start !== start) {
      this.updateRange(start, end)
    }
  }

  // 设置到新范围并重新渲染
  updateRange(start, end) {
    this.range.start = start
    this.range.end = end
    this.range.padFront = this.getPadFront()
    this.range.padBehind = this.getPadBehind()
    // 通知回调函数
    console.log(this.getRange())
    this.callUpdate(this.getRange())
  }

  // 这个其实就是基于他的开始位置,返回一个一定的位置
  getEndByStart(start) {
    const theoryEnd = start + this.param.keeps - 1
    // 也有可能最后算出来的超出了当前的总数据量 ,所以要取小来搞定结束位置
    const truelyEnd = Math.min(theoryEnd, this.getLastIndex())
    return truelyEnd
  }

  // 返回总前偏移
  getPadFront() {
    // 固定高度的
    if (this.isFixedType()) {
      return this.fixedSizeValue * this.range.start
    } else {
      // 非固定高度,在方法中用二分法,获取最接近的
      return this.getIndexOffset(this.range.start)
    }
  }

  // 计算总高度
  getPadBehind() {
    // 获取初始 end
    const end = this.range.end
    // 获取总条数
    const lastIndex = this.getLastIndex()
    // 如果是 fixed大小
    if (this.isFixedType()) {
      return (lastIndex - end) * this.fixedSizeValue
    }

    // 这是非固定高度
    if (this.lastCalcIndex === lastIndex) {
      //如果之前滑动到过底部则返回精确的偏移量
      return this.getIndexOffset(lastIndex) - this.getIndexOffset(end)
    } else {
      //如果没有,请使用估计值
      return (lastIndex - end) * this.getEstimateSize()
    }
  }

  // 获取项目估计大小,兜底策略,防止高度为空的情况,拿他的默认高度
  getEstimateSize() {
    return this.isFixedType()
      ? this.fixedSizeValue
      : this.firstRangeAverageSize || this.param.estimateSize
  }
}

以上代码中,就是对于虚拟滚动的封装,主要有关的就封装了那么几个方法

  • 1、保存个更新 range 实时更新 padding
  • 2、展示保存总数据的高度信息
  • 3、兼容固定高度,和非固定高度的 item 类型
  • 4、一些用于通信的辅助函数

接下来就很简单了我们初始化这个实例

 // 初始化虚拟滚动
    const installVirtual = () => {
      // 获取虚拟滚动所用实例
      virtual = new Virtual(
        {
          slotHeaderSize: 0,
          slotFooterSize: 0,
          keeps: props.keeps,
          estimateSize: props.estimateSize,
          buffer: Math.round(props.keeps / 3), // 默认保留三分之一,也就是十条之所以保留三分之一,防止他还没划到地方就更改 padding 出现错误
          uniqueIds: getUniqueIdFromDataSources(),
        },
        // 选区改变,重新生成选区
        onRangeChanged,
      )
      // 获取选区这一步其实有点多此一举了
      //range.value = virtual.getRange()
    }
 // 在组件的初始渲染发生之前被调用。
    onBeforeMount(() => {
      // 初始化虚拟滚动
      installVirtual()
    })

监听滚动事件,根据virtual 中的实例 动态改变数据和更改 padding

// list核心组件
export default defineComponent({
  name: 'VirtualList',
  // props 传值
  props: VirtualProps,
  setup(props, { emit, slots, expose }) {
   // 主渲染逻辑
    const getRenderSlots = () => {
      const slots = []
      // 由于在之前 scroll 中更改了 范围的开始和结束
      const { start, end } = range.value
      const {
        dataSources,
        dataKey,
        itemClass,
        itemTag,
        itemStyle,
        extraProps,
        dataComponent,
        itemScopedSlots,
      } = props
      // 开始遍历,当前内容
      for (let index = start; index <= end; index++) {
        const dataSource = dataSources[index]
        if (dataSource) {
          const uniqueKey =
            typeof dataKey === 'function'
              ? dataKey(dataSource)
              : dataSource[dataKey]
          if (typeof uniqueKey === 'string' || typeof uniqueKey === 'number') {
            slots.push(
              // 传入的内容,将内容放到 item 上,注意这个 item 是传入的
              <Item
                index={index}
                tag={itemTag}
                event={EVENT_TYPE.ITEM}
                horizontal={isHorizontal}
                uniqueKey={uniqueKey}
                source={dataSource}
                extraProps={extraProps}
                component={dataComponent}
                scopedSlots={itemScopedSlots}
                style={itemStyle}
                class={`${itemClass}${
                  props.itemClassAdd ? ' ' + props.itemClassAdd(index) : ''
                }`}
                onItemResize={onItemResized}
              />,
            )
          } else {
            console.warn(
              `Cannot get the data-key '${dataKey}' from data-sources.`,
            )
          }
        } else {
          console.warn(`Cannot get the index '${index}' from data-sources.`)
        }
      }
      return slots
    }
      // 核心逻辑监听滚动事件
    const onScroll = (evt) => {
      // 获取距离顶部的距离
      const offset = getOffset()
      // 获取视口宽度
      const clientSize = getClientSize()
      // 获取内容总高度
      const scrollSize = getScrollSize()

      // iOS滚动回弹行为会造成方向错误,解决兼容 bug
      if (offset < 0 || offset + clientSize > scrollSize + 1 || !scrollSize) {
        return
      }
      // 处理滚动事件确定数据
      virtual.handleScroll(offset)
      emitEvent(offset, clientSize, scrollSize, evt)
    }
      return () => {
      // 拿到 props
      const {
        pageMode,
        rootTag: RootTag,
        wrapTag: WrapTag,
        wrapClass,
        wrapStyle,
        headerTag,
        headerClass,
        headerStyle,
        footerTag,
        footerClass,
        footerStyle,
      } = props
      // 动态的更改 paddingtop 和 paddingbottom
      // 注意这个距离顶部的距离,和距离底部的距离,是根据在滑动的时候动态算出来的
      const { padFront, padBehind } = range.value!
      const paddingStyle = {
        padding: isHorizontal
          ? `0px ${padBehind}px 0px ${padFront}px`
          : `${padFront}px 0px ${padBehind}px`,
      }
      const wrapperStyle = wrapStyle
        ? Object.assign({}, wrapStyle, paddingStyle)
        : paddingStyle
      const { header, footer } = slots
      // jsx
      return (
        <RootTag ref={root} onScroll={!pageMode && onScroll}>
          {/* header slot */}
          {header && (
            <Slot
              class={headerClass}
              style={headerStyle}
              tag={headerTag}
              event={EVENT_TYPE.SLOT}
              uniqueKey={SLOT_TYPE.HEADER}
              onSlotResize={onSlotResized}
            >
              {header()}
            </Slot>
          )}

          {/* main list */}
          <WrapTag class={wrapClass} style={wrapperStyle}>
              // 核心展示逻辑
            {getRenderSlots()}
          </WrapTag>

          {/* footer slot */}
          {footer && (
            <Slot
              class={footerClass}
              style={footerStyle}
              tag={footerTag}
              event={EVENT_TYPE.SLOT}
              uniqueKey={SLOT_TYPE.FOOTER}
              onSlotResize={onSlotResized}
            >
              {footer()}
            </Slot>
          )}

          {/* an empty element use to scroll to bottom */}
          <div
            ref={shepherd}
            style={{
              width: isHorizontal ? '0px' : '100%',
              height: isHorizontal ? '100%' : '0px',
            }}
          />
        </RootTag>
      )
    }
    },
})

以上核心代码中,主要就做了两件小事

  • 1、 监听 scroll 事件,更改virtual实例的内容
  • 2、 根据virtual 的内容动态更改展示数据

打完收工

最后

这两种方案虽然都能提升性能,但各有千秋,因为,前者是无法规避 vue 内部的 diff 计算的 js 损耗

而后者,是无法规避每次滑动的渲染损耗

所以两瓶毒药,大家可自己斟酌,如果自己斟酌不了

那就问领导!!!,如果他也搞不定,那他还当什么领导,让我来

源码分析完了,如果有看不明白的 jy可以给我私信

至于怎么变成插件,我在之前的文章中已经写过了实用的VUE系列——手把手教你写个vue 插件

请移步学习!

希望跟各位jym 共同进步!