做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 classlist-page view classlist-item v-foritem in pageData.list :keyitem.id text{{ item.title }}/text /view view classload-status text v-ifpageData.loading加载中.../text text v-else-ifpageData.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 UniAppVue 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 xxxVue 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的组合式函数清爽但风险控制要优先考虑。真正的迁移节点应该是在整个项目有升级计划的时候顺带完成把它作为一次整体技术升级的一部分来做单独为了一个功能去冒险升级框架代价可能超出收益。