LoadMoreList 组件完整指南
版本: 2.0.0 更新时间: 2026-02-08 维护者: Claude Code
📚 目录
组件概述
🎯 设计目标
LoadMoreList 是一个通用分页列表组件,旨在解决以下问题:
- 代码重复:多个页面都实现了相同的分页加载逻辑
- 维护困难:修改动效需要同时改多个文件
- 集成成本高:新页面需要复制粘贴大量代码
✨ 核心特性
- ✅ 自动分页加载:触底自动加载下一页(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)
场景: 只需展示列表,支持下拉刷新
<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 分别存储
- ✅ 动态列表:
currentListcomputed 根据 activeTab 动态返回 - ✅ 自动 tab 选择: 首次搜索时自动选择有结果的 tab
- ✅ 三种空状态: 初始状态、搜索无结果、有结果
迁移模式
模式 1: 简单列表迁移
适用场景: 只需展示列表,无复杂交互
迁移步骤:
-
定义状态
const currentList = ref([]) const currentPage = ref(0) const pageSize = 10 const hasMore = ref(true) const loading = ref(false) const loadingMore = ref(false) -
实现数据加载函数
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 } } } -
实现 handleLoadMore
const handleLoadMore = async (page) => { currentPage.value = page await fetchData({ page, limit: pageSize }, true) } -
使用组件
<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
关键点:
- 搜索防抖: 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: 带分类缓存的列表迁移
适用场景: 需要缓存每个分类的分页状态
关键点:
-
使用 Map 保存缓存
const categoryPageCache = ref(new Map()) const categoryListCache = ref(new Map()) -
保存状态到缓存
const cacheKey = activeTabId.value categoryPageCache.value.set(cacheKey, { currentPage: currentPage.value, hasMore: hasMore.value }) categoryListCache.value.set(cacheKey, [...currentList.value]) -
从缓存读取
const cached = categoryPageCache.value.get(tabId) if (cached) { currentPage.value = cached.currentPage hasMore.value = cached.hasMore currentList.value = categoryListCache.value.get(tabId) || [] }
模式 4: 带下拉刷新的列表迁移
适用场景: 需要支持下拉刷新
关键点:
-
启用下拉刷新
<LoadMoreList :enable-pull-down-refresh="true" @refresh="handleRefresh" > <!-- ... --> </LoadMoreList> -
实现 handleRefresh
const handleRefresh = async () => { currentPage.value = 0 // 或 1,根据 API 要求 hasMore.value = true await fetchData({ page: currentPage.value, limit: pageSize }) }
模式 5: 双列表系统迁移
适用场景: 需要支持多种类型的数据(产品和资料)
关键点:
-
双列表存储
const products = ref([]) const files = ref([]) -
动态列表 computed
const currentList = computed(() => { if (activeTab.value === 'product') { return products.value } else { return files.value } }) -
动态列表总数 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. 区分 loading 和 loadingMore
提升用户体验,显示不同的加载状态。
// 首次加载
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