Infinite Scroll 无限滚动

5,478 阅读2分钟

element-ui

功能

滚动至底部时,加载更多数据。

  1. 可以设置距离底部位置多少,触发加载更多功能
  2. 立即充满容器,自动执行加载更多功能
  3. 等到一定条件后,可以阻止加载更多
  4. 设置滚动节流时间

使用

以指令形式绑定元素,父元素或者自身需要添加overflow: auto或者overflow: scroll

<template>
  <div class="infinite-list-wrapper" style="overflow: auto">
    <ul
      v-infinite-scroll="load"
      class="list"
      :infinite-scroll-disabled="disabled"
    >
      <li v-for="i in count" :key="i" class="list-item">{{ i }}</li>
    </ul>
    <p v-if="loading">加载中</p>
    <p v-if="noMore">没有更多了</p>
  </div>
</template>
<script setup lang="ts">
let count = ref(2),
  loading = ref(false)
  
const noMore = computed(() => {
  return count.value >= 20
})

const disabled = computed(() => {
  return loading.value || noMore.value
})

const load = () => {
  loading.value = true
  setTimeout(() => {
    count.value += 2
    loading.value = false
  }, 1000)
}
</script>
属性名说明类型可选值默认值
v-infinite-scroll滚动到底部时,加载更多数据function----
v-infinite-delay节流时延,单位为msnumber--200
v-infinite-distance触发加载的距离阈值,单位为pxnumber--0
v-infinite-immediate是否立即执行加载方法,以防初始状态下内容无法撑满容器。boolean--true

效果

加载开始

image.png

加载中

image.png

加载结束

image.png

原理

元素只有内部子元素的高度大于自身,并且自身的overflowauto/scroll 才可以发生滚动

  1. 找到带有属性overflowauto/scroll的上级元素,如果没有这个元素,直接停止
  2. 合并用户传递的属性与默认属性
  3. 如果有这个元素(container),使用MutationObserver监控元素,期间不断执行用户传递的函数,直到绑定 指令的元素与 container 的底部重叠
  4. container 发生滚动,执行用户传递的函数,直到触发disabled
  5. 当页面卸载时,解除所有公共变量

实现

找到带有属性overflowauto/scroll的上级元素 - getOverScrollEle

通过正则不断去匹配元素的overflow属性,如果没有,就找父级元素,直到找到根元素

function getOverScrollEle(el: HTMLElement) {
  let reg = /(scroll)|(auto)/g;
  
  while (el != document.documentElement) {
    let overflow = getComputedStyle(el).overflow
    if (reg.test(overflow)) {
      return el
    } else {
      if (el.parentElement) {
        el = el.parentElement
      } else {
        el = document.documentElement
        return
      }
    }
  }
}

合并用户的属性与默认属性 -getScrollOptions

let defaultOption = {
  "delay": 500,
  "immediate": true,
  "disabled": false,
  "distance": 0,
}

function getScrollOptions(el: HTMLElement, instance: ComponentPublicInstance): defaultOptionKey<TypeDefaultOption> {
  return Object.keys(defaultOption).reduce((map, key) => {
    // 去除 infinite-scroll-
    const attrVal = el.getAttribute(`infinite-scroll-${key}`) || ''
    let value = instance[attrVal] ?? attrVal ?? defaultOption[key]
    value = value === 'false' ? false : value

    map[key] = value
    return map
  }, {})
}

因为是绑定在元素身上的属性,所以使用getAttribute获取,同时获取的都是字符串,需要对字符串false做一次转换
instance是 当前vue实例,类似于vue2this,可以获取用户绑定的动态值
el为绑定属性的节点

监控&自动执行

如果用户传递了 v-infinite-immediate,需要立即执行用户传递的方法

let container = getOverScrollEle(el) as HTMLElement;

let {  immediate } = getScrollOptions(el, instance!);
   if (immediate) {

      let observe = new MutationObserver(onScroll)
      // subtree 可选
      // 当为 true 时,将会监听以 target 为根节点的整个子树。包括子树中所有节点的属性,而不仅仅是针对 target
      observe.observe(container, {
        childList: true, // 儿子节点
        subtree: true // 儿子的儿子
      })
      onScroll()
    }
  let onScroll = handleScroll.bind(null, el, cb)

使用MutationObserver监控container(属性中有overflow),如果他的子元素或者子元素的子元素发生变化,就要执行handleScroll方法

handleScroll

主要作用是为了滚动时触发,判断元素是否触底,或者是否有disabled触发 触底的条件也十分简单,只要元素的被页面卷曲高度+元素的可视高度 == 元素的实际高度就可以判断它已经到底了

function handleScroll(el: InfiniteScrollEl, fn: InfiniteScrollCallback) {
  const { instance, observer, container } = el[SCOPE]
  const { disabled, distance } = getScrollOptions(el, instance)
  // // 说明没有触动
  if (disabled) return;
  // @ts-ignore
  if (container.scrollTop + container.clientHeight + Number(distance) >= container.scrollHeight) {
    console.log("触底")
    fn()
  }
}

container 元素滚动

container 元素滚动的时候,需要不断的执行onScroll事件,由于是滚动事件,加上一个节流事件, 当滚动的途中,不断的判断是否触底

container?.addEventListener("scroll", throttle(onScroll.bind(null, el, instance), delay))

function throttle(fn, delay = 200) {
  let timer: null | NodeJS.Timeout = null
  let flag = true
  return () => {
    if (!flag) return
    flag = false
    const args = arguments
    timer = setTimeout(() => {
      flag = true
      clearTimeout(timer!)
      fn.apply(window, args)
    }, delay)
  }
}

总结

graph TD
Start --> 寻找容器
寻找容器 -- overflow是auto/scroll--> 存在容器container
存在容器container-->shouldDoNow{是否需要立即执行}
shouldDoNow --N--> 绑定滚动事件
shouldDoNow--Y--> isDoNow[立即执行]
isDoNow-->MutationObserver监听元素container
isDoNow ---->onScroll
onScroll-->判断是否触底/disabled,执行用户传递函数
绑定滚动事件-->onScroll

源码


type infinite<S = string> = S extends `infinite-scroll-${infer P}` ? P : S;

type TypeDefaultOption = Record<`infinite-scroll-${string}`, any>



type defaultOptionKey<T> = {
  [K in keyof T as infinite<K>]: T[K]
}
let defaultOption = {
  "delay": 500,
  "immediate": true,
  "disabled": false,
  "distance": 0,
}

function getScrollOptions(el: HTMLElement, instance: ComponentPublicInstance): defaultOptionKey<TypeDefaultOption> {
  return Object.keys(defaultOption).reduce((map, key) => {
    // 去除 infinite-scroll-
    const attrVal = el.getAttribute(`infinite-scroll-${key}`) || ''
    let value = instance[attrVal] ?? attrVal ?? defaultOption[key]
    value = value === 'false' ? false : value

    map[key] = value ?? defaultOption[`${key}`]
    return map
  }, {})
}

function getOverScrollEle(el: HTMLElement) {
  let reg = /(scroll)|(auto)/g;
  while (el != document.documentElement) {
    let overflow = getComputedStyle(el).overflow
    if (reg.test(overflow)) {
      return el
    } else {
      if (el.parentElement) {
        el = el.parentElement
      } else {
        el = document.documentElement
        return
      }
    }
  }
}


function throttle(fn, delay = 200) {
  let timer: null | NodeJS.Timeout = null
  let flag = true
  return () => {
    if (!flag) return
    flag = false
    const args = arguments
    timer = setTimeout(() => {
      flag = true
      clearTimeout(timer!)
      fn.apply(window, args)
    }, delay)
  }
}

const SCOPE = 'infinite-scroll'

type InfiniteScrollCallback = () => void

type InfiniteScrollEl = HTMLElement & {
  [SCOPE]: {
    container: HTMLElement | Window
    containerEl: HTMLElement
    instance: ComponentPublicInstance
    delay: number
    lastScrollTop: number
    cb: InfiniteScrollCallback
    onScroll: () => void
    observer?: MutationObserver
  }
}

// 滚动判断是否触底 或者 到了 disabled 
function handleScroll(el: InfiniteScrollEl, fn: InfiniteScrollCallback) {
  const { instance, observer, container } = el[SCOPE]
  const { disabled, distance } = getScrollOptions(el, instance)
  // // 说明没有触动
  if (disabled) return;
  // @ts-ignore
  if (container.scrollTop + container.clientHeight + Number(distance) >= container.scrollHeight) {
    fn()
  } else {
    if (observer) {
      (observer as MutationObserver).disconnect()
      delete el[SCOPE].observer
    }
  }
}

// 自定义指令
let vInfiniteScroll: ObjectDirective = {

  async mounted(el, bindings) {
    const { instance, value: cb } = bindings
    let { delay, immediate } = getScrollOptions(el, instance!);
    let container = getOverScrollEle(el) as HTMLElement;

    let onScroll = handleScroll.bind(null, el, cb)

    if (!instance) return
    el[SCOPE] = {
      container,
      onScroll,
      el,
      instance,
    }
    if (immediate) {

      let observe = new MutationObserver(onScroll)
      el[SCOPE].observer = observe
      // subtree 可选
      // 当为 true 时,将会监听以 target 为根节点的整个子树。包括子树中所有节点的属性,而不仅仅是针对 target
      observe.observe(container, {
        childList: true, // 儿子节点
        subtree: true // 儿子的儿子
      })
      onScroll()
    }
    container?.addEventListener("scroll", throttle(onScroll.bind(null, el, instance), delay))
  },
  unmounted(el) {
    const { onScroll, container } = el[SCOPE]
    if (container) {
      container.removeEventListener("scroll", onScroll)
      el[SCOPE] = {}
    }
  }
}