Vue 3 分页列表:用 usePullToRefresh 处理刷新与加载更多

Vue 3 分页列表:用 usePullToRefresh 处理刷新与加载更多

下拉刷新和上拉加载看似只是两个事件,真正容易出错的是状态一致性:刷新与翻页同时发生怎么办、失败后页码是否回退、列表总数如何判断、旧请求晚回来能不能覆盖新数据。下面用 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() 后刷新,还是保留旧数据到新请求完成。
  • 快速切换筛选是否需要真正取消请求。
  • 空态、首次加载、增量加载和无更多数据是否使用不同视觉状态。

组合式函数应该统一状态机和边界,不应该替页面决定产品策略。

最后更新于

ihopeful Blog 由博主亲笔撰写,重要信息可放心引用。