☰
uniapp列表分页请求封装:usePageList自定义Hook实战
2026/10/14 8:02:25 网站建设 项目流程

做uniapp这几年,我数了下,光列表页就写过不下二十个:商品列表、订单列表、评论列表、消息列表……每个页面看着长得差不多,代码却各自为分页、加载、请求、刷新折腾一遍。一开始我靠复制粘贴,后来改到怀疑人生,索性抽了一个通用hook,把列表请求这条流水线彻底封装起来。这篇记录就聊聊这个usePageList的思路、完整代码,以及我实际用下来踩过的坑。如果你也是把请求逻辑堆在每个.vue文件里反复改的朋友,这篇对你应该有用。

1. 为什么非要把列表请求拆成hook

1.1 先看看传统写法有多痛

最初写uniapp列表页时,我的代码几乎长一个样子:data里放list、page、pageSize、total、loading,onLoad里调一次接口,onReachBottom里判断是否还能加载下一页,onPullDownRefresh里重置分页重新请求。单个页面这么写还好,一旦项目里同时有商品、订单、优惠券、公告好几个列表,问题就来了。

最难受的还不是重复代码,而是"每个页面的分页逻辑都略有不同"。有的接口返回data.list,有的返回data.records;有的total在data.total,有的在data.totalCount;有的需要传pageNum,有的需要传current。于是每个页面对接口返回值的处理方式都不一样,经常出现"A页面改好了,B页面忘了同步"的情况。

再加上防重复请求、失败重试、token过期跳转、网络错误提示,这些东西如果只靠复制粘贴去维护,迟早要出事。我印象最深的一次:一个订单列表页在onReachBottom里忘了加loading判断,用户快速上拉时同一个分页请求连发三次,后端直接给我返回了一堆重复数据。

1.2 hook方案到底解决什么问题

把列表请求抽成hook,核心不是"少写代码",而是把分页列表的状态流转统一收口。页面不再关心"当前是第几页、加载完了没有、请求是否在途",它只做两件事:传参数给hook,然后拿到list、loading、finished这些状态去渲染。

用Vue3的组合式API写这个特别顺,因为逻辑可以暴露成函数,状态天然是响应式。页面相当于只留下了一个"壳",所有跟请求相关的逻辑都在hook内部管理。这套方案的服务对象其实也明确:使用Vue3版本的uniapp项目,并且页面中存在大量"列表+分页+刷新"形态的功能。

1.3 这套hook适用哪些场景

不是所有页面的列表都适合套这个hook,但大多数都行。最典型的是三类:

一是标准的瀑布式分页列表,下拉刷新、触底加载,这种直接用就够了;二是带搜索和筛选的列表,比如关键词搜索、状态切换、分类切换,搜索条件变化时通过setParams重置列表;三是Tab多列表页面,一个页面里有好几个Tab,每个Tab一个独立列表,如果不用hook,你会在data里写一大堆list1、list2,hooks可以帮你把这一堆状态彻底打散。

如果页面列表结构比较特殊,比如需要动态展示字段、行内编辑、批量操作,hook负责的部分只是数据请求,不会干扰页面的交互逻辑。

2. 接口设计:先把形状定下来

2.1 对外到底暴露什么

写hook之前,我习惯先把"输入输出"定清楚,不然写着写着就会失控。

对外入参我最终只保留了五个:

  • requestFn:必填,真正的接口请求函数,接收一个参数对象,返回Promise。
  • defaultParams:默认请求参数,比如固定携带的分页大小、排序字段。
  • pageSize:每页条数,默认10。
  • immediate:是否立即加载第一页,默认true。
  • resParser:可选,接口返回数据解析函数。处理后端字段不统一的问题。

对外返回的状态和方法,我认为这几个是必须的:

返回项类型说明
listref<Array>列表数据,页面渲染时直接使用
loadingref<Boolean>第一页加载中,通常用于首次进入页面的loading状态
loadingMoreref<Boolean>加载更多中,用于触底时的加载状态提示
finishedref<Boolean>是否已加载完,底部显示"没有更多了"就靠它
errorref<Boolean>请求是否出错,出错时页面可以显示重试按钮
refreshFunction重置并加载第一页,供下拉刷新调用
loadMoreFunction加载下一页,供触底调用
setParamsFunction修改请求参数并自动刷新列表,供搜索筛选调用

设计接口时我给自己定了一条原则:hook内部可以自动处理的,一律不暴露给页面。比如当前页码、分页状态、是否已结束,这些如果暴露出去,页面难免会手贱去改,改了就出问题。

2.2 参数设计里我纠结过的几个点

首先是requestFn到底怎么传。我试过在hook内部直接依赖api模块,但后来放弃了,因为项目里接口路径和返回结构几乎每个页面都不一样,hook不能绑定具体接口。让页面把请求函数传进来,hook只负责组织参数、发起请求、接收结果,这样通用性最强。

其次是分页参数名的问题。page和pageSize是项目里最常见的叫法,但确实有后端用pageNum、current。我一开始想做成配置项,后来发现这样会让hook参数越来越臃肿。我的妥协方案是:hook内部统一用page和pageSize发请求,如果接口不接受这两个名字,在requestFn里做一次映射即可。毕竟requestFn本身就是页面定义的方法,转一下参数不费事。

然后是finished的判断。光看list.length >= total是靠谱的,但有些后端接口压根不返回total。所以我做了一层兜底逻辑:如果total大于0,就直接比较总数;如果total不可靠甚至没有,就用"本次返回的数据不足一页"来判断是否到底。

2.3 和uniapp页面生命周期的配合方式

这个hook天然依赖"页面栈",所以跟uniapp的生命周期配合必须想清楚。

refresh和loadMore不能只在用户手动触发时生效,还需要能够和页面的onPullDownRefresh、onReachBottom对接。我要求refresh和loadMore都必须返回Promise,这样页面在调用uni.stopPullDownRefresh()时不用自己等接口时间。另外,组件卸载时如果请求还没回来,应该阻止后续更新,这在Vue3里用onUnmounted就能处理。

有一点容易忽略:immediate为true时,如果页面一进来就调refresh(),那么onLoad里就不要再手动调loadMore了,否则会重复请求。我见过不少页面把这些责任重叠在一起,导致首屏请求发了两遍。hook的设计是:如果你开了immediate,就让它负责首次加载,页面的onLoad只做参数准备。

3. 完整实现:从请求封装到usePageList

3.1 先把uni.request二次封装做好

usePageList底层依赖一个可用的请求封装。直接用uni.request不是不行,但你会遇到很多重复劳动:token加在哪里、业务错误码怎么判断、401跳转怎么统一处理。我建议先做一个简单的request.js。

基础封装不复杂,重点是把"成功"和"失败"的判定标准统一起来。我习惯用HTTP状态码判断请求是否完成,再用业务code判断接口是否成功:

// utils/request.js const BASE_URL = 'https://api.example.com' let authToken = '' export function setToken(token) { authToken = token } export function request(options = {}) { return new Promise((resolve, reject) => { uni.request({ url: (options.baseUrl || BASE_URL) + options.url, data: options.data || {}, method: options.method || 'GET', timeout: options.timeout || 15000, header: { 'Content-Type': 'application/json', 'Authorization': authToken ? `Bearer ${authToken}` : '', ...(options.header || {}) }, success: (res) => { // 先看HTTP状态码,2xx才算链路通 if (res.statusCode < 200 || res.statusCode >= 300) { if (res.statusCode === 401) { // token失效,跳登录 uni.reLaunch({ url: '/pages/login/login' }) } reject(res) return } const body = res.data // 再看业务code,0或者200按成功处理 if (body && (body.code === 0 || body.code === 200)) { resolve(body) } else { uni.showToast({ title: (body && body.msg) || '请求失败', icon: 'none' }) reject(body) } }, fail: (err) => { uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' }) reject(err) } }) }) }

这里有一个细节:我在request里同时处理了HTTP状态码和业务状态码。有的团队只处理statusCode,结果业务返回失败但HTTP是200,页面拿到code: 500的数据照样渲染,等用户反馈"明明报错了列表却是空的"才后知后觉。

3.2 usePageList的核心代码

接下来是主角。我用的是Vue3的ref、computed、onUnmounted。为了让代码好读,我给关键逻辑写了注释,但注释不是摆设,每一行都有对应的场景。

// composables/usePageList.js import { ref, computed, onUnmounted } from 'vue' export function usePageList(options = {}) { const { requestFn, // 页面传入的真实请求函数,必须返回Promise defaultParams = {}, // 默认请求参数 pageSize = 10, // 每页条数 immediate = true, // 是否立即加载第一页 resParser = null // 自定义返回结构解析 } = options // 状态 const list = ref([]) const total = ref(0) const loading = ref(false) // 首次/刷新加载中 const loadingMore = ref(false) // 触底加载中 const error = ref(false) // 是否出错 // 内部变量 const page = ref(1) const params = ref({ ...defaultParams }) let requestSeq = 0 // 递增序号,用于处理请求竞态 let active = true // 组件是否存活 // 是否已全部加载完 const finished = computed(() => { const currentCount = list.value.length if (total.value > 0) { return currentCount >= total.value } // 后端没返回total时,以最后一页不足pageSize为结束依据 return currentCount > 0 && currentCount % pageSize > 0 }) async function requestList(isLoadMore = false) { // 防重复请求 if (loading.value || loadingMore.value) return if (isLoadMore && finished.value) return isLoadMore ? (loadingMore.value = true) : (loading.value = true) error.value = false const seq = ++requestSeq try { const requestParams = { page: page.value, pageSize, ...params.value } const res = await requestFn(requestParams) // 组件卸载或已有新请求,丢弃旧结果 if (!active || seq !== requestSeq) return // 解析返回数据 const data = parseResponse(res) const newList = data.list || [] const newTotal = data.total ?? 0 if (isLoadMore) { list.value = list.value.concat(newList) } else { list.value = newList } total.value = newTotal page.value += 1 } catch (e) { if (active) error.value = true throw e } finally { if (active && seq === requestSeq) { loading.value = false loadingMore.value = false } } } // 返回数据解析,优先走自定义函数 function parseResponse(res) { if (resParser) return resParser(res) // 默认适配 { data: { list, total } } 或 { list, total } 两种结构 const payload = res && res.data ? res.data : res return { list: payload.list || payload.records || payload.rows || [], total: payload.total ?? payload.totalCount ?? 0 } } // 刷新:重置页码,拉取第一页 function refresh() { page.value = 1 total.value = 0 return requestList(false) } // 加载更多:页码递增,追加列表 function loadMore() { return requestList(true) } // 修改参数并按新条件刷新 function setParams(newParams = {}) { params.value = { ...params.value, ...newParams } return refresh() } // 手动重置全部状态,一般用不到 function reset() { page.value = 1 list.value = [] total.value = 0 loading.value = false loadingMore.value = false error.value = false } onUnmounted(() => { active = false }) if (immediate) { refresh() } return { list, total, loading, loadingMore, finished, error, params, refresh, loadMore, setParams, reset } }

这套代码看起来不长,但里面全是实战里磨出来的细节。

3.3 容易被冲掉的三个关键细节

第一个是requestSeq竞态锁。直接比较seq,就能保证"上一次请求比下一次晚返回"时不会覆盖新数据。这个场景在快速切换搜索关键词时经常出现:用户输入"A",请求还没回来,又输入"AB"发起新请求,结果A的响应先到还是后到完全不可控。没有序号锁,列表里可能默默地混入上一次搜索的结果。

第二个是loading锁。loading和loadingMore同时为true时必须阻止下一次请求。这个不用多解释,触底加载时如果用户又触发下拉刷新,两个请求叠着跑,分页就乱了。我用的是锁定标志,比节流简单也直接。

第三个是参数合并用展开运算符。setParams里我用{ ...params.value, ...newParams }而不是直接修改params.value的老字段,这样能确保每次传参都是全新的一份。以前我用过Object.assign,结果在多页面复用时出现了引用共享的诡异问题,排查了半天才发现是两个页面共用了一个params对象。

4. 页面落地:两种写法都要会

4.1 用setup()返回的传统写法

如果项目里还在用Options API的写法,可以在setup()里使用hook,再把状态和方法return出来:

// pages/list/list.vue <script> import { usePageList } from '@/composables/usePageList' import { fetchListApi } from '@/api/list' export default { setup() { const { list, loading, finished, refresh, loadMore, setParams } = usePageList({ requestFn: fetchListApi, defaultParams: { categoryId: '' }, pageSize: 10 }) return { list, loading, finished, refresh, loadMore, setParams } }, onPullDownRefresh() { // 刷新完成后必须手动停止下拉动画 this.refresh().finally(() => uni.stopPullDownRefresh()) }, onReachBottom() { this.loadMore() }, methods: { onSearch(keyword) { this.setParams({ keyword }) }, onChangeCategory(id) { this.setParams({ categoryId: id }) } } } </script>

这种写法兼容性最好,页面生命周期写在配置项里,看起来和以前一样。需要注意的是setup里return出去的方法要在methods里通过this调用,别在模板事件里直接写refresh()。

4.2 用

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询