问题描述
页面滚动到底部的时候加载下一页数据,是开发中十分常见的问题,一些插件和组件已经提供了相应的解决方案。
但是如果不使用这些插件或者组件,要自己去实现相应的功能的话,一般的解决方案是动态计算滚动条的位置,当滚动条触底的时候,再去请求下一页的数据。
这是大多数人使用的解决方法。但是这个方案存在的弊端也十分的明显。那就是性能开销的问题,可以想象一下,我们要在滚动条滚动的时候去计算位置,要获取dom元素,然后获取高度,然后…总之逻辑还是比较繁琐的,而监听的滚动事件的触发是十分的频繁的,滚动就会触发逻辑,有时候滚动事件甚至会触发上百次。因此,监听滚动事件,然后去动态的计算高度是非常的消耗性能的。
解决方案
那么,有什么好的办法,能够实现我们的需求的同时降低在性能上的开销呢。那就是我今天要说的用观察者模式实现。
观察者模式,其实看名字大概就能理解到什么意思。假设存在一个观察者,来帮我们观察页面有没有触底,当页面触底的时候,能够提醒我们页面触底了,然后我们去执行相对应的方法,那么是不是能够减少滚动时动态重复计算高度造成的不必要的开销呢?那么如何来创建一个观察者呢,在创建观察者前,我们首先要知道一个api。Intersection Observer
这个api有什么用呢?我们先来看一下官方文档上是怎么写的。
Intersection Observer
官方文档的地址:
https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserver
从文档可以看到,intersectionObserver 提供了观察目标元素与其祖先元素或顶级文档视窗的交叉状态的方法。简单点来说就是允许你追踪目标元素与其祖先元素有没有重叠的状态。举一个例子,可以看下面的图。
假设红色的框代表的是我们的页面,白色的框代表的是我们观察的元素,将 intersectionObserver 应用于这里,那么此时我们将祖先元素设置为红色的框。这个api就可以告诉我们,白色的框与红色的框的重叠的状态。根据这一特性,我们就可以实现到底部时加载下一页的需求。那么具体要怎么实现呢?
实现方法
假设有这么一个页面,我们在页面的底部,放上一个loading的盒子。此时我们只需要知道这个loading的盒子有没有出现在视口里面,当出现在视口里面的时候,我们去加载下一页的数据就好了。
那么如何在不监听滚动条,不计算高度的情况下,知道这个元素出现在了视口里面呢。这个时候就需要请出我们的intersectionObserver。具体的写法如下
const ob = new IntersectionObserver(fuction(){
},
{
root:'视口',//目标元素
threshold:1
})
它返回一个observer对象 ob,并且api接收两个参数,第一个参数是回调函数。也即是,当我们的被观察和目标元素重叠的时候,执行的回调,一般我们请求下一页的数据的方法就要写在这里面。第二个参数是一个配置对象,它里面有一个属性root,可以指定我们的目标元素。这个是一个可填项,当不填的时候,它默认设置我们的视口为目标,而我们的需求也是以视口为目标,因此在这里可以不填。并且它还有第二个属性,threshold的作用就是指定当我们的被观察元素的部分有多少是和目标元素重叠的时候,去触发回调,他可以填小数,也可以填整数1,当填了整数1的时候,就代表着我们的被观察元素,要完全和视口重叠,才去触发回调,也即是下面这种情况。
然后我们来完善一下代码
const ob = new IntersectionObserver(
() => {
console.log("被观察者完全出现在和视口重叠")
},
{
threshold: 1,
},
)
那么,我们如何要指定被观察者就是最底部的那个loading呢?之前提到了,这个api会返回一个observer对象,我们可以获取到loading元素后,利用返回的observer对象来观察loading
const loading = document.getElementById('loading')
const ob = new IntersectionObserver(
() => {
console.log("被观察者完全出现在和视口重叠")
},
{
threshold: 1,
},
)
ob.observe(loading)
这个时候其实就已经能够观察loading是不是和视口重叠了。然后我们在页面初始化的时候执行以下这个逻辑开启观察。可以看到:
可以看到其实页面已经打印了这句话,但是有朋友会发现代码的逻辑执行了两次,那么为什么会执行两次呢?
首先我们知道,IntersectionObserver其实并不是当前的被观察元素一直在视口里才执行逻辑,而是只要和视口重叠过,就会执行逻辑,而重叠不管是进入视口,还是离开视口,都会触发重叠。我们可以看下面这张图:
在接口没响应的时候(这里用的定时器模拟接口异步返回),列表里面是没有数据的。此时的loading毫无疑问是和视口完全重叠了。而当接口请求回来的时候:
接口请求回来数据的时候,列表把loading往下挤,挤出了这个可视区域。而IntersectionObserver是观察被观察者和目标元素的交叉状态的。因此不管是进入视口,还是被挤出视口,都存在和视口交叉的瞬间,因此就执行了两次。
可是这样是不对的,要在页面触底请求下一页数据,我们其实只需要loading在进入页面的时候去触发逻辑。在离开的时候,其实不用去触发逻辑的。那么怎么限制它呢。
其实在回调函数里面是有一个参数的,由于可以观察多个元素,因此他是一个数组,存放的是被观察元素的信息。
const loading = document.getElementById('loading')
const ob = new IntersectionObserver(
(target) => {
console.log(target)
},
{
threshold: 1,
},
)
ob.observe(loading)
我们打印一下这个数组,可以看到里面存在一个属性是isIntersecting,当他是false的时候就代表元素是离开可视区,当为true的时候就代表元素是进入可视区。我们就可以用这个属性,将离开可视区的情况给他排除掉。
const loading = document.getElementById('loading')
const ob = new IntersectionObserver(
(target) => {
if (target[0].isIntersecting) {
console.log("加载更多")
}
},
{
threshold: 1,
},
)
ob.observe(loading)
然后我们可以看到,已经基本实现了功能,也就是拉到最底部的时候加载下一页。
可是还有一个小问题就是如果我重复上下滚动,他会重复执行多次,因次我们需要在这里加上一个限制,当请求接口的时候将一个开关置为开启状态,当接口响应的时候才去重置开关,当开关处于开启状态的时候,不去执行逻辑。此时,就可以完美实现需求了。最后还有一点就是当最后一页数据的时候,将loading从页面上移除就好了。
const loading = document.getElementById('loading')
const ob = new IntersectionObserver(
(target) => {
if (target[0].isIntersecting&&!state.loading) {
console.log("加载更多")
}
},
{
threshold: 1,
},
)
ob.observe(loading)
以上就是用观察者模式实现请求下一页数据的全部内容了,这样的写法不仅简单,而且可以大幅度的减少性能的消耗,大家可以自己去尝试着写一写。接下来我贴上本页的完整代码,方便大家复制。
完整代码
<template>
<div class="warp">
<van-button @click="router.back()">返回</van-button>
<div class="scrollContainer">
<div class="item" v-for="(item, index) in data" :key="index">
{
{
index }}
</div>
<div class="loading" id="loading" v-if="showL">
<i class="iconfont icon-jiazai"></i>
</div>
</div>
</div>
</template>
<script lang="ts">
import {
defineComponent, reactive, toRefs, onMounted, nextTick } from 'vue'
import {
router } from '@/router'
export default defineComponent({
name: 'LoadMore',
props: {
},
components: {
},
setup() {
const state = reactive({
data: new Array(0),
loading: false,
flag: 0,
showL: true,
})
const loadMore = () => {
return new Promise((resolve, reject) => {
try {
setTimeout(() => {
state.data = state.data.concat(new Array(20))
resolve(true)
}, 1500)
} catch (error) {
reject(error)
}
})
}
onMounted(() => {
nextTick(() => {
// Ts 非空断言 !
const loading: HTMLElement = document.getElementById('loading')!
const ob = new IntersectionObserver(
(target: Array<{
isIntersecting: boolean }>) => {
if (target[0].isIntersecting && !state.loading) {
state.loading = true
state.flag++
loadMore().then((res) => {
state.loading = false
if (state.flag >= 3) {
// 最后一页数据的时候移除掉loading
state.showL = false
}
})
}
},
{
threshold: 1,
},
)
ob.observe(loading)
})
})
return {
router,
...toRefs(state),
}
},
})
</script>
<style lang="scss" scoped>
@keyframes rotate {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
.warp {
flex-direction: column;
display: flex;
width: 100%;
height: 100%;
.scrollContainer {
flex: 1;
overflow-y: auto;
.item {
color: #fff;
display: flex;
align-items: center;
justify-content: center;
height: 100px;
background-color: rgb(39, 1, 29);
border-bottom: 1px solid #fff;
}
.loading {
margin: 10px 0;
display: flex;
align-items: center;
justify-content: center;
i {
animation: rotate 1s infinite linear;
}
}
}
}
</style>