LoadMoreList 完整使用指南.md 40.8 KB

LoadMoreList 组件完整指南

版本: 2.0.0 更新时间: 2026-02-08 维护者: Claude Code


📚 目录


组件概述

🎯 设计目标

LoadMoreList 是一个通用分页列表组件,旨在解决以下问题:

  1. 代码重复:多个页面都实现了相同的分页加载逻辑
  2. 维护困难:修改动效需要同时改多个文件
  3. 集成成本高:新页面需要复制粘贴大量代码

✨ 核心特性

  • 自动分页加载:触底自动加载下一页(300ms 防抖)
  • 下拉刷新:可选的下拉刷新功能
  • 多种状态:首次加载、加载中、空状态、没有更多
  • 动画效果:列表项逐个进入动画(前10项延迟)
  • 高度可定制:支持自定义头部、列表项、空状态等
  • 性能优化:只为前10项使用动画延迟,避免累积延迟

📊 迁移收益

指标 迁移前 迁移后 改善
重复代码行数 ~700 行 0 行 -100%
页面平均代码行数 ~400 行 ~300 行 -25%
动效统一性 ❌ 不一致 ✅ 完全统一
维护成本 ❌ 高(5个文件) ✅ 低(1个文件)
新页面集成成本 ~150 行 ~10 行 -93%

组件 API

Props 属性

Prop 类型 默认值 必需 说明
list Array<any> [] 列表数据源
page Number - 当前页码(从0或1开始)
pageSize Number 10 每页数量
hasMore Boolean true 是否还有更多数据
loading Boolean false 首次加载状态
loadingMore Boolean false 加载更多状态
keyField String 'id' 唯一标识字段名
showHeader Boolean true 是否显示固定头部区域
enablePullDownRefresh Boolean false 是否启用下拉刷新
noPadding Boolean false 列表容器是否不需要 padding

Events 事件

Event 参数 说明
load-more page: number 触底加载更多时触发,传入下一页页码(page + 1)
refresh - 下拉刷新时触发(需启用 enablePullDownRefresh

Slots 插槽

Slot 作用域参数 说明
header - 自定义固定头部区域(导航、搜索、tabs等)
item { item, index } 自定义列表项渲染
loading - 自定义首次加载状态
loading-more - 自定义加载更多状态
empty - 自定义空状态
no-more - 自定义"没有更多"提示

快速开始

最简单的使用方式

<template>
  <LoadMoreList
    :list="products"
    :page="page"
    :page-size="10"
    :has-more="hasMore"
    :loading="loading"
    :loading-more="loadingMore"
    key-field="id"
    @load-more="handleLoadMore"
  >
    <template #item="{ item }">
      <view class="product-item">{{ item.name }}</view>
    </template>
  </LoadMoreList>
</template>

<script setup>
import { ref } from 'vue'
import LoadMoreList from '@/components/LoadMoreList'

const products = ref([])
const page = ref(0)
const hasMore = ref(true)
const loading = ref(false)
const loadingMore = ref(false)

const handleLoadMore = async (nextPage) => {
  page.value = nextPage
  // 加载数据...
}
</script>

带头部和空状态

<template>
  <LoadMoreList
    :list="products"
    :page="page"
    :page-size="10"
    :has-more="hasMore"
    :loading="loading"
    :loading-more="loadingMore"
    @load-more="handleLoadMore"
  >
    <template #header>
      <NavHeader title="产品中心" />
    </template>

    <template #item="{ item }">
      <ProductCard :product="item" />
    </template>

    <template #empty>
      <nut-empty description="暂无相关产品" image="empty" />
    </template>
  </LoadMoreList>
</template>

带下拉刷新

<template>
  <LoadMoreList
    :list="messages"
    :page="page"
    :page-size="10"
    :has-more="hasMore"
    :loading="loading"
    :loading-more="loadingMore"
    :enable-pull-down-refresh="true"
    @load-more="handleLoadMore"
    @refresh="handleRefresh"
  >
    <template #header>
      <NavHeader title="我的消息" />
    </template>

    <template #item="{ item }">
      <view class="message-item">{{ item.title }}</view>
    </template>
  </LoadMoreList>
</template>

<script setup>
const handleRefresh = async () => {
  page.value = 0  // 或 1,根据 API 要求
  hasMore.value = true
  await fetchData(true)  // true 表示刷新
}
</script>

实际案例

案例 1: 简单列表(week-hot-material、message)

场景: 只需展示列表,支持下拉刷新

页面: week-hot-materialmessage

<template>
  <LoadMoreList
    :list="currentList"
    :page="currentPage"
    :page-size="pageSize"
    :has-more="hasMore"
    :loading="loading"
    :loading-more="loadingMore"
    :enable-pull-down-refresh="true"
    key-field="id"
    @load-more="handleLoadMore"
    @refresh="handleRefresh"
  >
    <template #header>
      <NavHeader title="我的消息" />
    </template>

    <template #item="{ item }">
      <view class="message-item" @tap="handleItemClick(item)">
        <view class="title">{{ item.title }}</view>
        <view class="intro">{{ item.intro }}</view>
      </view>
    </template>
  </LoadMoreList>
</template>

<script setup>
import { ref } from 'vue'
import { useLoad } from '@tarojs/taro'
import LoadMoreList from '@/components/LoadMoreList'

const currentList = ref([])
const currentPage = ref(1)
const pageSize = 10
const hasMore = ref(true)
const loading = ref(false)
const loadingMore = ref(false)

// 加载数据
const fetchMessageList = async (params = {}, isLoadMore = false) => {
  try {
    if (isLoadMore) {
      loadingMore.value = true
    } else {
      loading.value = true
    }

    const res = await myListAPI(params)

    if (res.code === 1 && res.data) {
      const listData = res.data.list || []

      if (isLoadMore) {
        currentList.value = [...currentList.value, ...listData]
      } else {
        currentList.value = listData
      }

      hasMore.value = listData.length >= pageSize
    }
  } catch (err) {
    console.error('获取消息失败:', err)
  } finally {
    if (isLoadMore) {
      loadingMore.value = false
    } else {
      loading.value = false
    }
  }
}

// 加载更多
const handleLoadMore = async (page) => {
  currentPage.value = page
  await fetchMessageList({ page, limit: pageSize }, true)
}

// 下拉刷新
const handleRefresh = async () => {
  currentPage.value = 1
  hasMore.value = true
  await fetchMessageList({ page: 1, limit: pageSize })
}

// 页面加载
useLoad(async () => {
  await fetchMessageList({ page: 1, limit: pageSize })
})
</script>

要点:

  • enable-pull-down-refresh="true" 启用下拉刷新
  • ✅ 实现 handleRefresh 函数,重置页码和 hasMore
  • ✅ 使用 ...currentList.value, ...listData 追加数据

案例 2: 带搜索和 Tabs 的列表(product-center)

场景: 需要搜索功能、分类 tabs、计划书弹窗

页面: product-center

<template>
  <view class="bg-[#F9FAFB]">
    <!-- 计划书弹窗(放在 LoadMoreList 外部) -->
    <view v-if="showPlanPopup && selectedProduct">
      <PlanFormContainer
        v-model:visible="showPlanPopup"
        :product="selectedProduct"
        @submit="handlePlanSubmit"
      />
    </view>

    <LoadMoreList
      :list="currentList"
      :page="currentPage"
      :page-size="pageSize"
      :has-more="hasMore"
      :loading="loading"
      :loading-more="loadingMore"
      key-field="id"
      @load-more="handleLoadMore"
    >
      <!-- 固定头部:导航 + 搜索 + Tabs -->
      <template #header>
        <view class="sticky top-0 z-10 bg-[#F9FAFB]">
          <NavHeader title="产品中心" />

          <!-- Search Bar -->
          <view class="px-[24rpx] py-[16rpx] bg-white">
            <SearchBar
              v-model="searchValue"
              placeholder="搜索产品名称..."
              @search="onSearch"
              @input="onSearchInput"
              @clear="onClear"
            />
          </view>

          <!-- Tabs Container -->
          <view class="bg-white mt-[2rpx]">
            <nut-tabs v-model="activeTabId">
              <!-- 自定义标签栏 -->
              <template #titles>
                <view class="filter-tabs-wrapper">
                  <view
                    v-for="item in tabsData"
                    :key="item.id"
                    :class="[
                      'filter-tab-item',
                      activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
                    ]"
                    @tap="onTabClick(item.id)"
                  >
                    <text class="filter-tab-text">{{ item.name }}</text>
                  </view>
                </view>
              </template>
            </nut-tabs>
          </view>
        </view>
      </template>

      <!-- 列表项:产品卡片 -->
      <template #item="{ item }">
        <view class="product-card" @tap="handleProductClick(item)">
          <!-- 产品内容 -->
        </view>
      </template>

      <!-- 空状态 -->
      <template #empty>
        <nut-empty description="暂无相关产品" image="empty" />
      </template>
    </LoadMoreList>
  </view>
</template>

<script setup>
import { ref, computed } from 'vue'
import { useLoad } from '@tarojs/taro'
import LoadMoreList from '@/components/LoadMoreList'

const currentList = ref([])
const currentPage = ref(0)
const pageSize = 10
const hasMore = ref(true)
const loading = ref(false)
const loadingMore = ref(false)

// 搜索和 Tabs 相关状态
const activeTabId = ref('')
const searchValue = ref('')
const categories = ref([]) // 从接口获取的分类列表
let searchTimer = null // 搜索防抖定时器

// 计划书弹窗状态
const showPlanPopup = ref(false)
const selectedProduct = ref(null)

// 标签栏数据(根据接口返回的 categories 生成)
const tabsData = computed(() => {
  const allTab = { id: '', name: '全部' }
  const categoryTabs = categories.value.map(cat => ({
    id: String(cat.id),
    name: cat.name
  }))
  return [allTab, ...categoryTabs]
})

// 加载产品列表
const fetchProducts = async (params = {}, isLoadMore = false) => {
  try {
    if (isLoadMore) {
      loadingMore.value = true
    } else {
      loading.value = true
    }

    const res = await listAPI(params)

    if (res.code === 1 && res.data) {
      // 更新分类列表(首次加载时)
      if (!isLoadMore && res.data.categories) {
        categories.value = res.data.categories
      }

      // 处理产品列表
      if (res.data.list?.length) {
        const listData = res.data.list

        if (isLoadMore) {
          currentList.value = [...currentList.value, ...listData]
        } else {
          currentList.value = listData
        }

        hasMore.value = listData.length >= params.limit
      }
    }
  } catch (error) {
    console.error('获取产品列表失败:', error)
  } finally {
    if (isLoadMore) {
      loadingMore.value = false
    } else {
      loading.value = false
    }
  }
}

// 页面加载时获取数据
useLoad(async (options) => {
  await fetchProducts({ page: 0, limit: pageSize })
})

// 处理加载更多事件
const handleLoadMore = async (page) => {
  currentPage.value = page

  // 构建请求参数
  const params = {
    page: page,
    limit: pageSize
  }

  // 如果不是"全部"标签,添加分类 ID 参数
  if (activeTabId.value !== '') {
    params.cid = activeTabId.value
  }

  // 添加搜索关键词参数
  if (searchValue.value) {
    params.keyword = searchValue.value
  }

  // 加载下一页数据
  await fetchProducts(params, true)
}

// Tab 点击处理
const onTabClick = (id) => {
  if (activeTabId.value === id) return

  activeTabId.value = id
  currentPage.value = 0
  hasMore.value = true

  // 构建请求参数
  const params = {
    page: 0,
    limit: pageSize
  }

  if (id !== '') {
    params.cid = id
  }

  if (searchValue.value) {
    params.keyword = searchValue.value
  }

  // 重新加载数据
  fetchProducts(params, false)
}

// 搜索输入处理(带防抖)
const onSearchInput = (value) => {
  if (searchTimer) {
    clearTimeout(searchTimer)
  }

  // 500ms 后执行搜索
  searchTimer = setTimeout(() => {
    currentPage.value = 0
    hasMore.value = true

    const params = {
      page: 0,
      limit: pageSize
    }

    if (activeTabId.value !== '') {
      params.cid = activeTabId.value
    }

    if (value) {
      params.keyword = value
    }

    fetchProducts(params, false)
  }, 500)
}

// 其他处理函数...
</script>

要点:

  • 固定头部: 使用 sticky top-0 z-10 实现吸顶效果
  • 搜索防抖: 500ms 延迟,避免频繁请求
  • 弹窗位置: PlanFormContainer 放在 LoadMoreList 外部作为兄弟节点
  • 参数构建: 在 handleLoadMore 中同时处理分类和搜索状态

案例 3: 带分类缓存的列表(material-list)

场景: 需要缓存每个分类的分页状态,切换分类时保留滚动位置

页面: material-list

<template>
  <LoadMoreList
    :list="currentList"
    :page="currentPage"
    :page-size="pageSize"
    :has-more="hasMore"
    :loading="loading"
    :loading-more="loadingMore"
    key-field="id"
    @load-more="handleLoadMore"
  >
    <!-- 固定头部:导航 + 搜索 + Tabs -->
    <template #header>
      <view class="sticky top-0 z-10 bg-[#FFF]">
        <NavHeader title="资料列表" />

        <view class="px-[32rpx] py-[24rpx]">
          <SearchBar
            v-model="searchValue"
            placeholder="搜索资料名称..."
            @search="onSearch"
          />
        </view>

        <!-- Tabs Container -->
        <nut-tabs v-model="activeTabId">
          <template #titles>
            <view class="filter-tabs-wrapper">
              <view
                v-for="item in tabsData"
                :key="item.id"
                :class="[
                  'filter-tab-item',
                  activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
                ]"
                @tap="onTabClick(item.id)"
              >
                <text class="filter-tab-text">{{ item.name }}</text>
              </view>
            </view>
          </template>
        </nut-tabs>
      </view>
    </template>

    <!-- 列表项 -->
    <template #item="{ item }">
      <MaterialCard
        :id="item.id"
        :title="item.name"
        @collect-changed="handleCollectChanged"
      />
    </template>
  </LoadMoreList>
</template>

<script setup>
import { ref } from 'vue'
import { useLoad } from '@tarojs/taro'
import LoadMoreList from '@/components/LoadMoreList'

const currentList = ref([])
const currentPage = ref(0)
const pageSize = 20
const hasMore = ref(true)
const loading = ref(false)
const loadingMore = ref(false)

// 分类缓存(使用 Map 保存分页状态)
const categoryPageCache = ref(new Map())
const categoryListCache = ref(new Map())

// 搜索和 Tabs 状态
const searchValue = ref('')
const activeTabId = ref('all')
const initialCategoryId = ref('')
const tabsData = ref([
  { id: 'all', name: '全部' },
  // ... 其他分类
])

// 加载资料列表
const fetchMaterialList = async (params = {}, isLoadMore = false) => {
  try {
    if (isLoadMore) {
      loadingMore.value = true
    } else {
      loading.value = true
    }

    const res = await fileListAPI(params)

    if (res.code === 1 && res.data) {
      const listData = res.data.list || []

      if (isLoadMore) {
        currentList.value = [...currentList.value, ...listData]
      } else {
        currentList.value = listData
      }

      hasMore.value = listData.length >= params.limit

      // ⭐ 保存分页状态到缓存
      const isSearching = searchValue.value.trim() !== ''
      let cacheKey = isSearching ? searchValue.value.trim() :
                    (activeTabId.value !== 'all' ? activeTabId.value : 'all')

      categoryPageCache.value.set(cacheKey, {
        currentPage: currentPage.value,
        hasMore: hasMore.value
      })

      // ⭐ 保存列表数据到缓存
      if (!isSearching) {
        categoryListCache.value.set(cacheKey, [...currentList.value])
      }
    }
  } catch (err) {
    console.error('获取资料列表失败:', err)
  } finally {
    if (isLoadMore) {
      loadingMore.value = false
    } else {
      loading.value = false
    }
  }
}

// 处理加载更多事件
const handleLoadMore = async (page) => {
  currentPage.value = page

  const isSearching = searchValue.value.trim() !== ''
  const params = {
    cid: initialCategoryId.value,
    page: page,
    limit: pageSize
  }

  if (isSearching) {
    params.keyword = searchValue.value.trim()
    if (activeTabId.value !== 'all') {
      params.child_id = activeTabId.value
    }
  } else {
    if (activeTabId.value !== 'all') {
      params.child_id = activeTabId.value
    }
  }

  await fetchMaterialList(params, true)
}

// Tab 点击处理
const onTabClick = (id) => {
  if (activeTabId.value === id) return

  activeTabId.value = id

  // ⭐ 从缓存读取分页状态
  const cached = categoryPageCache.value.get(id)
  if (cached) {
    currentPage.value = cached.currentPage
    hasMore.value = cached.hasMore
    currentList.value = categoryListCache.value.get(id) || []
  } else {
    currentPage.value = 0
    hasMore.value = true
    currentList.value = []
  }

  // 重新加载数据(如果缓存为空)
  if (!cached || currentList.value.length === 0) {
    fetchMaterialList({
      cid: initialCategoryId.value,
      child_id: id !== 'all' ? id : undefined,
      page: currentPage.value,
      limit: pageSize
    })
  }
}
</script>

要点:

  • 分类缓存: 使用 Map 保存每个分类的分页状态和列表数据
  • 切换优化: 切换分类时先从缓存读取,避免重新加载
  • 搜索与分类区分: 搜索结果不缓存,分类结果才缓存

案例 4: 双列表系统(search)

场景: 同时支持产品和资料搜索,自动选择有结果的 tab

页面: search

<template>
  <view class="bg-[#FFF]">
    <LoadMoreList
      :list="currentList"
      :page="currentPage"
      :page-size="pageSize"
      :has-more="hasMore"
      :loading="loading"
      :loading-more="loadingMore"
      :show-header="true"
      key-field="id"
      @load-more="handleLoadMore"
    >
      <!-- 固定顶部:导航栏 + 搜索栏 + Tabs + 结果计数 -->
      <template #header>
        <view class="bg-[#FFF] sticky top-0 z-10">
          <NavHeader title="搜索" />

          <!-- Search Input -->
          <view class="px-[40rpx] mt-[32rpx]">
            <SearchBar
              v-model="searchKeyword"
              placeholder="搜索培训资料、案例、产品..."
              @search="handleSearch"
              @clear="clearSearch"
            />
          </view>

          <!-- Tabs Container -->
          <nut-tabs v-model="activeTab">
            <template #titles>
              <view class="filter-tabs-wrapper">
                <view
                  v-for="item in tabsData"
                  :key="item.id"
                  :class="[
                    'filter-tab-item',
                    activeTab === item.id ? 'filter-tab-active' : 'filter-tab-inactive',
                    !activeTab ? 'filter-tab-inactive' : ''
                  ]"
                  @tap="onTabClick(item.id)"
                >
                  <text class="filter-tab-text">{{ item.name }}</text>
                </view>
              </view>
            </template>
          </nut-tabs>

          <!-- Result Count -->
          <view v-if="currentList.length > 0" class="px-[60rpx] text-[#6B7280] text-[24rpx] pb-[24rpx]">
            找到 {{ currentTotal }} 个相关结果
          </view>
        </view>
      </template>

      <!-- 列表项:根据 activeTab 动态渲染 -->
      <template #item="{ item }">
        <!-- Product Results -->
        <ProductCard
          v-if="activeTab === 'product'"
          :product-id="item.id"
          :product-name="item.product_name || item.name"
          :tags="item.tags || []"
          @detail="goToProductDetail"
          @plan="openPlanPopup"
        />

        <!-- File Results -->
        <MaterialCard
          v-else-if="activeTab === 'file'"
          :id="item.id"
          :title="item.title"
          :file-name="item.fileName"
          :file-size="item.fileSize"
          @collect-changed="handleCollectChanged"
        />
      </template>

      <!-- 自定义空状态:处理三种状态 -->
      <template #empty>
        <!-- Initial State (从未搜索过) -->
        <view v-if="!hasSearched" class="flex flex-col items-center justify-center py-[120rpx]">
          <IconFont name="search" class="text-gray-300 mb-[24rpx]" size="64" />
          <view class="text-[#6B7280] text-[28rpx]">搜索产品或资料</view>
          <view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">输入关键词开始搜索,自动切换分类</view>
        </view>

        <!-- Empty State (已搜索但无结果) -->
        <view v-else>
          <nut-empty description="暂无搜索结果" image="empty">
            <view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">试试其他关键词吧</view>
          </nut-empty>
        </view>
      </template>
    </LoadMoreList>

    <!-- Plan Form Container -->
    <PlanFormContainer
      v-if="selectedProduct"
      v-model:visible="showPlanPopup"
      :product="selectedProduct"
      @submit="handlePlanSubmit"
    />
  </view>
</template>

<script setup>
import { ref, computed } from 'vue'
import LoadMoreList from '@/components/LoadMoreList'

// State
const searchKeyword = ref('')
const activeTab = ref('')          // 当前选中的 tab(初始为空)
const hasSearched = ref(false)     // 是否已经搜索过

// 数据状态 - 双列表系统
const products = ref([])           // 产品列表
const files = ref([])               // 资料列表
const productsTotal = ref(0)       // 产品总数
const filesTotal = ref(0)           // 资料总数

// 分页状态
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = ref(true)
const currentPage = ref(0)
const pageSize = 20

/**
 * 当前列表的列表
 * @description 根据 activeTab 动态返回对应的列表数据
 */
const currentList = computed(() => {
  if (!activeTab.value) return []

  if (activeTab.value === 'product') {
    return products.value
  } else {
    return files.value
  }
})

/**
 * 当前列表总数
 */
const currentTotal = computed(() => {
  if (!activeTab.value) return 0

  if (activeTab.value === 'product') {
    return productsTotal.value
  } else {
    return filesTotal.value
  }
})

/**
 * 执行搜索
 */
const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMore = false) => {
  try {
    if (isLoadMore) {
      loadingMore.value = true
    } else {
      loading.value = true
    }

    const params = { keyword, page, limit }
    if (type) params.type = type

    const res = await searchAPI(params)

    if (res.code === 1) {
      // 映射产品列表
      const newProducts = res.data.products.list || []
      // 映射资料列表...
      const newFiles = (res.data.files.list || []).map(item => ({ /* ... */ }))

      // 根据是否为加载更多来处理数据
      if (isLoadMore) {
        products.value = [...products.value, ...newProducts]
        files.value = [...files.value, ...newFiles]
      } else {
        products.value = newProducts
        files.value = newFiles
      }

      productsTotal.value = res.data.products.total || 0
      filesTotal.value = res.data.files.total || 0

      // ⭐ 重要:自动选择有数据的 tab
      if (!type && !isLoadMore) {
        if (productsTotal.value > 0) {
          activeTab.value = 'product'
        } else if (filesTotal.value > 0) {
          activeTab.value = 'file'
        }
      }

      // 判断是否还有更多数据
      const actualTab = type || activeTab.value
      if (actualTab === 'product') {
        hasMore.value = products.value.length < productsTotal.value
      } else if (actualTab === 'file') {
        hasMore.value = files.value.length < filesTotal.value
      } else {
        hasMore.value = false
      }

      hasSearched.value = true
    }
  } catch (err) {
    console.error('搜索失败:', err)
  } finally {
    if (isLoadMore) {
      loadingMore.value = false
    } else {
      loading.value = false
    }
  }
}

/**
 * 处理加载更多事件
 */
const handleLoadMore = async (page) => {
  currentPage.value = page

  if (!hasSearched.value || !activeTab.value || !searchKeyword.value.trim()) {
    return
  }

  await performSearch(
    searchKeyword.value.trim(),
    activeTab.value,
    page,
    pageSize,
    true
  )
}

/**
 * Tab 点击处理(实时查询)
 */
const onTabClick = async (tabId) => {
  if (activeTab.value === tabId) return

  activeTab.value = tabId
  currentPage.value = 0
  hasMore.value = true

  if (hasSearched.value && searchKeyword.value.trim()) {
    await performSearch(searchKeyword.value.trim(), tabId, 0, pageSize, false)
  }
}

/**
 * 提交搜索
 */
const handleSearch = async () => {
  const keyword = searchKeyword.value.trim()
  if (!keyword) {
    // 提示输入关键词
    return
  }

  currentPage.value = 0
  hasMore.value = true

  // 不传 type,让后端返回两种数据,前端自动选择 tab
  await performSearch(keyword, undefined, 0, pageSize, false)
}

/**
 * 清空搜索
 */
const clearSearch = () => {
  searchKeyword.value = ''
  hasSearched.value = false
  products.value = []
  files.value = []
  productsTotal.value = 0
  filesTotal.value = 0
  activeTab.value = ''
  currentPage.value = 0
  hasMore.value = true
}
</script>

要点:

  • 双列表系统: products 和 files 分别存储
  • 动态列表: currentList computed 根据 activeTab 动态返回
  • 自动 tab 选择: 首次搜索时自动选择有结果的 tab
  • 三种空状态: 初始状态、搜索无结果、有结果

迁移模式

模式 1: 简单列表迁移

适用场景: 只需展示列表,无复杂交互

迁移步骤:

  1. 定义状态

    const currentList = ref([])
    const currentPage = ref(0)
    const pageSize = 10
    const hasMore = ref(true)
    const loading = ref(false)
    const loadingMore = ref(false)
    
  2. 实现数据加载函数

    const fetchData = async (params = {}, isLoadMore = false) => {
    try {
    if (isLoadMore) {
      loadingMore.value = true
    } else {
      loading.value = true
    }
    
    const res = await yourAPI(params)
    
    if (res.code === 1 && res.data) {
      const listData = res.data.list || []
    
      if (isLoadMore) {
        currentList.value = [...currentList.value, ...listData]
      } else {
        currentList.value = listData
      }
    
      hasMore.value = listData.length >= pageSize
    }
    } catch (err) {
    console.error('获取数据失败:', err)
    } finally {
    if (isLoadMore) {
      loadingMore.value = false
    } else {
      loading.value = false
    }
    }
    }
    
  3. 实现 handleLoadMore

    const handleLoadMore = async (page) => {
    currentPage.value = page
    await fetchData({ page, limit: pageSize }, true)
    }
    
  4. 使用组件

    <LoadMoreList
    :list="currentList"
    :page="currentPage"
    :page-size="pageSize"
    :has-more="hasMore"
    :loading="loading"
    :loading-more="loadingMore"
    @load-more="handleLoadMore"
    >
    <template #item="{ item }">
    <!-- 列表项内容 -->
    </template>
    </LoadMoreList>
    

模式 2: 带搜索的列表迁移

适用场景: 需要搜索功能,可能还需要 tabs

关键点:

  1. 搜索防抖: 500ms 延迟 ```javascript let searchTimer = null

const onSearchInput = (value) => { if (searchTimer) { clearTimeout(searchTimer) }

searchTimer = setTimeout(() => { currentPage.value = 0 hasMore.value = true

const params = {
  page: 0,
  limit: pageSize
}

if (value) {
  params.keyword = value
}

fetchData(params, false)

}, 500) }


2. **固定头部**: 使用 `sticky top-0 z-10`
```vue
<template #header>
  <view class="sticky top-0 z-10 bg-[#FFF]">
    <NavHeader title="产品中心" />
    <SearchBar v-model="searchValue" @search="onSearch" />
  </view>
</template>

模式 3: 带分类缓存的列表迁移

适用场景: 需要缓存每个分类的分页状态

关键点:

  1. 使用 Map 保存缓存

    const categoryPageCache = ref(new Map())
    const categoryListCache = ref(new Map())
    
  2. 保存状态到缓存

    const cacheKey = activeTabId.value
    categoryPageCache.value.set(cacheKey, {
    currentPage: currentPage.value,
    hasMore: hasMore.value
    })
    categoryListCache.value.set(cacheKey, [...currentList.value])
    
  3. 从缓存读取

    const cached = categoryPageCache.value.get(tabId)
    if (cached) {
    currentPage.value = cached.currentPage
    hasMore.value = cached.hasMore
    currentList.value = categoryListCache.value.get(tabId) || []
    }
    

模式 4: 带下拉刷新的列表迁移

适用场景: 需要支持下拉刷新

关键点:

  1. 启用下拉刷新

    <LoadMoreList
    :enable-pull-down-refresh="true"
    @refresh="handleRefresh"
    >
    <!-- ... -->
    </LoadMoreList>
    
  2. 实现 handleRefresh

    const handleRefresh = async () => {
    currentPage.value = 0  // 或 1,根据 API 要求
    hasMore.value = true
    await fetchData({ page: currentPage.value, limit: pageSize })
    }
    

模式 5: 双列表系统迁移

适用场景: 需要支持多种类型的数据(产品和资料)

关键点:

  1. 双列表存储

    const products = ref([])
    const files = ref([])
    
  2. 动态列表 computed

    const currentList = computed(() => {
    if (activeTab.value === 'product') {
    return products.value
    } else {
    return files.value
    }
    })
    
  3. 动态列表总数 computed

    const currentTotal = computed(() => {
    if (activeTab.value === 'product') {
    return productsTotal.value
    } else {
    return filesTotal.value
    }
    })
    

最佳实践

✅ 推荐做法

1. 使用 key-field prop

确保列表更新正确,避免渲染问题。

<LoadMoreList
  :list="products"
  key-field="id"  <!-- ✅ 使用唯一标识 -->
  @load-more="handleLoadMore"
>

2. 区分 loadingloadingMore

提升用户体验,显示不同的加载状态。

// 首次加载
loading.value = true
currentList.value = []

// 加载更多
loadingMore.value = true
currentList.value = [...currentList.value, ...newData]

3. 使用 JSDoc 注释

提升代码可读性。

/**
 * 获取产品列表
 *
 * @description 从 API 获取产品列表数据
 * @param {Object} params - 请求参数
 * @param {number} params.page - 页码
 * @param {number} params.limit - 每页数量
 * @param {boolean} isLoadMore - 是否为加载更多
 * @returns {Promise<void>}
 */
const fetchProducts = async (params = {}, isLoadMore = false) => {
  // ...
}

4. 使用 slot 自定义

保持组件灵活性,不要过度修改组件内部代码。

<LoadMoreList>
  <template #header>
    <!-- 自定义头部 -->
  </template>

  <template #item="{ item }">
    <!-- 自定义列表项 -->
  </template>

  <template #empty>
    <!-- 自定义空状态 -->
  </template>
</LoadMoreList>

5. 使用 computed 简化模板

将复杂逻辑提取到 computed 中。

const currentList = computed(() => {
  if (activeTab.value === 'product') {
    return products.value
  } else {
    return files.value
  }
})

❌ 避免做法

1. 在 slot 中处理分页

应该在父组件处理,不要在 slot 中修改分页状态。

<!-- ❌ 错误 -->
<template #item="{ item }">
  <view @tap="loadMore()">加载更多</view>
</template>

<!-- ✅ 正确 -->
<LoadMoreList @load-more="handleLoadMore">
  <!-- 组件内部会自动触发 load-more 事件 -->
</LoadMoreList>

2. 忽略 key-field

可能导致列表更新异常。

<!-- ❌ 错误 -->
<LoadMoreList :list="products">

<!-- ✅ 正确 -->
<LoadMoreList :list="products" key-field="id">

3. 直接修改 props

应该通过事件通知父组件。

// ❌ 错误
const handleClick = () => {
  props.list.push(newItem)  // 直接修改 props
}

// ✅ 正确
const emit = defineEmits(['update'])
const handleClick = () => {
  emit('update', [...props.list, newItem])
}

4. 过度自定义 slot

能用默认的就用默认的,保持简洁。

<!-- ❌ 过度自定义 -->
<LoadMoreList>
  <template #loading>
    <!-- 复杂的加载动画 -->
  </template>

  <template #loading-more>
    <!-- 复杂的加载动画 -->
  </template>
</LoadMoreList>

<!-- ✅ 使用默认 -->
<LoadMoreList>
  <!-- 组件内置的加载动画已经很好了 -->
</LoadMoreList>

常见问题

Q1: 如何处理 API 页码从 1 开始还是从 0 开始?

A: 根据 API 要求设置初始页码。

// 如果 API 页码从 1 开始
const currentPage = ref(1)

// 如果 API 页码从 0 开始
const currentPage = ref(0)

// handleLoadMore 不需要修改,组件会自动 +1
const handleLoadMore = async (page) => {
  currentPage.value = page
  await fetchData({ page, limit: pageSize }, true)
}

Q2: 如何判断是否还有更多数据?

A: 比较返回的数据量与请求的数据量。

if (res.code === 1 && res.data) {
  const listData = res.data.list || []

  if (isLoadMore) {
    currentList.value = [...currentList.value, ...listData]
  } else {
    currentList.value = listData
  }

  // 如果返回的数据量 >= 请求的量,说明还有更多
  hasMore.value = listData.length >= pageSize
}

Q3: 如何实现搜索防抖?

A: 使用 setTimeout + clearTimeout

let searchTimer = null

const onSearchInput = (value) => {
  // 清除之前的定时器
  if (searchTimer) {
    clearTimeout(searchTimer)
  }

  // 设置新的定时器(500ms 后执行搜索)
  searchTimer = setTimeout(() => {
    currentPage.value = 0
    hasMore.value = true

    const params = {
      page: 0,
      limit: pageSize
    }

    if (value) {
      params.keyword = value
    }

    fetchData(params, false)
  }, 500)
}

Q4: 如何缓存分类的分页状态?

A: 使用 Map 保存每个分类的状态。

// 缓存 Map
const categoryPageCache = ref(new Map())
const categoryListCache = ref(new Map())

// 保存到缓存
const saveToCache = (tabId) => {
  categoryPageCache.value.set(tabId, {
    currentPage: currentPage.value,
    hasMore: hasMore.value
  })
  categoryListCache.value.set(tabId, [...currentList.value])
}

// 从缓存读取
const loadFromCache = (tabId) => {
  const cached = categoryPageCache.value.get(tabId)
  if (cached) {
    currentPage.value = cached.currentPage
    hasMore.value = cached.hasMore
    currentList.value = categoryListCache.value.get(tabId) || []
  }
}

// Tab 点击时先尝试从缓存读取
const onTabClick = (tabId) => {
  loadFromCache(tabId)

  // 如果缓存为空,重新加载
  if (currentList.value.length === 0) {
    fetchData({ /* ... */ }, false)
  }
}

Q5: 如何实现双列表系统(如搜索页)?

A: 分别存储两个列表,使用 computed 动态返回。

// 双列表存储
const products = ref([])
const files = ref([])

// 动态列表
const currentList = computed(() => {
  if (activeTab.value === 'product') {
    return products.value
  } else {
    return files.value
  }
})

// 动态总数
const currentTotal = computed(() => {
  if (activeTab.value === 'product') {
    return productsTotal.value
  } else {
    return filesTotal.value
  }
})

Q6: 如何处理嵌套弹窗(如计划书弹窗)?

A: 将弹窗组件放在 LoadMoreList 外部作为兄弟节点。

<template>
  <view>
    <!-- 计划书弹窗(放在 LoadMoreList 外部) -->
    <view v-if="showPlanPopup && selectedProduct">
      <PlanFormContainer
        v-model:visible="showPlanPopup"
        :product="selectedProduct"
        @submit="handlePlanSubmit"
      />
    </view>

    <!-- LoadMoreList -->
    <LoadMoreList
      :list="currentList"
      @load-more="handleLoadMore"
    >
      <!-- ... -->
    </LoadMoreList>
  </view>
</template>

Q7: 如何实现三种空状态(初始、搜索无结果、有结果)?

A: 使用状态变量控制不同的空状态显示。

<template #empty>
  <!-- Initial State (从未搜索过) -->
  <view v-if="!hasSearched">
    <IconFont name="search" />
    <view>搜索产品或资料</view>
  </view>

  <!-- Empty State (已搜索但无结果) -->
  <view v-else>
    <nut-empty description="暂无搜索结果" />
  </view>
</template>

<script setup>
const hasSearched = ref(false)

const handleSearch = async () => {
  // ...
  hasSearched.value = true
}

const clearSearch = () => {
  hasSearched.value = false
  // ...
}
</script>

性能优化

1. 动画延迟优化

问题: 如果每个列表项都使用动画延迟,长列表会导致累积延迟。

解决方案: 只为前10项使用动画延迟。

// ✅ LoadMoreList 组件内部已实现
function getAnimationDelay(index) {
  // 只为前10项使用动画延迟
  if (index < 10) {
    return { animationDelay: `${index * 20}ms` }
  }
  // 第10项以后立即显示(无延迟)
  return {}
}

效果:

  • 前10项:逐个进入,形成波浪效果
  • 第10项以后:立即显示,避免累积延迟

2. 触底加载防抖

问题: 用户滚动到底部时,useReachBottom 可能触发多次。

解决方案: 使用 300ms 防抖。

// ✅ LoadMoreList 组件内部已实现
let loadMoreTimer = null

useReachBottom(() => {
  // 如果正在加载或没有更多数据,不执行
  if (props.loadingMore || props.loading || !props.hasMore) {
    return
  }

  // 防抖:300ms 内只触发一次
  if (loadMoreTimer) {
    clearTimeout(loadMoreTimer)
  }

  loadMoreTimer = setTimeout(() => {
    const nextPage = props.page + 1
    emit('load-more', nextPage)
  }, 300)
})

3. 搜索防抖

问题: 用户输入时频繁触发搜索请求。

解决方案: 使用 500ms 防抖。

let searchTimer = null

const onSearchInput = (value) => {
  if (searchTimer) {
    clearTimeout(searchTimer)
  }

  searchTimer = setTimeout(() => {
    // 执行搜索
    fetchData({ keyword: value })
  }, 500)
}

4. 分类缓存

问题: 切换分类时重新加载数据,用户体验差。

解决方案: 使用 Map 缓存每个分类的数据。

const categoryPageCache = ref(new Map())
const categoryListCache = ref(new Map())

// 切换分类时先从缓存读取
const onTabClick = (tabId) => {
  const cached = categoryPageCache.value.get(tabId)
  if (cached) {
    // 从缓存读取,立即显示
    currentPage.value = cached.currentPage
    currentList.value = categoryListCache.value.get(tabId) || []
  } else {
    // 缓存为空,重新加载
    fetchData({ /* ... */ })
  }
}

5. 数据追加 vs 替换

问题: 不正确处理数据会导致重复或丢失数据。

解决方案:

  • 首次加载/刷新: 替换数据
  • 加载更多: 追加数据
if (isLoadMore) {
  // 追加数据
  currentList.value = [...currentList.value, ...listData]
} else {
  // 替换数据
  currentList.value = listData
}

更新日志

v2.0.0 (2026-02-08)

新增

  • ✅ 完整的组件 API 文档
  • ✅ 5 个页面的实际迁移案例
  • ✅ 5 种迁移模式详解
  • ✅ 最佳实践和常见问题
  • ✅ 性能优化建议

迁移完成

  • ✅ week-hot-material 页面
  • ✅ message 页面
  • ✅ product-center 页面
  • ✅ material-list 页面
  • ✅ search 页面

收益

  • ✅ 减少重复代码 ~700 行
  • ✅ 统一 5 个页面的分页加载逻辑
  • ✅ 统一动画效果和加载状态
  • ✅ 提升代码可维护性

相关文档


创建时间: 2026-02-08 维护者: Claude Code 版本: 2.0.0