第30期 | 原来无限滚动是这样实现的!

1,649 阅读4分钟

前言

    如何实现滚动至底部时,加载更多数据?原理是什么?下面一起来探究一下~

组件介绍

  • 功能 滚动至底部时,加载更多数据。
  • 属性
属性说明类型默认
v-infinite-scroll滚动到底部时,加载更多数据Function
infinite-scroll-disabled是否禁用booleanfalse
infinite-scroll-delay节流时延,单位为msnumber200
infinite-scroll-distance触发加载的距离阈值,单位为pxnumber0
infinite-scroll-immediate是否立即执行加载方法,以防初始状态下内容无法撑满容器。booleantrue
  • 效果预览

scroll.gif

源码下载

git clone https://github.com/element-plus/element-plus.git
 cd element-plus
 pnpm install
 // 本地打开文档
 pnpm docs:dev
 // 本地运行例子
 pnpm run dev

调试源码

  1. 官网例子复制到play\src\App.vue,参考代码如下:
<template>
  <div class="play-container">
    <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>
  </div>
</template>
<script setup lang="ts">
  import { ref } from 'vue'
const count = ref(0)
const load = () => {
  count.value += 2
}
</script>
  1. 打开源码文件所在位置packages\components\infinite-scroll\index.ts,并打debugger
  2. 运行pnpm run dev并打开http://localhost:3000/,效果如下:

scroll-debugger.gif     通过调试可以发现,InfiniteScroll具有mounted、unmounted、updated等钩子,且有如下属性及方法,接着我们通过源码来看一下这些属性及方法具体有什么作用,探究一下到底为什么通过指令就能实现无限滚动~

图片.png

源码分析

index.ts 入口文件

// 文件位置packages\components\infinite-scroll\index.ts
import InfiniteScroll from './src'
import type { App } from 'vue'
import type { SFCWithInstall } from '@element-plus/utils'

const _InfiniteScroll = InfiniteScroll as SFCWithInstall<typeof InfiniteScroll>

_InfiniteScroll.install = (app: App) => {
  app.directive('InfiniteScroll', _InfiniteScroll)
}
export default _InfiniteScroll
export const ElInfiniteScroll = _InfiniteScroll

    入口文件的作用很简单,就是利用app.directive()注册一个全局指令【同时传递一个名字和一个指令定义】

infinite-scroll\src\index.ts 功能文件

    我们可以在ts文件看不懂的地方打debugger,哪里不会点哪里

scroll-debugger-2.gif

  • getScrollOptions
import type { ComponentPublicInstance } from 'vue'
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'] }
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)
}

    getScrollOptions函数接收两个实参,分别是指令绑定的元素el以及触发指令的组件实例,作用就是获取默认属性选项,这里咱们可以借鉴学习的点是ts的keyof遍历属性【type ScrollOptions = { [K in keyof Attrs]: Attrs[K]['default'] }】以及利用Object.entries返回参数对象自身的(不含继承的)所有可遍历(enumerable)属性的键值对数组继而遍历对象~

  • getScrollContainer
declare const isClient: boolean;
export const isScroll = (el: HTMLElement, isVertical?: boolean): boolean => {
  if (!isClient) return false
  // 非空断言
  const key = (
    {
      undefined: 'overflow',
      true: 'overflow-y',
      false: 'overflow-x',
    } as const
  )[String(isVertical)]!
  // overflow = 'auto'
  const overflow = getStyle(el, key)
  return ['scroll', 'auto', 'overlay'].some((s) => overflow.includes(s))
}

export const getScrollContainer = (
  el: HTMLElement,
  isVertical?: boolean
): Window | HTMLElement | undefined => {
  if (!isClient) return

  let parent: HTMLElement = el
  while (parent) {
    if ([window, document, document.documentElement].includes(parent))
      return window

    if (isScroll(parent, isVertical)) return parent

    parent = parent.parentNode as HTMLElement
  }

  return parent
}

    getScrollContainer的作用是获取滚动的容器元素,主要是通过判断元素的overflow属性是否为scroll|auto|overlay来判断

  • handleScroll
export const SCOPE = 'ElInfiniteScroll'
type InfiniteScrollCallback = () => void
import type { ComponentPublicInstance } from 'vue'

type InfiniteScrollEl = HTMLElement & {
  [SCOPE]: {
    container: HTMLElement | Window
    containerEl: HTMLElement
    instance: ComponentPublicInstance
    delay: number // export for test
    lastScrollTop: number
    cb: InfiniteScrollCallback
    onScroll: () => void
    observer?: MutationObserver
  }
}
// 获取当前元素距离顶部的距离
export const getOffsetTop = (el: HTMLElement) => {
  let offset = 0
  let parent = el

  while (parent) {
    offset += parent.offsetTop
    parent = parent.offsetParent as HTMLElement
  }

  return offset
}
// 获取指定容器距离顶部距离之差
export const getOffsetTopDistance = (
  el: HTMLElement,
  containerEl: HTMLElement
) => {
  return Math.abs(getOffsetTop(el) - getOffsetTop(containerEl))
}
// 控制滚动
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

  // trigger only if full check has done and not disabled and scroll down
  if (observer || disabled || delta < 0) return

  let shouldTrigger = false

  if (container === el) {
    shouldTrigger = scrollHeight - (clientHeight + scrollTop) <= distance
  } else {
    // get the scrollHeight since el might be visible overflow
    const { clientTop, scrollHeight: height } = el
    const offsetTop = getOffsetTopDistance(el, containerEl)
    shouldTrigger =
      scrollTop + clientHeight >= offsetTop + clientTop + height - distance
  }

  if (shouldTrigger) {
    cb.call(instance)
  }
}

    handleScroll函数其实就是触底判断然后控制是否继续滚动触发加载,这里有使用节流函数throttle来稀释函数的执行频率,这里的计算会涉及到元素的以下属性:

  1. scrollHeight【元素内容高度的度量,包括由于溢出导致的视图中不可见内容】
  2. clientHeight【元素内部的高度(以像素为单位),包含内边距】
  3. scrollTop【元素的内容垂直滚动的像素数】
  4. clientTop【一个元素顶部边框的宽度】
  5. offsetTop【当前元素相对于其 offsetParent 元素的顶部内边距的距离】
  • checkFull
function checkFull(el: InfiniteScrollEl, cb: InfiniteScrollCallback) {
  const { containerEl, instance } = el[SCOPE]
  const { disabled } = getScrollOptions(el, instance)
  if (disabled || containerEl.clientHeight === 0) return
  if (containerEl.scrollHeight <= containerEl.clientHeight) {
    cb.call(instance)
  } else {
    destroyObserver(el)
  }
}

     checkFull函数的作用是实现当元素还没滚动到底部时立即触发加载函数,否则停止监听目标

总结

    看完以上函数,不难总结出无限滚动的实现主要是通过判断是否触底来触发加载,该指令在mounted钩子时添加滚动事件及目标元素的监听,updated钩子执行加载函数,并在unmounted时停止监听目标元素及滚动事件~