基于IntersectionObserver的滚动加载组件
前言
无限滚动(infinite scroll)在前端有很多实现思路。本文主要针对某些业务场景提出通用的解决方案。
在移动端开发中,经常需要实现滚动加载更多的业务。但是由于ui设计变幻莫测,应用场景也经常变化(如列表滚动在内部,和整个页面滚动),组件库常见的list组件无法很好的覆盖业务场景。listFooter组件更为灵活,能兼容更多的平台,也更容易做样式改造。
功能特性
1. 多状态支持
组件支持四种不同的加载状态:
- loading: 加载中状态,显示"加载中..."提示
- loadFinish: 加载完成状态,显示"到底啦"提示
- loadFail: 加载失败状态,显示"加载失败,点击刷新"并支持点击重试
- empty: 空数据状态,显示空状态组件
2. 自动触发机制
当组件进入用户视口时,自动触发load
事件,通知父组件执行数据加载逻辑。
3. 职责单一
只提供页脚展示和加载更多功能,与列表本身的样式和功能隔离
核心:初始化时,会默认进行一次判断;加载事件仅在组件loading状态触发
技术实现思路
核心思想:IntersectionObserver API
IntersectionObserver
是浏览器原生提供的异步API,专门用于监听元素与视口的交集变化,调用方式这里不做描述。我们需要使用的是他的特性,元素进入和离开可视窗时,会触发我们的回调函数。
实际有这个特性的api都可以实现,比如vueUse的useIntersectionObserver,uniapp中的uni.createIntersectionObserver。作者曾经使用的是团队内部的hooks方法,兼容处理了低版本的场景。
关键实现细节
1. 首次触发机制
IntersectionObserver
在调用observe()
方法时会立即触发一次回调,报告元素的初始交集状态。这确保了即使元素在页面加载时就已经可见,也能正确触发加载逻辑。
主要优点(凑字数)
1. 性能优越
- 异步执行: IntersectionObserver在独立线程中运行,不阻塞主线程
- 无需节流: 浏览器内部已优化,无需手动实现节流机制
- 减少DOM查询 : 避免频繁调用
getBoundingClientRect()
2. 代码简洁
- 核心逻辑仅需几十行代码
- 无需复杂的事件监听和清理逻辑
- 状态管理清晰明了
3. 兼容性良好
- 现代浏览器原生支持
- 可通过polyfill支持旧版浏览器
- 渐进增强的设计理念
4. 易于维护
- 单一职责原则,只负责触发加载
- 状态驱动的设计,逻辑清晰
- 完善的生命周期管理
实现代码
代码思路很简单,核心是监听listStatus做实例的挂载和注销
vue
<template>
<view class="list-footer" ref="targetElement">
<div>
<div v-if="listStatus === 'loadFail'" class="text loadFail">
<div @click="refresh">加载失败,点击刷新</div>
</div>
<div v-else-if="listStatus === 'loading'" class="text">
<div class="list-loading">
<span class="icon-list-loading">加载中...</span>
</div>
</div>
<!-- empty不期望在组件中处理 -->
<empty v-else-if="listStatus === 'empty'" :show-img="false" />
<!-- 加载完成状态:提示用户已到底部 -->
<div v-else-if="listStatus === 'loadFinish'" class="text">
<div>到底啦</div>
</div>
</div>
</view>
</template>
<script>
// 引入空状态组件
import empty from '@/components/empty';
/**
* 列表底部状态组件
*
* 功能特性:
* 1. 支持多种列表状态显示(加载中、加载失败、加载完成、空数据)
* 2. 基于IntersectionObserver实现可见性检测
* 3. 当组件进入视口时自动触发加载事件
* 4. 支持加载失败时的重试功能
*
* 使用场景:
* - 分页列表的底部状态提示
* - 无限滚动列表的加载状态管理
* - 列表数据的错误处理和重试
*
* 事件:
* @event load - 当组件可见时触发,用于加载更多数据
* @event refresh - 当点击重试按钮时触发,用于重新加载数据
*/
export default {
name: 'ListFooter',
components: {
empty,
},
props: {
/**
* 列表状态
* @type {String}
* @default 'loading'
* @example 'loading' | 'loadFinish' | 'loadFail' | 'empty'
* @description 'loading' 加载中 | 'loadFinish' 加载完成 | 'loadFail' 加载失败 | 'empty' 空数据(此时应不展示组件)
*/
listStatus: {
type: String,
default: 'loading',
validator(value) {
return ['loading', 'loadFinish', 'loadFail', 'empty'].includes(value);
}
},
},
data() {
return {
// 组件是否在视口中可见
isVisible: false,
// IntersectionObserver实例
observer: null,
}
},
mounted() {
// 组件挂载后,如果状态为加载中,则开始观察元素可见性
if (this.listStatus === 'loading') {
this.observeElement();
}
},
watch: {
/**
* 监听列表状态变化
* 当状态不为loading时,移除观察器
* 当状态变为loading时,重新开始观察
*/
listStatus(val) {
if (val !== 'loading') {
this.removeObserver();
} else {
this.observeElement();
}
},
/**
* 监听可见性变化
* 当组件变为可见时,触发load事件
*/
isVisible(val) {
if (val) {
this.$emit('load');
}
},
},
beforeDestroy() {
// 组件销毁前清理观察器
this.removeObserver();
},
methods: {
/**
* 刷新/重试方法
* 当加载失败时点击重试按钮触发
* 向父组件发送refresh事件
*/
refresh() {
this.$emit('refresh');
},
/**
* 开始观察元素可见性
* 使用IntersectionObserver API监听组件是否进入视口
*/
observeElement() {
// 先移除之前的观察器,避免重复观察
this.removeObserver();
// 检查目标元素是否存在
if (!this.$refs.targetElement) return;
// 创建IntersectionObserver实例
this.observer = new IntersectionObserver((entries) => {
const entry = entries[0];
// 更新可见性状态
this.isVisible = entry.isIntersecting;
}, {
root: null, // 使用浏览器视口作为根元素
rootMargin: '0px', // 根元素的外边距
threshold: 0 // 目标元素0%可见时就触发回调
});
// 开始观察目标元素
this.observer.observe(this.$refs.targetElement);
},
/**
* 移除观察器
* 断开IntersectionObserver连接并清空引用
*/
removeObserver() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
},
},
};
</script>
<style lang="less" scoped>
.list-footer {
margin-top: 10px;
margin-bottom: 10px;
width: 100%;
.text {
color: #00000070;
text-align: center;
font-size: 14px;
font-weight: 700;
line-height: 20px;
height: 20px;
}
}
.loadFail {
color: #156AFF;
}
</style>
使用示例
主要是在@load时加载数据,以及处理listStatus状态。需要重新加载数据时,只要把listStatus置为loading就好。
vue
<template>
<div>
<!-- 列表内容 -->
<div v-for="item in list" :key="item.id">
{{ item.name }}
</div>
<!-- 滚动加载组件 -->
<list-footer
:list-status="listStatus"
@load="loadMore"
/>
</div>
</template>
<script>
import ListFooter from '@/components/list-footer.vue'
export default {
components: { ListFooter },
data() {
return {
list: [],
listStatus: 'loading', // loading | loadFinish | loadFail | empty
page: 1
}
},
methods: {
async loadMore() {
try {
this.listStatus = 'loading'
const data = await this.fetchData(this.page)
if (data.length === 0) {
this.listStatus = this.list.length === 0 ? 'empty' : 'loadFinish'
} else {
this.list.push(...data)
this.page++
this.listStatus = 'loading' // 准备下次加载
}
} catch (error) {
this.listStatus = 'loadFail'
}
}
}
}
</script>
总结
回到设计代码的初衷,这个组件很好的处理了两个业务目的。1.与列表组件的ui解耦2.能兼容不同的滚动父级场景。这个组件是几年前写移动端功能的时候设计的,最近开始写uniapp,做了简单的api改造后也能适配。
实际上作者在准备找AI生成文章时,搜了下同类,也有很多标题是IntersectionObserver处理滚动加载的文章。有些是将功能放在列表内。我认为,IntersectionObserver作为滚动处理的方案,实际优势并不体现在他的性能,而是灵活性和通用性。有了这个组件,在不同的列表或者页面加载数据时,就不用考虑设计新的逻辑。IntersectionObserver会在页面维持一个实例,有一定的内存开销。实际在如uniapp这种有专门的scroll-view的场景,不见得是一个性能更好的方案。
现在回头看多年前的组件设计,其实缺少了一个很关键的ready状态。当前组件中,如果用户频繁的上下滚动,可能会重复请求接口。ready状态与loading一样维持Observer实例不销毁,但loading状态不会抛出load事件,这样就能处理重复请求的问题。这里仅提供思路,就不做更正了。