在移动端做信息流列表,翻页加载是最常见的交互,但传统分页那种点按钮跳转的体验,放到手机上一用就露馅儿——用户划着划着突然被拦下来,点个“下一页”,页面哗啦跳一下,阅读节奏全断了。无限滚动加载就是为这个场景设计的:内容显示到页面底部附近时,自动请求下一页数据并追加到列表后面,整个过程用户手指都不用离开屏幕。我之前在两个项目里分别用 Vue 2 和 Vue 3 搭配 UniApp 实现过这套逻辑,踩了一圈坑之后发现,两种版本的写法差异比想象中大,核心坑位甚至能列成一张排查表。这篇文章就把两种版本的完整实现、爬坑过程、性能优化一次讲透。
1. 无限滚动加载的整体设计与方案选型
1.1 为什么移动端要做无限滚动,而不是传统翻页
传统翻页在PC端网页上很合理:一页十条,最后面放上一页下一页按钮,跳转逻辑清晰,用户也习惯了。但放到移动端,这个体验就非常割裂。移动端的屏幕本来就小,一个列表页面的可视区域大概只能展示四五条信息,如果每看四五条就要停一下去点按钮,用户手指得在屏幕上反复移动,阅读节奏也断了,留存数据很难做上去。
无限滚动解决的核心问题,是把“请求下一页”这个动作从用户显式操作变成系统隐式判断。用户只管往下滑,滑到底部附近,系统自动去拉数据、拼接列表。这里的核心设计目标是让列表加载过程“不可察觉”——用户感知不到等待,只有内容在持续出现。当然,无限滚动也不是银弹,如果用户想要回到之前看过的某一条,翻页反而更好定位;但对资讯流、商品流、动态流这类“越新越靠前”的内容,无限滚动几乎是移动端标配。
在 UniApp 项目里实现无限滚动,技术上有两条路线:页面滚动触底和容器滚动触底。前者依赖页面的滚动机制,用 onReachBottom 生命周期;后者是针对局部滚动区域,用 scroll-view 的触底事件。选哪条路,要看你列表是不是页面主体——页面主体优先走 onReachBottom,只有页面里嵌套了独立滚动容器时才考虑 scroll-view。这两条路的具体实现细节差异很大,后面会展开讲。
1.2 Vue 2 与 Vue 3 的响应式差异,怎么影响列表追加
这一节要重点说清楚,为什么同样的“无限滚动”,Vue 2 和 Vue 3 写起来不完全一样。根子在于两者的响应式系统实现不同。Vue 2 用的是 Object.defineProperty 对组件的 data 做拦截,Vue 3 用的是 Proxy。这个底层差异在列表追加这种高频操作里的影响非常直接。
Vue 2 对数组做了特殊处理——重写了数组的部分方法,比如 push、pop、splice、concat 这些操作是可以触发视图更新的。但如果直接给数组某个索引赋值,比如 list[3] = newItem,或者直接修改数组长度,页面是不会更新的,因为没有触发 setter。这在无限滚动里非常容易踩:有些同学想复用已有数据,直接改了某个对象字段,结果视图纹丝不动,排查半天才发现是响应式丢失。
Vue 3 的 Proxy 直接解决了索引赋值的问题,任何属性访问和修改都能被捕获。但 Proxy 也带来了一个新问题:如果直接把整个数组赋值给 ref,或者解构响应式对象,很容易丢失响应式连接。在新项目中,我推荐所有列表都用 ref 或 reactive 包一层,追加数据时统一用 value.push 或者重新赋值新的数组,不要零星地修改某一项。
版本差异还体现在组件的生命周期和组合式 API 的可用性上。Vue 2 走选项式 API,监视器写在 watch 里,数据放在 data() 中;Vue 3 可以写 setup + ref,也可以继续选选项式写法兼容老语法。UniApp 对 Vue 3 的支持已经相当成熟,如果是新项目,我建议直接用 Vue 3;但存量项目如果跑在 Vue 2 上,也不用焦虑,下面给的方案两种版本都能落地。
1.3 状态机的角色:loading、finished 与 error
无限滚动最怕什么?“开始加载但没告诉用户”“已经没有数据但还在拼命请求”“请求失败了但什么都没有提示”。这三种情况叠加的观感就是:卡、呆、没反馈。所以实现的第一步,不是写请求,而是先定义状态机。
我习惯把无限滚动的状态拆成四个:
| 状态 | 含义 | 典型表现 |
|---|---|---|
| idle | 空闲,可触发下一次加载 | 列表底部有内容,用户在下滑 |
| loading | 请求进行中 | 底部显示加载中占位或菊花 |
| finished | 所有数据加载完毕 | 底部显示“没有更多了” |
| error | 请求失败 | 底部显示“加载失败,点击重试” |
这四个状态在代码里一般用三个布尔变量表示:loading、finished、error。之所以要单独拎出来讲,是因为我见过很多项目直接把 loading 当 finished 用——请求完发现没有下一页了,不置任何标识,用户继续往下滑又触发一次请求,接口返回同样的数据,形成一个死循环。在业务里,必须先约定后端的返回数据结构:要么返回总数 total,前端判 list.length >= total 置 finished;要么返回 hasMore 布尔值,前端直接使用。
这个状态机在 Vue 2 和 Vue 3 中的承载方式不同,但逻辑骨架一致。后文给代码时,你会看到两个版本的实现都把状态判断放在了 loadMore 函数的开头,这是防重复请求的第一道闸门。
2. 核心细节拆解:触底判定的原理与参数配置
2.1 onReachBottom 的触发机制与 onReachBottomDistance 配置
先聊 UniApp 里最省事的页面级触底方案。在页面生命周期里直接写 onReachBottom 函数,当页面滚动到底部时就会触发。这里有个容易被忽略的配置项:pages.json 中对应页面的 style 里可以设置 onReachBottomDistance,默认值为 50,单位是 px,表示距离底部还有多少像素时就触发。
举个例子:页面高度 700px,内容总高度 1500px,用户往下滚到滚动位置还剩 30px 就到底时,onReachBottom 就会提前触发。这个“提前量”对体验很重要——理想情况下,用户还没真正触底,列表就在加载新内容了,这样用户手指不用停顿。我会把 onReachBottomDistance 设成 80 或 100,在 Wi-Fi 或 4G 环境里,加载完成之前用户刚好滑完剩下的距离,体验最顺滑。但如果接口响应很慢,提前量太大反而会出现“到底了还在转圈”的窘境,这个值需要自己调。
另外有个使用前提:onReachBottom 只对页面滚动生效。如果你的页面内容总高度没有超过视口高度,它是永远不触发的。很多初学者拿着一个测试列表(只有两三条数据)发现触底事件不触发,以为代码写错了,其实是因为页面根本没有滚动空间。在实现时,至少要有超过一屏的数据才能验证逻辑是否跑通。
页面级滚动还有一个好处:原生滚动的手感和性能比 scroll-view 好很多,尤其在 App 端和微信小程序端,页面滚动由原生引擎处理,滚动帧率稳定,列表项复杂时也不会明显卡顿。所以能用页面滚动的场景,我全用页面滚动。
2.2 scroll-view 容器方案:fixed 高度与 lower-threshold
页面上如果存在自定义的滚动容器——比如弹窗内的列表、双栏联动的右侧列表、聊天页的消息区域——就不能用页面滚动触底了,必须换成 scroll-view。这个组件的触底事件是 @scrolltolower,触发条件由 lower-threshold 属性控制,默认也是 50px,表示距离滚动容器底部还有 50px 时触发。
scroll-view 最关键的一个坑是高度问题:它的滚动区域必须被显式约束,否则组件自身不会滚动。具体来说,要么给 scroll-view 设置固定高度,要么用 CSS 通过 flex 布局拉伸到可用高度。我见过大量页面在 scroll-view 里堆了一堆内容,滚动就是不生效,最终排查发现是父容器或者 scroll-view 自身高度没有限制,内层高度撑开了外层,根本没有滚动溢出。
官方文档里有一个示例写法:
<scroll-view style="height: 300px;" :lower-threshold="80" @scrolltolower="loadMore"> <view v-for="item in list" :key="item.id">{{ item.title }}</view> </scroll-view>如果 scroll-view 所在页面同时也要下拉刷新,需要额外协调两层滚动关系。页面本身的下拉刷新在 scroll-view 区域外,会导致手势冲突,建议要么滚动区域铺满全屏,要么把下拉刷新改成容器内自定义刷新。这是 scroll-view 方案比页面滚动方案复杂的一个点。
2.3 节流与竞态:防止 loadMore 被高频触发
onReachBottom 和 @scrolltolower 都是事件回调,理论上用户滑一下鼠标滚轮或者手指快速滑动,可能在同一秒触发多次。如果把 loadMore 直接裸绑在事件上,就会出现同一页数据被请求 N 遍。光靠 loading 标志位并不能完全解决问题——因为第一次请求还没返回时 loading 是 true,事件又触发了,函数开头直接 return,这种写法没问题,但如果事件被触发时上一次请求刚好完成、loading 刚置 false,就会重复请求。
更稳妥的做法是在事件出口做节流。我常用的一个轻量实现:
// 简单节流:500ms 内最多执行一次 let lastTime = 0 function throttleLoad() { const now = Date.now() if (now - lastTime < 500) return lastTime = now loadMore() }事件绑定到 throttleLoad,内部再用 loading 状态兜底,双保险。这里有个小经验:节流时间不宜太长,300~500ms 是一个合理区间。太短起不到作用,太长会让用户明显感觉到触底后内容出现慢。竞态问题的根源是“请求返回顺序不确定”,即使加了节流也可能出现:第一次请求发出很慢,第二次触发的请求先返回。严格来说这需要一个请求序号或者 AbortController 来处理,但对普通列表场景,用“loading 标志 + 节流”已经能拦截 95% 的重复问题。
3. 完整实操:Vue 2 与 Vue 3 两种版本逐个实现
3.1 Vue 2 版本:基于选项式 API 的页面实现
先看完整代码。这个页面用的是 Vue 2 语法,核心逻辑是页码递增、列表拼接、状态控制。放在 UniApp 项目的 pages 目录下,直接就能跑。
<template> <view class="feed-list"> <view v-for="(item, index) in list" :key="item.id" class="list-item" > <text class="item-title">{{ item.title }}</text> <text class="item-desc">{{ item.desc }}</text> </view> <!-- 底部状态 --> <view class="load-status"> <text v-if="loading">加载中...</text> <text v-else-if="finished">没有更多了</text> <text v-else-if="error" @click="retry">加载失败,点击重试</text> </view> </view> </template> <script> export default { data() { return { list: [], page: 1, pageSize: 10, total: 0, loading: false, finished: false, error: false } }, // 页面滚到底部时触发 onReachBottom() { this.throttleLoad() }, // 下拉刷新(pages.json 开启 enablePullDownRefresh) onPullDownRefresh() { this.reset().finally(() => { uni.stopPullDownRefresh() }) }, methods: { // 模拟接口请求 fetchPage(page, pageSize) { return new Promise((resolve) => { setTimeout(() => { const start = (page - 1) * pageSize const list = Array.from({ length: pageSize }, (_, i) => ({ id: start + i + 1, title: `第${start + i + 1}条内容`, desc: `这是第${start + i + 1}条的描述信息` })) resolve({ list, total: 56 }) }, 600) }) }, async loadMore() { // 第一道闸门:防重复请求 if (this.loading || this.finished) return this.loading = true this.error = false try { const res = await this.fetchPage(this.page, this.pageSize) // 列表追加 this.list = this.list.concat(res.list) this.total = res.total this.page += 1 // 判断是否到底 if (this.list.length >= this.total) { this.finished = true } } catch (e) { this.error = true console.error('加载失败', e) } finally { this.loading = false } }, throttleLoad() { const now = Date.now() if (now - this.lastLoadTime < 500) return this.lastLoadTime = now this.loadMore() }, async reset() { // 重置到第一页 this.page = 1 this.list = [] this.total = 0 this.finished = false this.error = false await this.loadMore() }, retry() { this.loadMore() } } } </script>这里有几个细节值得展开。data 里没有 lastLoadTime,我是在 methods 里第一次访问 this.lastLoadTime 时自动添加的——这恰好是 Vue 2 响应式的一个小陷阱:这种动态添加的属性不是响应式的,但因为 throttleLoad 只是拿它做时间比较,不需要它驱动视图,所以没问题。如果你需要在模板里显示某个动态数据,那就必须在 data 里先声明好,否则视图不更新。
另一个细节是列表追加用了 concat 而不是 push。Vue 2 中 push 虽然能触发视图更新,但 concat 返回新数组,再整体赋值给 list,语义上更明确。更重要的原因是:后续如果我需要做“虚拟滚动”或“数据裁剪”,对数组整体替换会更容易扩展。在渲染层面,两者都会被 Vue 2 的响应式系统捕获。
3.2 Vue 3 版本:组合式 API 封装 useInfiniteScroll
Vue 3 的写法可以更优雅。如果把列表页的滚动逻辑抽成一个组合式函数,不同页面、不同列表可以按需复用,不用重复粘贴那几十行状态判断。先看这个函数的实现,存到 composables/useInfiniteScroll.js:
import { ref } from 'vue' export function useInfiniteScroll(fetcher, options = {}) { const list = ref([]) const page = ref(1) const pageSize = ref(options.pageSize || 10) const total = ref(0) const loading = ref(false) const finished = ref(false) const error = ref(false) const loadMore = async () => { if (loading.value || finished.value) return loading.value = true error.value = false try { const res = await fetcher(page.value, pageSize.value) // 追加数据 list.value.push(...res.list) total.value = res.total page.value += 1 if (list.value.length >= total.value) { finished.value = true } } catch (e) { error.value = true console.error('加载失败', e) } finally { loading.value = false } } const reset = async () => { page.value = 1 list.value = [] total.value = 0 finished.value = false error.value = false await loadMore() } const retry = () => { return loadMore() } return { list, page, pageSize, total, loading, finished, error, loadMore, reset, retry } }然后在页面组件里这样使用:
<template> <view class="feed-list"> <view v-for="item in list" :key="item.id" class="list-item" > <text class="item-title">{{ item.title }}</text> </view> <view class="load-status"> <text v-if="loading">加载中...</text> <text v-else-if="finished">没有更多了</text> <text v-else-if="error" @click="retry">加载失败,点击重试</text> </view> </view> </template> <script setup> import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app' import { useInfiniteScroll } from '@/composables/useInfiniteScroll' // 传入真正的请求函数 const { list, loading, finished, error, loadMore, reset, retry } = useInfiniteScroll( async (page, pageSize) => { // 这里替换成自己的 uni.request 封装 const res = await new Promise((resolve) => { setTimeout(() => { const start = (page - 1) * pageSize const items = Array.from({ length: pageSize }, (_, i) => ({ id: start + i + 1, title: `第${start + i + 1}条内容` })) resolve({ list: items, total: 42 }) }, 600) }) return res }, { pageSize: 10 } ) // 页面触底 onReachBottom(() => { loadMore() }) // 下拉刷新 onPullDownRefresh(() => { reset().finally(() => { uni.stopPullDownRefresh() }) }) </script>Vue 3 版本里,列表追加我用的是 list.value.push(...),因为 ref 包裹的数组在 push 时 Proxy 能捕获并触发更新。如果你习惯用 reactive 包裹整个状态对象,写法稍有不同,但核心逻辑一致。这里最需要注意的是对 list.value 的访问:在 setup 中不小心把 list 直接解构出来,会丢失响应式连接。
对比两个版本,Vue 2 的状态写在 data 里,Vue 3 的状态可以收敛到组合式函数里。这不是语法糖的差别,而是业务逻辑组织方式的升级——如果你的项目里有多个类似列表页,Vue 3 的封装能直接减少大量重复代码。我在一个项目中把 tab 切换+列表加载封装成一个组合式函数后,一个页面从 180 行缩到 60 行,改动需求时也只改函数内一处,维护成本明显下降。
3.3 下拉刷新与无限滚动协作的完整闭环
无限滚动一般不会单独存在,搭配的必然是下拉刷新。UniApp 里开启页面下拉刷新,需要在 pages.json 配置中把对应页面的 enablePullDownRefresh 设为 true,然后页面里监听 onPullDownRefresh 事件。在这个事件里要做的事情很明确:把 page 重置为 1,清空列表,重新加载首页数据。这个流程对应上面代码里的 reset()。
重置时有一个顺序问题:必须先清空列表,再重新请求;如果先请求再清空,旧列表数据会瞬间消失,然后又瞬间出现新列表,视觉上会闪一下。所以 reset 里我把 list 置空放在 loadMore 之前。同时,防止重置过程中用户又触底触发请求,reset 内部也可以加一个 loading 判断,或者利用一个 resetting 标志位把触底事件拦在外面。
在 App 端和微信小程序端,下拉刷新的交互是原生弹层,动画不容易自定义。这里有个很常见的体验问题:接口太快,下拉刷新的弹层一闪而过,用户根本来不及感知“刷新了”;接口太慢,弹层一直转,用户就会盯着看。我的做法是:reset 内至少保留 400ms 的动画时间,接口如果 150ms 就返回了,让 stopPullDownRefresh 稍微延后一点。这不是最优解,但在兼容性上最稳。也可以在 reset 的 finally 中直接 stop,如果产品对动画要求高,再引入额外状态控制。
3.4 图片列表的懒加载与渲染性能
无限滚动列表的数据一般不止一条文本,经常是图片+标题+价格这种卡片结构。图片是移动端性能的主要杀手。第一个要注意的是图片懒加载。uni-app 的 image 组件自带 lazy-load 属性,微信小程序端和 App 端都支持,加上就行:
<image :src="item.cover" mode="aspectFill" lazy-load class="item-cover" />lazy-load 会让图片在即将进入可视区域时才真正开始加载,避免一次性加载几十张图片把带宽打满。实测中,一个 20 条的列表如果不加懒加载,首屏往往只能看到 4 张图,却会同时发起 20 个图片请求,白白浪费流量,也让列表滚动卡顿。
第二是图片尺寸匹配。mode="aspectFill" 在裁剪场景非常好用,但要注意父容器得给定宽高,否则图片会把布局撑得到处乱跳。我建议所有列表项的图片容器都提前设置固定宽高比,比如宽 750rpx、高 400rpx,这样页面结构在渲染时是稳定的,不会等图片加载完才突然把列表往下推。用户感知到的就是“稳定的、可预测的滚动”,这是长列表体验的地基。
4. 常见问题排查与性能优化实录
4.1 请求重复发出的三个源头与破解办法
第一类:触底事件连续触发。解决办法是事件回调里加节流,结合 loading 标志双重拦截。第二类:加载完成后没有置 finished,用户继续滑动就会重复请求最后一页。很多接口返回 total 是固定的,但后端做了过滤导致实际数据少于 total,list.length 永远不等于 total,finished 永远不置 true,就会出现“疯狂请求但页面一直加载中”。破解方法是后端增加 hasMore 字段,或者前端在连续 2 次返回数据条数为 0 时自动置 finished。第三类:网络慢时用户疯狂下拉刷新,触发了多个 reset。这种情况要利用 AbortController 或请求序号解决,简单的处理是在 reset 开头增加 loading 判断。
4.2 列表闪烁、回跳与滚动位置错乱
无限滚动出现的“列表闪一下”通常有两个原因。一个是重置列表时为了置空,直接把 list = [],如果这个操作发生在用户视觉范围内,列表会瞬间消失再出现。优化方案是用 TopShelf 模式:不置空,而是保留旧数据直到新数据返回,再整体替换;或者给列表加 loading 遮罩,挡住重置过程。
另一个常见问题是滚动位置错乱。用户滑了很长的列表,下拉刷新后想回到原来的位置,结果被顶到页面顶部。半途而废的处理方式是完全不管;讲究一点的做法是利用 uni.pageScrollTo 配合滚动位置缓存。更实用的方案是,刷新成功后依然保持之前的总数据量——也就是不要清空列表,而是“先追加新数据在头部”,然后利用 scroll-view 或者页面滚动位置偏移来衔接。这个逻辑有点复杂,如果产品没有明确要求,我一般建议接受回到顶部,因为下拉刷新的语义本身通常就是“回到顶部重新浏览”。
4.3 长列表的内存与渲染优化
无限滚动看似是无限增加列表项,但浏览器和 WebView 的内存是有限的。列表渲染几千条之后,即使无限滚动逻辑没坏,页面也会明显变卡。最有效的方案是虚拟列表——只渲染可见区域附近的内容,其他项用占位高度代替。UniApp 生态里没有官方内置的虚拟列表组件,可以自己实现一个简化版:根据滚动位置和 item 高度计算可见起始索引和结束索引,用一个 padding 撑起总高度,只渲染中间部分。它的核心公式是:
startIndex = Math.floor(scrollTop / itemHeight) - bufferCount endIndex = Math.ceil((scrollTop + viewportHeight) / itemHeight) + bufferCount但这种方案对每个 item 高度一致的情况比较友好。如果你的列表项高度参差不齐(比如不同长度的文本),需要先渲染再测量,复杂度就上去了。我自己的实践原则是:超过 300 条才做虚拟列表,普通列表加 lazy-load 已经够用。在 App 端和 H5 端的实际表现中,300 条以内渲染不卡;超过 500 条建议考虑虚拟化,同时限制无限滚动的最大加载页数,比如超过 50 页就强制停止并给用户一个“已经到底了”的明确提示。
4.4 Vue 2 数组更新不触发视图的经典场景
Vue 2 的响应式数组坑,我在实际项目里遇到过好几次。最经典的是下面这个场景:列表数据返回后,我想给某一项追加一个字段。
// 错误写法:视图不更新 this.list[3].is_vip = true // 正确写法:替换整个项 this.$set(this.list[3], 'is_vip', true) // 或者整体替换 const newItem = { ...this.list[3], is_vip: true } this.$set(this.list, 3, newItem)另一个场景是直接用索引修改数组元素本身:
// 错误写法 this.list[0] = newData // 正确写法 this.$set(this.list, 0, newData) // 或者 this.list.splice(0, 1, newData)如果列表项是一个对象数组,修改对象内部的属性用的是 Vue 2 的深度响应式,只要这个对象在初始化时就存在,修改其属性是能更新的。只有“动态新增”的属性才会丢失响应式——这是 Object.defineProperty 拦截不到新增 key 导致的。理解了这个原理,排查方向也就清晰了:先确认属性是不是初始就存在,再确认修改方式是不是 Vue 2 支持的方法。换成 Vue 3 之后这些坑基本消失,但这不代表 Vue 3 没有自己的问题——直接在解构出来的变量上赋值,比 Vue 2 的响应式丢失更容易犯。
4.5 App 端与小程序端的差异化表现
同一个无限滚动代码,在不同端上表现不完全一致。小程序端对页面滚动的 onReachBottom 支持良好,但 scroll-view 的性能不如页面滚动,嵌套层数多了容易卡顿;H5 端对 scroll-view 的渲染依赖浏览器行为,桌面浏览器的滚动事件触发频率和手机差异不大,但如果列表内包含大量图片,建议在 PC 上测试时加节流;App 端如果用的是 webview 渲染,和 H5 基本一致,如果用 nvue(weex 模式),组件选择和事件写法都不同,无法直接套用这套方案。
我在一个项目里维护了两个平台:微信小程序和 App 端。两个端都跑这套 Vue 3 + UniApp 的无限滚动代码,唯一需要额外适配的是下拉刷新的停止时机——小程序端用 uni.stopPullDownRefresh() 天然兼容,App 端某些 webview 内核需要延迟调用才能避免视觉闪烁。这个延迟调用的 hack 不值得写进代码,但在测试时如果发现下拉刷新动画卡住不动,优先检查 stopPullDownRefresh 是否在正确的生命周期里被调到了。
我在几个项目里把这套流程跑过很多遍之后,最深刻的体会是:无限滚动这个功能,代码量其实不长,真正决定上线体验的,是那些看不见的状态判断和响应式细节。很多项目初期功能看着都能跑,但数据量一大、网络一波动,各种问题就全冒出来了。如果让我给一个最优先级的建议,那就是先把状态机写好——loading、finished、error 三个变量一定得有,而且要在正确的时机切换。在此基础上再去优化滚动体验、图片加载、虚拟列表,一切才有意义。
最后分享一个小技巧:调试无限滚动的时候,把接口响应时间人为调成 2000ms——正常加载、失败重试、下拉刷新、触底连续滑动这四类操作各走一遍,很快就能发现大多数隐藏问题。这个办法帮我揪出过不少只在异常情况下才会出现的 bug,你也值得试试。