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

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

前言

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

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

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

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

虚拟滚动有哪些应用场景

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

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

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

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

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

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

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

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

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

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

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

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

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

虚拟滚动原理

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

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

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

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

别急我们一个一个讲

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

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

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

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

js 复制代码
  <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>

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

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

js 复制代码
// @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 上,既解决了实例保存的问题,有解决了多指令获取实例的问题。

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

js 复制代码
//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 这其实就是一个创新升级,希望在我们搬砖的项目中,可以借鉴。

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

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

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

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

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

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

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

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

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

如何更新视口内容

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

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

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

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

js 复制代码
<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搞定,无所谓。

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

一图胜千言

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

有兴趣可以移步virtual-list

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

首先他的使用方式很简单

js 复制代码
    // 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'"
  />

虚拟滚动源码分析

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

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

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

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

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

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

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

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

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

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

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

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

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

代码如下:

js 复制代码
/**
 * 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 类型

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

js 复制代码
 // 初始化虚拟滚动
    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

js 复制代码
// 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可以给我私信

希望跟各位jym 共同进步!

相关推荐
花花鱼6 分钟前
vue3 axios ant-design-vue cdn的方式使用
前端·javascript·vue.js
GoppViper40 分钟前
uniapp中实现<text>文本内容点击可复制或拨打电话
前端·后端·前端框架·uni-app·前端开发
Sam90291 小时前
【Webpack--007】处理其他资源--视频音频
前端·webpack·音视频
Code成立1 小时前
HTML5精粹练习第1章博客
前端·html·博客·html5
架构师ZYL1 小时前
node.js+Koa框架+MySQL实现注册登录
前端·javascript·数据库·mysql·node.js
gxhlh2 小时前
React Native防止重复点击
javascript·react native·react.js
一只小白菜~2 小时前
实现实时Web应用,使用AJAX轮询、WebSocket、还是SSE呢??
前端·javascript·websocket·sse·ajax轮询
计算机学姐2 小时前
基于python+django+vue的在线学习资源推送系统
开发语言·vue.js·python·学习·django·pip·web3.py
晓翔仔2 小时前
CORS漏洞及其防御措施:保护Web应用免受攻击
前端·网络安全·渗透测试·cors·漏洞修复·应用安全
jingling5553 小时前
后端开发刷题 | 数字字符串转化成IP地址
java·开发语言·javascript·算法