<template>
<div class="pull-refresh-container" ref="containerRef">
<!-- 列表内容插槽 -->
<div class="list-content">
<slot :list="list" :loading="loading" :finished="finished"></slot>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-wrapper">
<Loading size="24px" vertical>加载中...</Loading>
</div>
<!-- 没有更多数据提示 -->
<div v-else-if="finished && list.length > 0" class="finished-text">
没有更多数据了
</div>
<!-- 空数据提示 -->
<div v-else-if="!loading && list.length === 0" class="empty-text">
暂无数据
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { Loading } from 'vant'
/**
* 下拉加载更多组件的属性接口
*/
interface PullRefreshProps {
/** 每页数据条数,默认10 */
pageSize?: number
/** 距离底部多少像素时触发加载,默认50 */
offset?: number
/** 是否立即加载第一页数据,默认true */
immediateLoad?: boolean
}
/**
* 列表项数据接口(可根据实际需求扩展)
*/
interface ListItem {
id: number | string
[key: string]: any
}
/**
* 分页响应数据接口
*/
interface PageResponse {
/** 当前页数据 */
list: any[]
/** 数据总条数 */
total: number
/** 当前页码 */
page: number
/** 每页条数 */
pageSize: number
}
/**
* 加载函数类型定义
*/
type LoadFunction = (page: number, pageSize: number) => Promise<PageResponse>
// 组件属性
const props = withDefaults(defineProps<PullRefreshProps>(), {
pageSize: 10,
offset: 50,
immediateLoad: true
})
// 组件事件
const emit = defineEmits<{
/** 加载数据事件 */
load: [page: number, pageSize: number]
/** 加载完成事件 */
loaded: [data: PageResponse]
/** 加载错误事件 */
error: [error: Error]
}>()
// 响应式数据
const containerRef = ref<HTMLElement>()
const list = ref<any[]>([])
const loading = ref(false)
const finished = ref(false)
const currentPage = ref(0)
const total = ref(0)
/**
* 加载数据
* @param isRefresh 是否为刷新操作(重置列表)
*/
const loadData = async (isRefresh = false) => {
if (loading.value) return
// 如果已经加载完所有数据且不是刷新操作,则不再加载
if (finished.value && !isRefresh) return
loading.value = true
try {
const page = isRefresh ? 1 : currentPage.value + 1
// 触发加载事件,让父组件处理数据加载
emit('load', page, props.pageSize)
} catch (error) {
console.error('加载数据失败:', error)
emit('error', error as Error)
}
}
/**
* 处理加载完成
* @param data 加载的数据
*/
const handleLoadSuccess = (data: PageResponse) => {
loading.value = false
if (currentPage.value === 0) {
// 首次加载或刷新
list.value = data.list
} else {
// 追加数据
list.value.push(...data.list)
}
currentPage.value = data.page
total.value = data.total
// 判断是否还有更多数据
finished.value = list.value.length >= total.value
emit('loaded', data)
}
/**
* 处理加载错误
* @param error 错误信息
*/
const handleLoadError = (error: Error) => {
loading.value = false
emit('error', error)
}
/**
* 刷新列表(重新加载第一页)
*/
const refresh = () => {
currentPage.value = 0
finished.value = false
loadData(true)
}
/**
* 滚动事件处理
*/
const handleScroll = () => {
if (!containerRef.value || loading.value || finished.value) return
const { scrollTop, scrollHeight, clientHeight } = document.documentElement
// 当滚动到距离底部指定距离时触发加载
if (scrollTop + clientHeight >= scrollHeight - props.offset) {
loadData()
}
}
/**
* 节流函数 - 优化滚动性能
*/
const throttle = (func: Function, delay: number) => {
let timeoutId: NodeJS.Timeout | null = null
let lastExecTime = 0
return function (this: any, ...args: any[]) {
const currentTime = Date.now()
if (currentTime - lastExecTime > delay) {
func.apply(this, args)
lastExecTime = currentTime
} else {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
func.apply(this, args)
lastExecTime = Date.now()
}, delay - (currentTime - lastExecTime))
}
}
}
// 节流后的滚动处理函数
const throttledScroll = throttle(handleScroll, 100)
// 生命周期
onMounted(() => {
// 添加滚动监听
window.addEventListener('scroll', throttledScroll, { passive: true })
// 立即加载数据
if (props.immediateLoad) {
nextTick(() => {
loadData(true)
})
}
})
onUnmounted(() => {
// 移除滚动监听
window.removeEventListener('scroll', throttledScroll)
})
// 暴露给父组件的方法和数据
defineExpose({
/** 刷新列表 */
refresh,
/** 手动加载下一页 */
loadMore: () => loadData(),
/** 处理加载成功 */
handleLoadSuccess,
/** 处理加载错误 */
handleLoadError,
/** 当前列表数据 */
list,
/** 加载状态 */
loading,
/** 是否已加载完成 */
finished,
/** 当前页码 */
currentPage,
/** 数据总数 */
total
})
</script>
<style scoped>
.pull-refresh-container {
min-height: 100vh;
position: relative;
}
.list-content {
padding-bottom: 60px; /* 为加载状态预留空间 */
}
.loading-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
color: #999;
}
.finished-text {
text-align: center;
padding: 20px;
color: #999;
font-size: 14px;
}
.empty-text {
text-align: center;
padding: 60px 20px;
color: #999;
font-size: 16px;
}
/* 加载动画优化 */
.loading-wrapper :deep(.van-loading__spinner) {
animation-duration: 0.8s;
}
</style>
# PullRefreshList 组件使用说明
## 概述
`PullRefreshList` 是一个基于 Vue 3 + TypeScript + Composition API 开发的下拉加载更多组件,支持分页加载、自动检测滚动位置、Loading 状态显示等功能。
## 特性
- ✅ **Vue 3 + TypeScript + Setup 语法**
- ✅ **自动下拉加载更多**:滚动到底部自动触发加载
- ✅ **Vant Loading 集成**:使用 Vant 的 Loading 组件显示加载状态
- ✅ **智能分页管理**:自动管理页码和总数判断
- ✅ **性能优化**:节流滚动事件,避免频繁触发
- ✅ **完整的 TypeScript 支持**
- ✅ **灵活的插槽设计**:支持自定义列表项渲染
- ✅ **响应式设计**:适配移动端和桌面端
## 基本用法
```vue
<template>
<PullRefreshList
:page-size="10"
:offset="100"
@load="handleLoad"
@loaded="handleLoaded"
@error="handleError"
>
<template #default="{ list }">
<div v-for="item in list" :key="item.id">
{{ item.name }}
</div>
</template>
</PullRefreshList>
</template>
<script setup lang="ts">
import PullRefreshList from '~/components/PullRefreshList.vue'
const handleLoad = async (page: number, pageSize: number) => {
// 在这里调用你的 API
const response = await fetchData(page, pageSize)
// 组件会自动处理数据
}
const handleLoaded = (data: any) => {
console.log('加载完成:', data)
}
const handleError = (error: Error) => {
console.error('加载失败:', error)
}
</script>
属性 (Props)
| 属性名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
pageSize | number | 10 | 每页数据条数 |
offset | number | 50 | 距离底部多少像素时触发加载 |
immediateLoad | boolean | true | 是否立即加载第一页数据 |
事件 (Events)
| 事件名 | 参数 | 说明 |
|---|---|---|
load | (page: number, pageSize: number) | 需要加载数据时触发 |
loaded | (data: PageResponse) | 数据加载完成时触发 |
error | (error: Error) | 数据加载失败时触发 |
插槽 (Slots)
| 插槽名 | 作用域参数 | 说明 |
|---|---|---|
default | { list, loading, finished } | 自定义列表内容渲染 |
暴露的方法
通过 ref 可以访问以下方法:
<template>
<PullRefreshList ref="pullRefreshRef" @load="handleLoad">
<!-- 内容 -->
</PullRefreshList>
</template>
<script setup>
const pullRefreshRef = ref()
// 刷新列表
const refresh = () => {
pullRefreshRef.value?.refresh()
}
// 手动加载更多
const loadMore = () => {
pullRefreshRef.value?.loadMore()
}
</script>
可用方法
refresh(): 刷新列表(重新加载第一页)loadMore(): 手动加载下一页handleLoadSuccess(data): 处理加载成功(通常由组件内部调用)handleLoadError(error): 处理加载错误(通常由组件内部调用)
可访问的响应式数据
list: 当前列表数据loading: 加载状态finished: 是否已加载完成currentPage: 当前页码total: 数据总数
数据格式
组件期望的 API 响应格式:
interface PageResponse {
list: any[] // 当前页数据数组
total: number // 数据总条数
page: number // 当前页码
pageSize: number // 每页条数
}
完整示例
参考 app/pages/list.vue 文件,这是一个完整的商品列表页面示例,展示了:
- 模拟数据生成
- API 请求模拟
- 错误处理
- 自定义列表项渲染
- 响应式设计
- 性能优化
性能优化
组件内置了多项性能优化:
- 节流滚动事件:使用 100ms 节流避免频繁触发
- 懒加载图片:支持
loading="lazy"属性 - 虚拟滚动准备:架构支持后续添加虚拟滚动
- 内存管理:组件卸载时自动清理事件监听器
- CSS 动画优化:使用 GPU 加速的 transform 动画
注意事项
- 确保在父组件的
@load事件中调用handleLoadSuccess或handleLoadError - 组件会自动管理分页状态,无需手动维护页码
- 当
list.length >= total时,组件会自动停止加载更多数据 - 建议在移动端使用时设置合适的
offset值(推荐 50-100px)
浏览器兼容性
- Chrome 60+
- Firefox 60+
- Safari 12+
- Edge 79+
支持所有现代浏览器,使用了 ES6+ 语法和现代 Web API。
使用
<template>
<div class="list-page">
<div class="header">
<h1>商品列表</h1>
<button @click="refreshList" class="refresh-btn" :disabled="pullRefreshRef?.loading">
{{ pullRefreshRef?.loading ? '刷新中...' : '刷新' }}
</button>
</div>
<!-- 使用下拉加载组件 -->
<PullRefreshList
ref="pullRefreshRef"
:page-size="10"
:offset="100"
@load="handleLoad"
@loaded="handleLoaded"
@error="handleError"
>
<template #default="{ list }">
<div class="product-list">
<div
v-for="item in (list as Product[])"
:key="item.id"
class="product-item"
@click="handleItemClick(item)"
>
<div class="product-image">
<img :src="item.image" :alt="item.name" loading="lazy" />
</div>
<div class="product-info">
<h3 class="product-name">{{ item.name }}</h3>
<p class="product-desc">{{ item.description }}</p>
<div class="product-meta">
<span class="product-price">¥{{ item.price }}</span>
<span class="product-sales">已售{{ item.sales }}件</span>
</div>
<div class="product-tags">
<span
v-for="tag in item.tags"
:key="tag"
class="product-tag"
>
{{ tag }}
</span>
</div>
</div>
</div>
</div>
</template>
</PullRefreshList>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import PullRefreshList from '~/components/PullRefreshList.vue'
/**
* 商品数据接口
*/
interface Product {
id: number
name: string
description: string
price: number
image: string
sales: number
tags: string[]
category: string
createTime: string
}
/**
* 分页响应接口
*/
interface PageResponse {
list: Product[]
total: number
page: number
pageSize: number
}
// 组件引用
const pullRefreshRef = ref<InstanceType<typeof PullRefreshList>>()
/**
* 模拟商品数据生成器
* @param count 生成数量
* @param startId 起始ID
*/
const generateMockProducts = (count: number, startId: number): Product[] => {
const categories = ['数码', '服装', '家居', '美食', '运动', '图书']
const adjectives = ['精品', '热销', '新款', '限量', '经典', '时尚', '优质', '特价']
const products = ['手机', '耳机', 'T恤', '连衣裙', '沙发', '台灯', '零食', '咖啡', '跑鞋', '小说']
return Array.from({ length: count }, (_, index) => {
const id = startId + index
const category = categories[Math.floor(Math.random() * categories.length)]
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)]
const product = products[Math.floor(Math.random() * products.length)]
return {
id,
name: `${adjective}${product} #${id}`,
description: `这是一款${adjective}的${product},品质优良,性价比超高,深受用户喜爱。`,
price: Math.floor(Math.random() * 1000) + 50,
image: `https://picsum.photos/200/200?random=${id}`,
sales: Math.floor(Math.random() * 10000),
tags: [
category,
Math.random() > 0.5 ? '包邮' : '自营',
Math.random() > 0.7 ? '新品' : '热销'
].filter((tag): tag is string => Boolean(tag)),
category: category!,
createTime: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString()
}
})
}
/**
* 模拟API请求延迟
* @param ms 延迟毫秒数
*/
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
/**
* 模拟获取商品列表API
* @param page 页码
* @param pageSize 每页数量
*/
const fetchProductList = async (page: number, pageSize: number): Promise<PageResponse> => {
// 模拟网络延迟
await delay(800 + Math.random() * 500)
// 模拟总数据量
const totalCount = 95 // 模拟总共95条数据
// 计算起始ID
const startId = (page - 1) * pageSize + 1
// 计算实际返回数量(最后一页可能不足pageSize)
const remainingCount = totalCount - (page - 1) * pageSize
const actualCount = Math.min(pageSize, Math.max(0, remainingCount))
// 生成模拟数据
const list = actualCount > 0 ? generateMockProducts(actualCount, startId) : []
return {
list,
total: totalCount,
page,
pageSize
}
}
/**
* 处理数据加载
* @param page 页码
* @param pageSize 每页数量
*/
const handleLoad = async (page: number, pageSize: number) => {
try {
console.log(`正在加载第${page}页数据,每页${pageSize}条`)
const response = await fetchProductList(page, pageSize)
// 通知组件加载成功
pullRefreshRef.value?.handleLoadSuccess(response)
} catch (error) {
console.error('加载数据失败:', error)
pullRefreshRef.value?.handleLoadError(error as Error)
}
}
/**
* 处理加载完成
* @param data 加载的数据
*/
const handleLoaded = (data: any) => {
console.log(`第${data.page}页加载完成,本页${data.list.length}条,总计${data.total}条`)
}
/**
* 处理加载错误
* @param error 错误信息
*/
const handleError = (error: Error) => {
console.error('加载失败:', error.message)
// 这里可以添加错误提示,比如使用 Toast 组件
alert(`加载失败: ${error.message}`)
}
/**
* 刷新列表
*/
const refreshList = () => {
pullRefreshRef.value?.refresh()
}
/**
* 处理商品点击
* @param item 商品数据
*/
const handleItemClick = (item: Product) => {
console.log('点击商品:', item)
// 这里可以跳转到商品详情页
// navigateTo(`/product/${item.id}`)
}
// 页面元数据
definePageMeta({
title: '商品列表'
})
</script>
<style scoped>
.list-page {
background-color: #f5f5f5;
min-height: 100vh;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 100;
}
.header h1 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
}
.refresh-btn {
padding: 8px 16px;
background: #1989fa;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
}
.refresh-btn:hover:not(:disabled) {
background: #1976d2;
}
.refresh-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.product-list {
padding: 12px;
}
.product-item {
display: flex;
background: white;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
cursor: pointer;
transition: all 0.3s;
}
.product-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.product-item:active {
transform: translateY(0);
}
.product-image {
width: 80px;
height: 80px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
margin-right: 16px;
}
.product-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.product-item:hover .product-image img {
transform: scale(1.05);
}
.product-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.product-name {
margin: 0 0 8px 0;
font-size: 16px;
font-weight: 600;
color: #333;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.product-desc {
margin: 0 0 12px 0;
font-size: 14px;
color: #666;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.product-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.product-price {
font-size: 18px;
font-weight: 600;
color: #ff4444;
}
.product-sales {
font-size: 12px;
color: #999;
}
.product-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.product-tag {
padding: 2px 8px;
background: #f0f0f0;
color: #666;
font-size: 12px;
border-radius: 12px;
white-space: nowrap;
}
.product-tag:first-child {
background: #e8f4ff;
color: #1989fa;
}
/* 响应式设计 */
@media (max-width: 768px) {
.header {
padding: 12px 16px;
}
.header h1 {
font-size: 16px;
}
.product-list {
padding: 8px;
}
.product-item {
padding: 12px;
margin-bottom: 8px;
}
.product-image {
width: 60px;
height: 60px;
margin-right: 12px;
}
.product-name {
font-size: 15px;
}
.product-desc {
font-size: 13px;
}
.product-price {
font-size: 16px;
}
}
/* 加载动画优化 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.product-item {
animation: fadeInUp 0.4s ease-out;
}
</style>