本文章内容概括
1.IntersectionObserver API介绍
2.触底功能实现
3.数据加载完毕处理
4.双重节流判断(加载状态、触发时间)
5.调试日志编写
6.请求重试机制
7.随机抖动请求
8.内存优化
9.用户体验优化
10.完整代码(可直接复制使用)
前言
我们在实现触底加载功能时一般会通过scroll滚动事件监听视图的scrollTop、scrollHeight、clientHeight属性进行判断触底,从而实现触底加载。
如果有小伙伴需要了解scroll滚动实现触底,可以在我的主页文章进行查看。
在现代浏览器中提供了一个较为方便的API IntersectionObserver可以更加方便的实现触底效果。
本文章将以最高效的方式快速介绍并实现使用IntersectionObserver的触底加载功能,并涵盖节流和性能优化。
如文章内容有遗漏或错误,请批评指点
最终效果展示:
一、IntersectionObserver介绍
在实现触底功能前,我们需要认识一下IntersectionObserver
1.1 构建实例
首先我们可以new一个IntersectionObserver实例
IntersectionObserver有两个参数,第一个为回调函数,第二个为配置项。
var observe = new IntersectionObserver((entries) => {
//回调函数
}, {
// 配置项
})
1.2 核心方法
在构建实例后,我们需要调用实例方法来实现监视、移除监视、移除实例等
observe.observe(dom元素)//开始监视 可以对多个dom进行监视
observe.unobserve(dom元素)//停止监视
observe.disconnect(dom元素)//销毁实例
1.3 返回值
在template中定义一个div,并获取到这个dom元素。使用observe.observe方法对其进行监视
<template>
<div id="app">
<div ref="monitoredObjects"></div>
</div>
</template>
通过ref对其进行获取,并在onMounted钩子内通过observer方法对dom元素进行监视。 并在回调函数中打印entries参数。
<script setup>
let sentry = ref(null)//获取需要监视的哨兵元素
//创建IntersectionObserver实例
var observe = new IntersectionObserver((entries) => {
console.log(entries);
}, {
// 配置项
})
//在dom挂载后对其进行监视
onMounted(() => {
//如果dom元素存在则对其进行监视
if (sentry) {
observe.observe(sentry.value)//开始监视
}
})
</script>
可通过浏览器的控制台查看到entries的结构。我们对其进行逐个的介绍。
entries返回的是一个数组对象,可以通过下标的方式对每个被监视的dom参数进行查看
参数如下:
boundingClientRect:目标元素相对于窗口视图的边界矩形,及目标元素相对于窗口视图左上角的位置
intersectionRatio:窗口视图与目标元素的交叉比例,及我们能在浏览器中看到这个元素的大小百分比
intersectionRect:窗口视图与目标元素的交叉范围的元素大小
isIntersecting:目标元素是否与根元素交叉,可以理解为目标元素是否在窗口视图在可见
rootBounds:根元素大小,如果为绑定根元素则为Window窗口视图大小
target:目标元素,及observe.observe(目标元素)所监视的dom元素
time:交叉状态变化的时间戳
备注:isVisible为非标准属性,可以不用在意
1.4 常用配置项
通过使用IntersectionObserver的配置项,可以更好的帮助我们对其进行操作
常用配置项如下:
root:根元素,默认值:null 为Window视图窗口(如需在组件中使用可以将根组件设置为组件最外层元素,防止与其他组件发生滚动冒泡)
rootMargin:默认值:0px 可以理解为元素触发的提前量
threshold:默认值:1 交叉比例阈值,可以是单个数值或数组,当元素可见比例>=阈值时会触发回调函数
var observe = new IntersectionObserver((entries) => {
console.log(entries);
}, {
root: null,//默认设置为Window窗口视图
rootMargin: "0px",//定义根组件的边距 用于扩展/缩小检测范围
threshold: 1//触发比例阈值 可以为数组[0,0.25,0.5,1]
})
1.5 回调函数触发情况
在我们的触底功能中,IntersectionObserver的回调函数触发时机一般为
1.页面内能够看到被监视的元素(交叉比例与配置项的threshold有关,如:当threshold为1时,被监视元素全部可见触发回调):isIntersecting变为true
2.被监视元素不可见时触发:isIntersecting变为false
二、触底功能实现
2.1 页面搭建与数据渲染
首先模拟一个后端调取数据的情况
2.1.1 定义基础变量
let sentry = ref(null)//获取需要监视的哨兵元素
let page = ref(0)//页码
let pageSize = 15//每页的数据个数
let items = reactive([])//数据列表
2.1.2 模拟后端请求数据
通过处理数据并返回一个Promise对象,用于模拟请求后端数据
定义渲染列表items的属性为
id:用于v-for虚拟dom渲染提高性能
text:各个属性内容
//获取数据(模拟)
const getData = (page) => {
const promise = new Promise((resolve) => {
//Array()创建pageSize(15)个元素的空数组
//.fill()将这15个元素赋值为undefined
//.map()对每个元素进行遍历
let data = Array(pageSize).fill().map((_, index) => {
return {
id: page * pageSize + index + 1,
text: '第' + (page * pageSize + index + 1) + '个元素'
}
})
resolve(data)
})
return promise
}
2.1.3 加载更多数据函数封装
通过push方法将获取到的数据推入数据列表
...data:展开运算符用于将data数组展开成单个数据
//加载更多数据
const getMore = async () => {
try {
let data = await getData(page.value)
//将获取的数据渲染在页面上
items.push(...data)
//是页码数加1
page.value++
} catch (err) {
//处理错误信息
console.log(err)
}
}
2.1.4 创建页面布局
使用v-for对数据进行动态渲染
同时添加哨兵元素,通过ref获取dom,在JS中通过let sentry = ref(null)进行获取
<template>
<div id="app">
<ul>
<li class="liList" v-for="item in items" :key="item.id">{{ item.text }}</li>
</ul>
<div class="sentry" ref="sentry">我是哨兵元素</div>
</div>
</template>
Css:
#app {
width: 90%;
margin: 0 auto;
background-color: #f5f5f5;
padding: 10px 0;
}
.sentry {
text-align: center;
}
ul {
display: flex;
flex-wrap: wrap;
list-style: none;
gap: 10px;
justify-content: space-between;
}
.liList {
flex: 0 1 calc(33% - 10px);
text-align: center;
height: 300px;
line-height: 300px;
font-size: 24px;
font-weight: 600;
background-color: #ccbb;
}
2.1.5 IntersectionObserver实例创建与配置
创建IntersectionObserver实例并对其配置项进行设置
//创建IntersectionObserver实例
const observe = new IntersectionObserver((entries) => {
}, {
root: null,//默认设置为Window窗口视图
rootMargin: "0px",//定义根组件的边距 用于扩展/缩小检测范围
threshold: 1//触发比例阈值 可以为数组[0,0.25,0.5,1]
})
2.1.6 启动监视
在mounted挂载后生命周期钩子内对dom元素进行监视
//在dom挂载后对其进行监视
onMounted(() => {
//如果dom元素存在则对其进行监视
if (sentry) {
observe.observe(sentry.value)//开始监视
}
})
2.1.7 触底加载数据
在IntersectionObserver实例中调用getMore函数对页面数据列表进行赋值和渲染
需要注意:
1.不能直接在回调中使用getMore()函数
因为IntersectionObserver实例的回调函数会在元素显示和未显示时各触发一次,及一次加载会触发两次getMore()函数
需要通过被监视对象的isIntersecting属性对其进行判断
2.要通过enteries[0]的方式获取被监视对象
形参enteries返回的是包含所有被监视对象的数组,需要通过下标的方式获取指定的dom元素进行判断
const observe = new IntersectionObserver((entries) => {
//仅在被监视元素可见时触发
if (entries[0].isIntersecting) {
//新增数据
getMore()
}
}, {
root: null,//默认设置为Window窗口视图
rootMargin: "0px",//定义根组件的边距 用于扩展/缩小检测范围
threshold: 1//触发比例阈值 可以为数组[0,0.25,0.5,1]
})
2.2 完整代码
至此我们就实现了触底加载的功能,但仍有许多优化空间,我将会在下面的章节对代码进行优化
完整代码如下:
<template>
<div id="app">
<ul>
<li class="liList" v-for="item in items" :key="item.id">{{ item.text }}</li>
</ul>
<div class="sentry" ref="sentry">我是哨兵元素</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
let sentry = ref(null)//获取需要监视的哨兵元素
let page = ref(0)//页码
let pageSize = 15//每页的数据个数
let items = reactive([])//数据列表
//创建IntersectionObserver实例
var observe = new IntersectionObserver((entries) => {
//仅仅在被监视元素可见时触发
if (entries[0].isIntersecting) {
//新增数据
getMore()
}
}, {
root: null,//默认设置为Window窗口视图
rootMargin: "0px",//定义根组件的边距 用于扩展/缩小检测范围
threshold: 1//触发比例阈值 可以为数组[0,0.25,0.5,1]
})
//在dom挂载后对其进行监视
onMounted(() => {
//如果dom元素存在则对其进行监视
if (sentry) {
observe.observe(sentry.value)//开始监视
}
})
//获取数据(模拟)
const getData = (page) => {
const promise = new Promise((resolve) => {
//Array()创建pageSize(15)个元素的空数组
//.fill()将这15个元素赋值为undefined
//.map()对每个元素进行遍历
let data = Array(pageSize).fill().map((_, index) => {
return {
id: page * pageSize + index + 1,
text: '第' + (page * pageSize + index + 1) + '个元素'
}
})
resolve(data)
})
return promise
}
//加载更多数据
const getMore = async () => {
try {
let data = await getData(page.value)
console.log(data)
//将获取的数据渲染在页面上
items.push(...data)
//是页码数加1
page.value++
} catch (err) {
//处理错误信息
console.log(err)
}
}
</script>
<style>
#app {
width: 90%;
margin: 0 auto;
background-color: #f5f5f5;
padding: 10px 0;
}
.sentry {
text-align: center;
}
ul {
display: flex;
flex-wrap: wrap;
list-style: none;
gap: 10px;
justify-content: space-between;
}
.liList {
flex: 0 1 calc(33% - 10px);
text-align: center;
height: 300px;
line-height: 300px;
font-size: 24px;
font-weight: 600;
background-color: #ccbb;
}
</style>
三、性能优化
3.1 数据耗尽处理
当我们页面的数据已经被加载完毕后,需要进行判断并禁止用户再次获取数据
假设我们的最大页码为3
//限制条件
const LIMIT = {
MAX_Page: 3,//最大页码数(模拟)
}
并在模拟获取数据时进行数据处理
//获取数据(模拟)
const getData = (page) => {
if (page >= LIMIT.MAX_Page) {
console.log("没有更多数据");
return
}
const promise = new Promise((resolve) => {
//...原本代码逻辑
代码实现:
1.添加判断变量
let noMore = ref(false)//判断数据是否加载完毕
2.在getMore函数中进行条件判断
try {
let data = await getData(page.value)
//判断data是否获取到数据
if (data) {
//将获取的数据渲染在页面上
items.push(...data)
//页码数加1
page.value++
//记录最后一次加载的时间
lastLoadingTime.value = Date.now()
} else {
//将无更多数据状态改为true
noMore.value = true
}
3.添加数据状态拦截判断
/*数据加载完毕则退出函数*/
if ( oMore.value) return
调试日志
可以在进入getMore函数后检测各参数的值,判断数据状态
console.log({'[数据状态]': {
数据是否加载完毕: noMore.value,
是否进行拦截: noMore.value
}
})
3.2 节流
我们在日常开发中经常会对代码进行节流处理,以减少服务器的压力。
这是使用setTimeout对获取数据进行包裹,用于模拟网络请求的响应时间
/获取数据(模拟)
const getData = (page) => {
const promise = new Promise((resolve) => {
//使用setTimeout模拟网络数据请求时间
setTimeout(() => {
let data = Array(pageSize).fill().map((_, index) => {
return {
id: page * pageSize + index + 1,
text: '第' + (page * pageSize + index + 1) + '个元素'
}
})
resolve(data)
}, 800)
})
return promise
}
定义一个loading变量用于判断是否在加载数据
定义一个lastLoadingTime变量用于储存最后加载时间
let loading = false//节流(是否正在加载)
let lastLoadingTime = ref(0)//节流(记录最后加载的时间)
对上述定义的变量进行判断,如有以下情况,则return:
1. loading为true:正在获取后台数据
2.(触发时间-最后加载时间)小于1秒
代码书写注意:
1.在执行获取数据前需要将loading改为true,结束获取数据时将loading重新修改为false
2.创建变量记录当前触发时间,并在结束获取数据时记录最后加载时间
const getMore = async () => {
//记录当前时间
let nowTime = Date.now()
//如果数据正在加载或连续触发触底则退出函数
if (loading.value || nowTime - lastLoadingTime.value < 1000) return
//进入函数将加载状态改为true
loading.value = true
try {
let data = await getData(page.value)
//将获取的数据渲染在页面上
items.push(...data)
//页码数加1
page.value++
} catch (err) {
//处理错误信息
console.log(err)
} finally {
//无论成功还是失败都将加载状态改为false
loading.value = false
//记录最后一次加载的时间
lastLoadingTime.value = Date.now()
}
}
调试日志
可以在进入getMore函数后检测各参数的值,判断节流数据
const getMore = async () => {
let nowTime = Date.now()
//调试日志
console.log({
'[时间节流状态]': {
当前时间: nowTime,
上次加载时间: lastLoadingTime.value,
时间差: nowTime - lastLoadingTime.value,
是否进行时间节流: (nowTime - lastLoadingTime.value < 1000)
},
'[加载节流状态]': {
加载状态: loading.value,
是否进行加载节流: loading.value
}
})
if (loading.value || nowTime - lastLoadingTime.value < 1000) return
...之前代码加载逻辑
}
使用调试日志后可在控制台精切的查看各节流状态
3.3 请求重试
3.3.1 基本代码实现
用户在访问系统时可能会遇到网络不佳的情况。
当获取数据失败时,我们可以对其进行请求重试操作,优化用户体验
1.定义重试次数
//加载更多数据 retryCount记录重试次数
const getMore = async (retryCount = 0) => {
2.在catch内 处理错误信息
catch (err) {
//处理错误信息
//获取数据失败时修改loading
loading.value = false
//允许三次重试
if (retryCount < 3) {
console.log("重新加载" + (retryCount + 1) + "次");
//通过await Promise设置等待时长
await new Promise(resolve => setTimeout(resolve, 1000))
//递归调用加载函数重试请求
return getMore(retryCount + 1)
}
console.log(err)
}
3.效果展示
3.3.2 添加随机抖动
当服务器出现问题,客户端中的所有用户同时进行数据重试时服务器可能会在同一时间受到大量的请求
问题描述:
所有客户端同时重试 → 服务端收到突发流量 → 可能引发雪崩效应
这是我们就应该使用随机抖动来减少服务器压力
代码实现:
1.定义固定等待时间随机等待时间
2.随机等待时间可以使用Math.random()生成一个随机的0-1之间的小数
3.将两者相加获取随机等待时间,使用等待 Promise来等待时间
4.递归getMore再次获取数据
catch (err) {
loading.value = false
if (retryCount < 3) {
console.log("重新加载" + (retryCount + 1) + "次");
//添加随机抖动减少服务器压力
const waitTime = 1000 //定义最小等待时间1秒
let jitter = Math.random() * 1000 //0-1秒随机时间
await new Promise(resolve => setTimeout(resolve, (waitTime + jitter)))
return getMore(retryCount + 1)
}
console.log(err)
}
3.4 内存优化
在无数据加载后以及页面关闭时,对IntersectionObserver实例进行销毁,优化内存空间
//监视noMore数据状态
watch(noMore, () => {
if (observe) {
observe.disconnect()
observe = null//释放引用
console.log('observer已销毁');
}
})
//页面销毁时销毁IntersectionObserver实例
onUnmounted(() => {
if (observe) {
observe.disconnect()
}
})
3.5 用户体验优化
最后对页面展示信息进行优化
1.使用v-if条件判断是否还要更多数据
2.判断loading的值,如果正在获取数据,显示正在加载数据
3.如果数据加载完毕,显示已无更多数据
<div id="app">
<ul>
<li class="liList" v-for="item in items" :key="item.id">{{ item.text }}</li>
</ul>
<div v-if="!noMore" class="sentry" ref="sentry">{{ loading ? "正在加载数据..." : "下滑加载更多" }}</div>
<div v-else class="sentry">已无更多数据</div>
</div>
完整代码
<template>
<div id="app">
<ul>
<li class="liList" v-for="item in items" :key="item.id">{{ item.text }}</li>
</ul>
<div v-if="!noMore" class="sentry" ref="sentry">{{ loading ? "正在加载数据..." : "下滑加载更多" }}</div>
<div v-else class="sentry">已无更多数据</div>
</div>
</template>
<script setup>
import { onUnmounted } from "vue";
import { onMounted, reactive, ref, watch } from "vue";
//数据加载限制条件
const LIMIT = {
MAX_Page: 3,//最大页码数(模拟)
MAX_retryCount: 3,//最多重试请求次数
MIN_TriggerTime: 400//最短连续触发时间 0.4s
}
let sentry = ref(null)//获取需要监视的哨兵元素
let page = ref(0)//页码
let pageSize = 15//每页的数据个数
let items = reactive([])//数据列表
let loading = ref(false)//节流(是否正在加载)
let lastLoadingTime = ref(0)//节流(记录最后加载的时间)
let noMore = ref(false)//判断数据是否加载完毕
//创建IntersectionObserver实例
var observe = new IntersectionObserver((entries) => {
//仅仅在被监视元素可见时触发
if (entries[0].isIntersecting) {
//新增数据
getMore()
}
}, {
root: null,//默认设置为Window窗口视图
rootMargin: "0px",//定义根组件的边距 用于扩展/缩小检测范围
threshold: 1//触发比例阈值 可以为数组[0,0.25,0.5,1]
})
//在dom挂载后对其进行监视
onMounted(() => {
//如果dom元素存在则对其进行监视
if (sentry) {
observe.observe(sentry.value)//开始监视
}
})
//监视noMore数据状态
watch(noMore, () => {
if (observe) {
observe.disconnect()
observe = null//释放引用
console.log('observer已销毁');
}
})
//页面销毁时销毁IntersectionObserver实例
onUnmounted(() => {
if (observe) {
observe.disconnect()
}
})
//获取数据(模拟)
const getData = (page) => {
if (page >= LIMIT.MAX_Page) {
console.log("没有更多数据");
return
}
const promise = new Promise((resolve) => {
//Array()创建pageSize(15)个元素的空数组
//.fill()将这15个元素赋值为undefined
//.map()对每个元素进行遍历
//使用setTimeout模拟网络数据请求时间
setTimeout(() => {
let data = Array(pageSize).fill().map((_, index) => {
return {
id: page * pageSize + index + 1,
text: '第' + (page * pageSize + index + 1) + '个元素'
}
})
resolve(data)
}, 800)
})
return promise
}
//加载更多数据 retryCount记录重试次数
const getMore = async (retryCount = 0) => {
//记录当前时间
let nowTime = Date.now()
//调试日志
console.log({
'[时间节流状态]': {
当前时间: nowTime,
上次加载时间: lastLoadingTime.value,
时间差: nowTime - lastLoadingTime.value,
是否进行时间节流: (nowTime - lastLoadingTime.value < 1000)
},
'[加载节流状态]': {
加载状态: loading.value,
是否进行加载节流: loading.value
},
'[数据状态]': {
数据是否加载完毕: noMore.value,
是否进行拦截: noMore.value
}
})
/*
1.正在加载
2.连续触发触底
3.数据加载完毕
则退出函数
*/
if (loading.value || nowTime - lastLoadingTime.value < LIMIT.MIN_TriggerTime || noMore.value) return
//进入函数将加载状态改为true
loading.value = true
try {
let data = await getData(page.value)
//判断data是否获取到数据
if (data) {
//将获取的数据渲染在页面上
items.push(...data)
//页码数加1
page.value++
//记录最后一次加载的时间
lastLoadingTime.value = Date.now()
} else {
//将无更多数据状态改为true
noMore.value = true
}
//修改加载状态改为false
loading.value = false
} catch (err) {
//处理错误信息
//获取数据失败时修改loading
loading.value = false
//允许三次重试
if (retryCount < LIMIT.MAX_retryCount) {
console.log("重新加载" + (retryCount + 1) + "次");
//添加随机抖动减少服务器压力
const waitTime = 1000 //定义最小等待时间1秒
let jitter = Math.random() * 1000 //0-1秒随机时间
//通过await Promise设置等待时长
await new Promise(resolve => setTimeout(resolve, (waitTime + jitter)))
//递归调用加载函数重试请求
return getMore(retryCount + 1)
}
console.log(err)
}
}
</script>
<style>
#app {
width: 90%;
margin: 0 auto;
background-color: #f5f5f5;
padding: 10px 0;
}
.sentry {
text-align: center;
}
ul {
display: flex;
flex-wrap: wrap;
list-style: none;
gap: 10px;
justify-content: space-between;
}
.liList {
flex: 0 1 calc(33% - 10px);
text-align: center;
height: 300px;
line-height: 300px;
font-size: 24px;
font-weight: 600;
background-color: #ccbb;
}
</style>