下拉刷新和上拉加载看似只是两个事件,真正容易出错的是状态一致性:刷新与翻页同时发生怎么办、失败后页码是否回退、列表总数如何判断、旧请求晚回来能不能覆盖新数据。下面用 Vue 3 组合式函数把这些边界集中处理。
先定义接口契约
不要让 Hook 猜测后端字段。先把分页结果收敛为稳定结构:
export interface PageResult<T> {
list: T[]
total?: number
hasMore?: boolean
}
export interface PageRequest {
page: number
pageSize: number
}
hasMore 优先级最高;没有它时用累计条数和 total 判断;两者都没有时,才用“本页不足 pageSize”作为结束条件。
一个可复用的实现
import { computed, onScopeDispose, ref, type Ref } from 'vue'
type LoadMode = 'refresh' | 'append'
interface UsePullToRefreshOptions<T> {
pageSize?: number
immediate?: boolean
fetchPage: (params: PageRequest) => Promise<PageResult<T>>
}
export function usePullToRefresh<T>(
options: UsePullToRefreshOptions<T>
) {
const pageSize = options.pageSize ?? 20
const list = ref<T[]>([]) as Ref<T[]>
const currentPage = ref(0)
const total = ref<number>()
const error = ref<unknown>()
const refreshing = ref(false)
const loadingMore = ref(false)
const noMore = ref(false)
let latestRequestId = 0
async function loadPage(page: number, mode: LoadMode) {
const requestId = ++latestRequestId
error.value = undefined
if (mode === 'refresh') {
refreshing.value = true
loadingMore.value = false
} else {
loadingMore.value = true
}
try {
const result = await options.fetchPage({ page, pageSize })
// 刷新发生后,更早的翻页结果不能覆盖新列表。
if (requestId !== latestRequestId) return
const nextList =
mode === 'refresh'
? result.list
: [...list.value, ...result.list]
list.value = nextList
currentPage.value = page
total.value = result.total
if (typeof result.hasMore === 'boolean') {
noMore.value = !result.hasMore
} else if (typeof result.total === 'number') {
noMore.value = nextList.length >= result.total
} else {
noMore.value = result.list.length < pageSize
}
} catch (cause) {
if (requestId === latestRequestId) {
error.value = cause
}
throw cause
} finally {
if (requestId === latestRequestId) {
refreshing.value = false
loadingMore.value = false
}
}
}
async function refresh() {
try {
await loadPage(1, 'refresh')
return true
} catch {
return false
}
}
async function loadMore() {
if (
refreshing.value ||
loadingMore.value ||
noMore.value
) {
return
}
try {
await loadPage(currentPage.value + 1, 'append')
return true
} catch {
return false
}
}
function reset() {
latestRequestId += 1
list.value = []
currentPage.value = 0
total.value = undefined
error.value = undefined
noMore.value = false
refreshing.value = false
loadingMore.value = false
}
onScopeDispose(() => {
// 让已经离开作用域的请求结果失效。
latestRequestId += 1
})
if (options.immediate !== false) {
void refresh()
}
return {
list,
total,
error,
refreshing,
loadingMore,
noMore: computed(() => noMore.value),
refresh,
loadMore,
reset
}
}
这里没有在请求发出前递增页码。只有请求成功后才提交 currentPage,因此失败重试不会跳页。
“最新请求获胜”只解决状态覆盖问题,不会真正取消网络请求。如果请求库支持 AbortController 或平台自己的 abort API,可以在此基础上继续取消旧请求。
在 uni-app 页面中使用
<script setup lang="ts">
import { computed } from 'vue'
import { usePullToRefresh } from '@/hooks/usePullToRefresh'
import { getGoodsPage } from '@/request/goods'
const {
list,
error,
refreshing,
loadingMore,
noMore,
refresh,
loadMore
} = usePullToRefresh({
pageSize: 20,
fetchPage: async ({ page, pageSize }) => {
const response = await getGoodsPage({ page, pageSize })
return {
list: response.records,
total: response.total
}
}
})
const footerText = computed(() => {
if (loadingMore.value) return '加载中…'
if (noMore.value) return '没有更多了'
return '继续上拉加载'
})
</script>
<template>
<scroll-view
scroll-y
refresher-enabled
class="page-scroll"
:refresher-triggered="refreshing"
@refresherrefresh="refresh"
@scrolltolower="loadMore"
>
<view v-for="item in list" :key="item.id">
{{ item.name }}
</view>
<view v-if="error" class="error">
加载失败,请重试
</view>
<view class="footer">{{ footerText }}</view>
</scroll-view>
</template>
scroll-view 必须有可计算的高度,否则滚动到底事件可能永远不会触发。微信小程序和 H5 的滚动、回弹、触底距离也有差异,需要在目标端分别验证。
生产使用时还要决定
- 刷新失败时,是保留旧列表还是展示全屏错误。
- 列表项重复时,按主键覆盖还是保留服务端顺序。
- 筛选条件变化时,是调用
reset()后刷新,还是保留旧数据到新请求完成。 - 快速切换筛选是否需要真正取消请求。
- 空态、首次加载、增量加载和无更多数据是否使用不同视觉状态。
组合式函数应该统一状态机和边界,不应该替页面决定产品策略。