✅ 通过 props 传入:
- 异步数据加载函数(
loadMoreFn) - 每页数量(
pageSize,可选,默认 10) - 容器高度(
height,可选)
✅ 内部自动处理:
- “空空如也”、“加载中....”、“到底了”
- 初始数据不足一屏时自动加载更多
- 自动清理 Observer 和事件监听
✅ 使用 <script setup> + TypeScript 风格(但不强制 TS,纯 JS 也可运行)
📦 通用组件:InfiniteScroll.vue
<template>
<div
ref="containerRef"
class="infinite-scroll-container"
:style="{ height: `${height}px` }"
>
<!-- 插槽:用于自定义 item 渲染 -->
<div v-if="list.length > 0" class="infinite-list">
<slot v-for="(item, index) in list" :item="item" :index="index" :key="index" />
</div>
<!-- 空状态 -->
<div v-else class="infinite-empty">
<slot name="empty">空空如也</slot>
</div>
<!-- 加载中 -->
<div v-if="loading" class="infinite-loading">
<slot name="loading">加载中....</slot>
</div>
<!-- 到底了 -->
<div v-else-if="!hasMore && list.length > 0" class="infinite-no-more">
<slot name="no-more">到底了</slot>
</div>
<!-- 哨兵元素 -->
<div ref="sentinelRef" class="infinite-sentinel"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
const props = defineProps({
// 必传:加载下一页的函数,接收 { page, pageSize },返回 Promise<{ data: any[], hasMore: boolean }>
loadMoreFn: {
type: Function,
required: true
},
// 可选:每页数量,默认 10
pageSize: {
type: Number,
default: 10
},
// 可选:容器高度,默认 400px
height: {
type: [Number, String],
default: 400
}
})
// 内部状态
const list = ref([])
const loading = ref(false)
const hasMore = ref(true)
const page = ref(1)
// DOM refs
const containerRef = ref(null)
const sentinelRef = ref(null)
let observer = null
// 加载下一页
const loadMore = async () => {
if (loading.value || !hasMore.value) return
loading.value = true
try {
const result = await props.loadMoreFn({ page: page.value, pageSize: props.pageSize })
const { data = [], hasMore: more = false } = result
list.value.push(...data)
hasMore.value = more
page.value++
} catch (err) {
console.error('InfiniteScroll: 加载失败', err)
// 可在此处 emit error 事件(如需)
} finally {
loading.value = false
}
}
// 判断是否撑满容器
const isContainerFull = () => {
const el = containerRef.value
return el.scrollHeight > el.clientHeight
}
// 自动补全(直到撑满或无更多)
const autoLoadIfNotFull = async () => {
await nextTick()
while (hasMore.value && !loading.value && !isContainerFull()) {
await loadMore()
await nextTick()
}
}
// 初始化
onMounted(async () => {
await loadMore()
autoLoadIfNotFull()
// 设置 IntersectionObserver
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && hasMore.value && !loading.value) {
loadMore()
}
},
{
root: containerRef.value,
threshold: 0.1
}
)
if (sentinelRef.value) {
observer.observe(sentinelRef.value)
}
// 绑定 scroll 事件用于动态补全
containerRef.value?.addEventListener('scroll', handleScroll)
})
const handleScroll = () => {
if (!isContainerFull() && hasMore.value && !loading.value) {
autoLoadIfNotFull()
}
}
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
containerRef.value?.removeEventListener('scroll', handleScroll)
})
// 暴露内部状态(可选,供父组件控制)
defineExpose({
list,
reload: async () => {
list.value = []
hasMore.value = true
page.value = 1
await loadMore()
autoLoadIfNotFull()
},
loadMore
})
</script>
<style scoped>
.infinite-scroll-container {
overflow-y: auto;
border: 1px solid #ddd;
padding: 10px;
box-sizing: border-box;
}
.infinite-list {
margin-bottom: 10px;
}
.infinite-empty,
.infinite-loading,
.infinite-no-more {
text-align: center;
padding: 12px;
color: #888;
}
.infinite-sentinel {
height: 1px;
}
</style>
🧪 使用示例
<template>
<InfiniteScroll
:load-more-fn="fetchData"
:page-size="8"
:height="500"
>
<!-- 默认插槽:渲染每个 item -->
<template #default="{ item }">
<div class="custom-item">
{{ item.title }}
</div>
</template>
<!-- 可选:自定义空状态 -->
<template #empty>
<div>暂无数据 😢</div>
</template>
<!-- 可选:自定义加载中 -->
<template #loading>
<div>正在拼命加载...</div>
</template>
<!-- 可选:自定义“到底了” -->
<template #no-more>
<div>没有更多啦 ✨</div>
</template>
</InfiniteScroll>
</template>
<script setup>
// 模拟 API
const fetchData = ({ page, pageSize }) => {
return new Promise((resolve) => {
setTimeout(() => {
const total = 30
const items = []
for (let i = 0; i < pageSize; i++) {
const idx = (page - 1) * pageSize + i
if (idx >= total) break
items.push({ id: idx + 1, title: `文章 ${idx + 1}` })
}
resolve({
data: items,
hasMore: (page * pageSize) < total
})
}, 600)
})
}
</script>
<style scoped>
.custom-item {
padding: 12px;
margin: 6px 0;
background: #f0f8ff;
border-radius: 4px;
}
</style>
📌 关键优势
| 特性 | 说明 |
|---|---|
| 完全解耦 | 数据加载逻辑由父组件提供,组件只负责 UI 和滚动逻辑 |
| 插槽灵活 | 支持自定义 item、empty、loading、no-more 的渲染 |
| 自动补全 | 初始数据不足一屏?自动加载直到撑满或无更多 |
| 类型安全友好 | 若使用 TypeScript,可轻松为 loadMoreFn 添加类型 |
| 可重置/重载 | 通过 expose 提供 reload() 方法 |
如果你使用 TypeScript,还可以为 loadMoreFn 添加类型约束,例如:
interface LoadMoreResult<T> {
data: T[]
hasMore: boolean
}
type LoadMoreFunction<T> = (params: { page: number; pageSize: number }) => Promise<LoadMoreResult<T>>
需要我提供 TS 版本吗?或者集成到你的具体业务场景?欢迎继续告诉我!