起因

由于需求需要,后端数据并不能返回总数量,但支持分页请求。因此对于表格来说就无法使用正常的分页组件来进行分页,而是进行下拉(或点击)来加载更多数据。

但是本以为这是一个很简单的需求,首先寻找了目前使用的 UI 框架NaiveUi中关于数据表格组件,发现并未相关的功能,即便是 Antd、arco 也并未类似组件。但是Element-Plus倒是可以通过appendslot 插槽进行无限滚动的实现。也并未有现成的可以使用的方案。

于是便百度搜索,找到了关于指令实现与 IntersectionObserver 实现的两种方式,便借鉴其思想将其二次封装。

无限滚动:v 指令

v 指令的实现主要还是用到了滚动事件,但是为了能够使 NaiveUI 的数据表格组件能够触发事件,则需要指定 DOM 元素。因为组件并非真正的滚动元素,在 NaiveUI(2.28.2)版本中,数据表格滚动的元素为.n-scrollbar-container,因此在实现指令时需要指定该元素。

既然是监听滚动事件,那必不可少的便是节流与防抖,因此目前我想到的则是向指令中传递至少四个参数:事件处理函数、指定的元素、延时、距离(指距离底部剩余的距离)。

1
2
3
4
5
6
7
8
9
10
11
<n-data-table
:columns="columns"
:data="data1"
max-height="500px"
v-infinite-scroll="{
func: load1,
target: '.n-scrollbar-container',
delay: 100,
threshold: 100
}"
/>

但这样实现却出现了一个问题,即指定的元素在指令 Mounted 阶段可能并未被创建,因此便出现了问题。

NaiveUI 数据表格首先渲染的是空表格、当数据填充时才会创建滚动元素

因此在使用指令时只能向指令传递一个动态值,这个值我选则了数据的长度。

1
2
3
4
5
6
7
8
9
10
11
<n-data-table
:columns="columns"
:data="data1"
max-height="500px"
v-infinite-scroll:[length1]="{
func: load1,
target: '.n-scrollbar-container',
delay: 100,
threshold: 100
}"
/>

指令的声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { Directive } from 'vue'
import { throttle, debounce } from '../../utils'

const infiniteScroll: Directive<any, any> = {
updated(el, binding) {
// 如果el已经有了滚动事件,则直接返回
if (el.hasInfiniteScrollLoadEvent) return

const { func, target, delay = 500, threshold = 100 } = binding.value
const targetElement = el.querySelector(target)
el.tableInfiniteScrollFn = function (e: Event) {
const element: HTMLElement | null = e.target as HTMLElement
const scrollMaxHeight = element.scrollHeight - element.clientHeight
// 判断:如果当前滚动距离 大于 最大滚动距离 减 100 则表示即将到达底部,触发回调
if (element.scrollTop >= scrollMaxHeight - threshold) {
if (func) {
func()
} else {
binding.value()
}
}
}
;(targetElement || el).addEventListener(
'scroll',
debounce(el.tableInfiniteScrollFn, delay)
)
el.hasInfiniteScrollLoadEvent = true
}
}
export default infiniteScroll

使用此方式需要保证首次加载的数据大于所设置的容器,否则可能无法触发滚动事件。

指令使用姿势

传递函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<div
class="test"
style="max-height: 500px; overflow-y: auto"
v-infinite-scroll="load2"
>
<div v-for="i in data2" :key="i" style="height: 100px">{{ i }}</div>
</div>
</template>
<script setup lang="ts">
function load2() {
console.log('load2滚动到底了...')
}
</script>

直接传递函数的情况下,将为当前元素绑定滚动事件,并且距离为 100(像素),延迟为 500ms。

传递对象

传递的对象的类型如下:

1
2
3
4
5
6
interface type {
func: Function
target?: string
delay?: number
threshold?: number
}
  • func 为事件处理函数
  • target 为指定的元素,如果不填则默认为当前绑定的元素。如果滚动元素不是当前元素,那么需要填写此参数来指定。例如:.n-scrollbar-container
  • delay 节流的延迟时间
  • threshold 距离底部的阈值

调用例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template>
<n-data-table
:columns="columns"
:data="data1"
max-height="500px"
v-infinite-scroll:[length1]="{
func: load1,
target: '.n-scrollbar-container',
delay: 100,
threshold: 100
}"
/>
</template>
<script setup lang="ts">
// 无实际用途,只用于触发create生命周期
const length1 = computed(() => {
return data1.value.length
})
function load1() {
console.log('load1滚动到底了...')
}
</script>

无限滚动:组件

此组件依赖于IntersectionObserver,其兼容下如下:

image-20220501205926047

此方案实现原理是在所需滚动容器最底部放置此组件,用于监听该元素是否进入可视范围。进入则表示用户滑到了底部。

组件的实现大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<template>
<div class="observer" ref="observerElementRef"></div>
</template>

<script lang="ts" setup>
import { onBeforeUnmount, onMounted, PropType, ref } from 'vue'
const props = defineProps({
/**
* 触发的函数
*/
handleIntersect: {
type: Function as PropType<Function>,
default: () => {}
},
/**
* 父级元素css选择器 如果不传入则取此组件的父级元素
*/
target: {
type: String as PropType<string>,
default: ''
},
/**
* 距离触发时间的距离(阈值)
* https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserver/IntersectionObserver
*/
rootMargin: {
type: String as PropType<string>,
default: '100px 0px'
}
})

const observerElementRef = ref<null | HTMLElement>(null)
let observer: null | IntersectionObserver = null
onMounted(() => {
const options = {
root: props.target
? document.querySelector(props.target)
: observerElementRef.value?.parentElement,
rootMargin: props.rootMargin
}
// 构建观察器
observer = new IntersectionObserver(([entry]) => {
// 目标元素与根元素相交
if (entry && entry.isIntersecting) {
props?.handleIntersect()
}
}, options)
if (observerElementRef.value) {
// 观察目标元素
observer.observe(observerElementRef.value)
}
})
onBeforeUnmount(() => {
observer?.disconnect()
})
</script>

<style scoped>
.observer {
width: 1px;
height: 1px;
}
</style>

组件使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<div class="observer-container">
<div class="item" v-for="i in data">{{ i }}</div>
<ObserverScroll :handle-intersect="load" />
</div>
</template>
<script setup lang="ts">
function load() {
setTimeout(() => {
for (let i = 0; i < 10; i++) {
data.value.push({
id: i,
name: `name ${i}`,
age: i,
address: `address ${i}`,
date: new Date()
})
}
}, 800)
}
</script>

参考

此文中所涉及到的代码可在vue3-infinite-scroll中找到。