下拉加载更多

41 阅读9分钟
<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)

属性名类型默认值说明
pageSizenumber10每页数据条数
offsetnumber50距离底部多少像素时触发加载
immediateLoadbooleantrue是否立即加载第一页数据

事件 (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 文件,这是一个完整的商品列表页面示例,展示了:

  1. 模拟数据生成
  2. API 请求模拟
  3. 错误处理
  4. 自定义列表项渲染
  5. 响应式设计
  6. 性能优化

性能优化

组件内置了多项性能优化:

  1. 节流滚动事件:使用 100ms 节流避免频繁触发
  2. 懒加载图片:支持 loading="lazy" 属性
  3. 虚拟滚动准备:架构支持后续添加虚拟滚动
  4. 内存管理:组件卸载时自动清理事件监听器
  5. CSS 动画优化:使用 GPU 加速的 transform 动画

注意事项

  1. 确保在父组件的 @load 事件中调用 handleLoadSuccesshandleLoadError
  2. 组件会自动管理分页状态,无需手动维护页码
  3. list.length >= total 时,组件会自动停止加载更多数据
  4. 建议在移动端使用时设置合适的 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>