做UniApp跨端开发,十有八九会撞到同一个需求:列表无限滚动加载。尤其在小程序端,用户的习惯已经被头部产品训练成"刷不到底"的信息流,如果做成传统的上一页/下一页按钮,体验上总觉得差了那么一口气。我自己在多个跨端项目里反复写过这玩意儿,从最早Vue 2版本一路迁移到Vue 3的组合式API,中间踩过的坑不少,今天把两套写法从头到尾拆开来讲清楚,包括分页参数怎么管、触底生命周期怎么挂、loading锁为什么要加、以及两个版本之间有哪些容易被忽略的细节差异。
这篇文章适合两类人看:一类是正在用Vue 2 + UniApp做项目、想把列表加载写得更稳的老手,另一类是要把老项目迁到Vue 3、或者新项目直接上Vue 3的开发者。我会把页面级方案和组件级方案都覆盖到,给出能直接使用的代码结构,再把生产环境里真正会遇到的坑一个一个指出来。全程不聊虚的,都是能落到工程里的实操。
1. 无限滚动加载在UniApp里的两条路:页面级和组件级
1.1 onReachBottom:开箱即用的页面级方案
UniApp跨的端很多,小程序、App、H5各自滚动机制不完全一致,但框架统一提供了一套页面级别的滚动触底生命周期:onReachBottom。它的原理很简单,页面滚动到接近底部时由框架触发一次回调,我们在这里加载下一页数据。对开发者来说,最大的好处是不用自己去监听scroll事件、不用关心滚动容器的位置计算,框架把跨端的差异屏蔽掉了。
要使用它,在页面配置里不需要额外开启什么开关,UniApp默认在微信小程序端和App端都会监听页面滚动。H5端也是在页面滚到底部附近触发,默认的触发距离是底部50px左右,这个值可以在pages.json的页面配置里用onReachBottomDistance字段调整。我在H5端调试时习惯把它调到80px左右,因为浏览器地址栏的显隐会改变视口高度,阈值太小容易造成"刚好卡在边界但就是触发不了"的尴尬情况。
这条路的注意点在于:它只适用于整页滚动。如果页面结构是"顶部自定义导航+中间内容区+底部tab栏",且中间内容区本身就是全屏滚动,那没问题。但如果页面布局是上下固定、中间一个独立滚动的区域,比如"顶部筛选栏固定、下面列表区域独立滚动",那onReachBottom就完全不会触发,因为你滚动的是内部容器,不是页面本身。这时候就得换方案。
1.2 scroll-view组件:局部滚动场景下的第一选择
scroll-view是UniApp提供的可滚动容器组件,最常见用法是竖向滚动列表。它对应的事件是@scrolltolower,意思是滚动到容器的底部时触发。相比页面级方案的唯一优势,就是它能让任意一个容器独立滚动并拥有自己的触底逻辑,页面其他部分保持固定。
但scroll-view有个关键前提,必须要让组件本身的高度撑起来。我看过太多人在H5端调试半天发现@scrolltolower就是不触发,最后发现是scroll-view的高度没有被约束。它默认高度是内容撑开的高度,如果内容没有超过可视区,它根本不会滚动,自然也就没有触底事件。正确做法是给滚动容器一个确定的高度,比如固定成calc(100vh - 顶部高度),或者在flex布局中让它参与占位,确保内容总高度大于容器高度。
另外要提一下,在小程序端scroll-view的滚动性能相比页面原生滚动略弱,数据量特别大时会有卡顿感。所以如果能用页面级方案,优先用页面级方案;只有布局确实需要局部滚动时,才用scroll-view。这是选型上的一个基本原则,别为了炫技或者看到别人这么写就乱套。
1.3 选型对照与决策建议
我整理一下两条路的适用场景,方便你在动手前直接对号入座:
| 对比项 | onReachBottom 页面级 | scroll-view 组件级 |
|---|---|---|
| 滚动主体 | 整个页面 | 指定容器 |
| 实现成本 | 极低,框架自动监听 | 需要设置容器高度 |
| H5兼容性 | 依赖页面滚动,正常触发 | 高度设置不对就不触发 |
| 局部滚动 | 不支持 | 支持 |
| 性能表现 | 小程序端为原生滚动,表现最好 | 数据量大时可能有性能损耗 |
| 推荐指数 | 常规列表场景首选 | 局部滚动和特殊布局时使用 |
我自己在项目里的习惯是,90%的列表页都用页面级方案,只有类似"商品详情页上滑查看评价列表"这种局部区域需要翻页的场景,才会用scroll-view。做决定之前先看一眼设计稿的布局,这个判断一分钟就能完成。
2. Vue 2 + UniApp:选项式API下的分页加载写法
2.1 状态字段怎么组织才不会乱
Vue 2的UniApp项目,页面结构通常是一个export default对象包含data、methods、onReachBottom等选项。写分页加载需要几个基础状态:列表数据、当前页码、每页条数、是否正在加载、是否没有更多了。这五个字段缺一个都会出问题。我把它们放在data里的一个pageData对象下统一管理,而不是散落成多个顶级字段,这样后续做数据重置时非常方便。
pages.json里下拉刷新的开关也需要留意。很多人以为下拉刷新不用配置就能直接触发,实际在小程序端要在pages.json对应页面的样式配置里加上"enablePullDownRefresh": true。App端和H5端行为略有差异,H5端默认支持下拉,但小程序端这是硬性开关,不配置就不生效。
2.2 一个完整页面的代码骨架
直接上一段我在Vue 2 + UniApp项目里打磨过很多次的代码结构,核心逻辑都在注释里说明白:
<template> <view class="list-page"> <view class="list-item" v-for="item in pageData.list" :key="item.id" > <text>{{ item.title }}</text> </view> <view class="load-status"> <text v-if="pageData.loading">加载中...</text> <text v-else-if="pageData.noMore">没有更多了</text> </view> </view> </template> <script> export default { data() { return { pageData: { list: [], page: 1, pageSize: 10, loading: false, noMore: false } } }, onReachBottom() { // 正在加载或者已经没数据了,直接返回,避免重复请求 if (this.pageData.loading || this.pageData.noMore) return this.loadData() }, onPullDownRefresh() { // 下拉刷新:重置分页后再请求第一页 this.pageData.page = 1 this.pageData.noMore = false this.pageData.list = [] this.loadData(true).finally(() => { uni.stopPullDownRefresh() }) }, methods: { async loadData(isRefresh = false) { this.pageData.loading = true try { const res = await this.fetchList({ page: this.pageData.page, pageSize: this.pageData.pageSize }) // 下拉刷新用新列表替换,触底加载用拼接 this.pageData.list = isRefresh ? res.list : this.pageData.list.concat(res.list) this.pageData.page += 1 // 已加载总数达到数据总量,说明没有更多了 if (this.pageData.list.length >= res.total) { this.pageData.noMore = true } } catch (error) { // 生产环境建议在这里补一个toast提示 console.error('加载失败', error) } finally { this.pageData.loading = false } }, fetchList(params) { // 模拟接口请求,实际工程里替换成uni.request return new Promise((resolve) => { setTimeout(() => { const start = (params.page - 1) * params.pageSize const total = 45 const list = [] for (let i = start; i < Math.min(start + params.pageSize, total); i++) { list.push({ id: i + 1, title: '第' + (i + 1) + '条内容' }) } resolve({ list, total }) }, 300) }) } } } </script>这段代码我用了一个比较关键的小设计:loadData接收一个isRefresh参数,用来区分是下拉刷新的加载还是触底翻页的加载。两种加载的数据合并策略不同,刷新要替换整个列表,触底要追加到现有列表后面。如果不做区分,下拉刷新时数据会不断重复追加,页面上就会出现大量重复项,排查起来很恼火。
2.3 触底加载和下拉刷新一起用时的细节
触底加载和下拉刷新放在一起时,逻辑上有几个容易漏掉的小点。第一个是下拉刷新结束之后,uni.stopPullDownRefresh()一定要在请求结束之后再调,否则浏览器端会出现下拉动画还没结束、刷新数据还没回来就收起动画的割裂感。用finally块保证无论请求成功还是失败,动画都能正常停止。
第二个是loading锁的作用范围。有些项目里用户快速下拉、又快速滚到触底,可能同时触发刷新和加载下一页,两个请求并发执行,列表数据就会出问题。loading置为true后,onReachBottom里的判断会提前拦截住,从机制上避免并发,这也是我在生产环境里最依赖的一层保护。
第三个是关于total的准确性问题。后端接口如果不太规范,total返回值可能忽大忽小,用list.length >= res.total作为无更多数据的判断在某些情况下不准确。更稳妥的方式是判断返回的list长度是否小于pageSize:最后一页数据不满一页,基本就意味着没有更多了。我倾向于两种判断同时用,以"返回条数小于pageSize"为主,total只做辅助参考。
3. Vue 3 + UniApp:组合式API的封装与迁移体验
3.1 生命周期钩子不再挂在选项上
Vue 3的UniApp项目,如果用的是<script setup>语法,页面生命周期不能像Vue 2那样直接写在组件选项里,而是需要从@dcloudio/uni-app包导入。这是从Vue 2迁移到Vue 3时第一个要注意的差异。刚开始接触时,很多人会习惯性地直接写setup() { onReachBottom(() => {}) },但如果漏了导入,运行时会直接报错说找不到这个方法。正确写法是这样的:
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'组合式API的好处是逻辑可以天然地按功能聚合。分页相关的状态、翻页函数、下拉刷新配合逻辑,这些原本在Vue 2的选项里散落在data、methods、生命周期钩子各处,到了Vue 3可以集中放在一个useLoadMore函数里,页面的脚本部分只负责调用和接入生命周期。这对维护性的提升是实实在在的,我迁移完几个页面之后回头再看Vue 2的代码,有种从一个散乱工具箱换成了分类收纳盒的感觉。
3.2 用useLoadMore抽离一个通用分页组合式函数
这里给出我在Vue 3项目里使用的组合式分页函数,可以放进项目的hooks目录下,所有列表页面共用:
// hooks/useLoadMore.js import { reactive } from 'vue' export function useLoadMore(fetcher) { const state = reactive({ list: [], page: 1, pageSize: 10, loading: false, noMore: false }) function reset() { state.page = 1 state.list = [] state.noMore = false } async function loadMore(isRefresh = false) { if (state.loading || state.noMore) return state.loading = true try { const res = await fetcher({ page: state.page, pageSize: state.pageSize }) state.list = isRefresh ? res.list : state.list.concat(res.list) state.page += 1 if (res.list.length < state.pageSize) { state.noMore = true } if (state.list.length >= res.total) { state.noMore = true } } catch (error) { console.error('load more error', error) } finally { state.loading = false } } return { state, loadMore, reset } }这里的fetcher可以理解成你自己定义的接口请求函数,接收分页参数、返回{ list, total }结构。函数内部把分页逻辑全部封装好了:管理页码、拼接列表、维护loading状态、判断是否没有更多。页面上接到这个组合式函数之后,剩下的就是非常轻量地接入生命周期:
// pages/list/list.vue <script setup> import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app' import { useLoadMore } from '@/hooks/useLoadMore' function fetchList(params) { return new Promise((resolve) => { setTimeout(() => { const start = (params.page - 1) * params.pageSize const total = 45 const list = [] for (let i = start; i < Math.min(start + params.pageSize, total); i++) { list.push({ id: i + 1, title: 'Vue3内容' + (i + 1) }) } resolve({ list, total }) }, 300) }) } const { state, loadMore, reset } = useLoadMore(fetchList) onReachBottom(() => { loadMore() }) onPullDownRefresh(async () => { reset() await loadMore(true) uni.stopPullDownRefresh() }) </script>模板部分和Vue 2版本结构差不多,只是访问数据时用state.list、state.loading、state.noMore,不像Vue 2里用this.pageData那一套。如果觉得每次在模板里写state.list不够简洁,可以用toRefs把state解构出来,模板里直接写list就行,但要注意解构之后数据仍然保持响应性,这点Vue 3比Vue 2的选项式API处理得自然得多。
3.3 组合式封装带来的工程收益
把这个分页逻辑做成公共组合式函数之后,最大的收益是列表页的代码量肉眼可见地减少。原来每个页面都要写五六个状态字段、完整的loadData方法、各种边界判断,现在一个useLoadMore调用加上两行生命周期绑定就搞定了。后续如果要改分页逻辑,比如加一个缓存机制或者请求取消,只需要在一处地方修改,所有引用它的页面自动生效,这比Vue 2时期每个页面复制一份代码再各自改的维护方式省力气得多。
有一点需要提醒:useLoadMore内部使用reactive管理状态,但reactive的赋值操作在某些UniApp编译目标上存在兼容性边界。如果遇到赋值不触发视图更新的诡异问题,可以把state改成多个ref或者用toRefs显式处理。实际项目中,我在普通页面里用reactive没有问题,但如果是封装到独立模块里并跨端复用,建议在写之前先在目标端跑一遍基础赋值。
4. Vue 2与Vue 3实现差异:从写代码的方式到思维方式的改变
4.1 语法层面对照表
两个版本的无限滚动加载实现,表面上解决的是同一个问题,但写法和心智模型有很强的代差。我做了个对比,可以对照着看:
| 对比维度 | Vue 2 + UniApp | Vue 3 + UniApp |
|---|---|---|
| 页面生命周期声明 | 直接写在组件选项对象里 | 需要从@dcloudio/uni-app导入 |
| 状态管理 | data()返回对象,通过this访问 | reactive/ref管理,变量直接持有 |
| 方法定义 | methods对象承载,通过this调用 | 普通函数定义在setup作用域内 |
| 逻辑复用 | 混入mixin,但属性命名易冲突 | 组合式函数,天然按功能聚合 |
| 类型支持 | 基本无智能提示 | 配合defineProps等有更好的类型体验 |
| 模板数据访问 | 直接写字段名,编辑器容易迷失 | 需要明确state前缀或先解构 |
差异最大的是组合式API带来的心智改变。Vue 2里我总是习惯把"状态"和"行为"分开放在data和methods里,像分页页码和加载函数被拆成两个区块,一旦逻辑复杂,需要在data、methods、生命周期钩子之间来回跳。Vue 3的useLoadMore把状态和行为放在同一个函数作用域里,对一个新加入项目的开发者来说,他看代码的速度和理解成本都降低了。
4.2 数据响应式的实际表现差异
在Vue 2里,我向列表追加数据时写的是this.pageData.list = this.pageData.list.concat(res.list),这行代码能正常工作,依赖的是Vue 2对数组整体赋值触发的响应式更新。如果你换成this.pageData.list.push(...res.list),其实也可以通过push触发数组变异方法侦测,但如果你用索引直接改某一项,比如this.pageData.list[0].title = 'xxx',Vue 2是侦测不到的,必须用this.$set。这是Vue 2响应式机制的经典边界问题,在开发小列表时不明显,一旦数据量大、嵌套深,踩中的概率很高。
Vue 3的reactive和ref基于Proxy实现,对数组索引赋值和新增属性都能够代理到,我在使用中明显感觉到这层枷锁被解开了。在分页加载场景里最常见的操作——"赋值一个新数组""在末尾追加若干项""修改某个字段的loading状态"——Vue 3全部能正确触发视图更新,不需要再纠结用$set还是$forceUpdate。实际体验下来,这部分代码写起来干净很多,排查问题的次数也少了很多。
4.3 迁移时最容易犯的错误清单
从Vue 2迁移到Vue 3时,有几个错误几乎是每个项目都会犯一遍的。第一个是把onReachBottom写在生命周期选项里但忘记导入,这个前面提过。第二个是在setup之外访问state变量,比如在事件处理函数里使用未定义的变量名,导致运行时报变量未定义。第三个是使用reactive之后又在模板里试图访问顶层this.state,但组合式API里根本没有this的概念,Vue 3的this在模板和逻辑代码中都不会自动指向组件实例。
这些错误通常会在控制台输出比较明显的报错信息,核心是理解Vue 3中"一切数据访问都是变量访问"这个原则。记得在迁移时把旧代码里的this全部剔除,把所有状态声明为ref或reactive,用变量名直接访问。如果项目比较大,建议分页面迁移,先迁移一个简单的列表页跑通流程,再逐步铺开,不要一次把几十个页面全部转掉,风险太大。
5. 生产环境里的高频坑位与优化手段
5.1 请求竞态和重复点击:锁与取消一个都不能少
无限滚动加载在生产环境里最容易翻车的就是请求竞态。用户在触底事件触发后快速下滑到另一个位置,如果上一次请求还没返回就又触发了一次加载,两个相同页码的请求可能同时执行,返回后数据重复拼接,列表出现重复项。我的解决思路是:核心用loading状态加锁,触底时先检查loading,为true就丢弃本次触发,这是最简单也最可靠的办法。代码里if (state.loading || state.noMore) return这行的存在意义就在这里,别嫌它多余。
另一个更隐蔽的竞态是下拉刷新与触底加载并发。用户快速下拉刷新,页面重置页码为1,紧接着又触底加载,这时候刷新请求和新一页请求同时发出,返回顺序不确定,可能导致最后页面数据混乱。稳妥做法是在下拉刷新开始时立即把loading置为true,让触底事件被锁住,等刷新完成之后才允许触底触发。
5.2 判断"没有更多"时别只依赖total
后端接口的质量参差不齐,有的total字段准,有的故意不分页返回一堆冗余数据,有的在搜索场景下total还是上个关键词的结果。我在项目里吃过这个亏:搜索条件变化后,接口确实返回了新的列表,但total仍然是上一个搜索词的旧值,导致页面提前显示"没有更多了",用户看到的列表缺了一截。
比较稳的做法是双条件判断:一是返回的list长度小于本次请求的pageSize,说明数据尾部已经到了;二是list.length达到或超过total,说明收集完毕。两个条件只要有一个成立就认为没有更多。还有最后一个兜底:空列表场景要单独处理,如果第一页请求返回的list就是空数组,应立即将noMore置为true,并显示一个自定义空态组件,别让用户面对一个空白页面发呆。
5.3 图片加载导致的滚动位置跳动
这里要说一个只有实际做列表加载才会遇到的现象:快速滚动列表时,视觉上会出现列表内容在触底后瞬间"跳一下",看起来像是滚动位置丢了。这个问题的根源通常不是滚动逻辑本身,而是图片资源的加载时序。列表底部新追加的项里如果包含大量图片,图片在滚动到可视区附近才开始加载,加载完成后撑高了内容高度,页面滚动位置就会产生一个偏移。
解决方案最常见的是给图片容器一个固定高度或宽高比占位。在分页列表的模板里,每个列表项的图片外层加一个设置了aspect-ratio或固定height的容器,图片加载前后高度不变,滚动偏移就会消失。这个优化对H5端尤其重要,小程序端因为有异步渲染机制,问题相对不明显,但固定占位始终是一个好的性能习惯。图片本身加上lazy-load属性可以减少首屏外图片的无效加载,配合无限滚动使用效果很好。
5.4 列表状态回显和重复渲染问题
我在处理列表页与详情页来回跳转时,遇到过这样一个需求:用户从列表页点进详情页,返回后希望列表仍然停留在之前浏览的位置。如果每次返回都重新初始化页面数据,这个体验就会被打断。UniApp里页面默认是会被销毁的,跨端场景下要保留状态,可以考虑用全局存储或者组件库的keep-alive机制,但更轻量的做法是通过Vue 3的onShow生命周期里做逻辑判断,只在首次进入时请求第一页数据,后续返回时直接复用之前的数据和滚动位置,并手动设置noMore。
这类细节在无限滚动加载里很容易被忽略,但实际体验差异很大。我的经验是,把列表状态设计成"可恢复"的,而不是每次都清零重建,会减少很多用户流失。
5.5 给新项目的一个配置和经验组合
最后给新项目开个实用配置清单。如果你的项目使用Vue 3 + UniApp,在pages.json里给列表页把enablePullDownRefresh打开,onReachBottomDistance设为60到80;在列表页面上把useLoadMore封装成公共hook;模板中每个列表项必须设置key且用稳定性足够的值,不要用数组索引;列表项图片加固定宽高比占位;接口返回结构建议统一成{ code, data: { list, total } },方便fetcher统一处理。
这些配置和经验组合起来,能覆盖我上面提到的大部分坑。我多次在团队里把这些内容沉淀成项目开发文档,新来的同事按照清单去搭一个列表页,基本不会出大的问题。
如果你已经在用Vue 2版本跑老项目,不建议为了无限滚动这个单一功能去整体升级Vue 3。分页加载在Vue 2下用mixin方案也可以达到不错的复用效果,虽然不如Vue 3的组合式函数清爽,但风险控制要优先考虑。真正的迁移节点应该是在整个项目有升级计划的时候顺带完成,把它作为一次整体技术升级的一部分来做,单独为了一个功能去冒险升级框架,代价可能超出收益。