资讯详情

Vue2/Vue3+UniApp实现移动端无限滚动加载实战指南

📅 2026/10/10 10:50:52 | 华诺云谱 👁 阅读
Vue2/Vue3+UniApp实现移动端无限滚动加载实战指南
在移动端做信息流列表翻页加载是最常见的交互但传统分页那种点按钮跳转的体验放到手机上一用就露馅儿——用户划着划着突然被拦下来点个“下一页”页面哗啦跳一下阅读节奏全断了。无限滚动加载就是为这个场景设计的内容显示到页面底部附近时自动请求下一页数据并追加到列表后面整个过程用户手指都不用离开屏幕。我之前在两个项目里分别用 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 styleheight: 300px; :lower-threshold80 scrolltolowerloadMore view v-foritem in list :keyitem.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 classfeed-list view v-for(item, index) in list :keyitem.id classlist-item text classitem-title{{ item.title }}/text text classitem-desc{{ item.desc }}/text /view !-- 底部状态 -- view classload-status text v-ifloading加载中.../text text v-else-iffinished没有更多了/text text v-else-iferror clickretry加载失败点击重试/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 封装 useInfiniteScrollVue 3 的写法可以更优雅。如果把列表页的滚动逻辑抽成一个组合式函数不同页面、不同列表可以按需复用不用重复粘贴那几十行状态判断。先看这个函数的实现存到 composables/useInfiniteScroll.jsimport { 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 classfeed-list view v-foritem in list :keyitem.id classlist-item text classitem-title{{ item.title }}/text /view view classload-status text v-ifloading加载中.../text text v-else-iffinished没有更多了/text text v-else-iferror clickretry加载失败点击重试/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() }) }) /scriptVue 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 :srcitem.cover modeaspectFill lazy-load classitem-cover /lazy-load 会让图片在即将进入可视区域时才真正开始加载避免一次性加载几十张图片把带宽打满。实测中一个 20 条的列表如果不加懒加载首屏往往只能看到 4 张图却会同时发起 20 个图片请求白白浪费流量也让列表滚动卡顿。第二是图片尺寸匹配。modeaspectFill 在裁剪场景非常好用但要注意父容器得给定宽高否则图片会把布局撑得到处乱跳。我建议所有列表项的图片容器都提前设置固定宽高比比如宽 750rpx、高 400rpx这样页面结构在渲染时是稳定的不会等图片加载完才突然把列表往下推。用户感知到的就是“稳定的、可预测的滚动”这是长列表体验的地基。4. 常见问题排查与性能优化实录4.1 请求重复发出的三个源头与破解办法第一类触底事件连续触发。解决办法是事件回调里加节流结合 loading 标志双重拦截。第二类加载完成后没有置 finished用户继续滑动就会重复请求最后一页。很多接口返回 total 是固定的但后端做了过滤导致实际数据少于 totallist.length 永远不等于 totalfinished 永远不置 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 基本一致如果用 nvueweex 模式组件选择和事件写法都不同无法直接套用这套方案。我在一个项目里维护了两个平台微信小程序和 App 端。两个端都跑这套 Vue 3 UniApp 的无限滚动代码唯一需要额外适配的是下拉刷新的停止时机——小程序端用 uni.stopPullDownRefresh() 天然兼容App 端某些 webview 内核需要延迟调用才能避免视觉闪烁。这个延迟调用的 hack 不值得写进代码但在测试时如果发现下拉刷新动画卡住不动优先检查 stopPullDownRefresh 是否在正确的生命周期里被调到了。我在几个项目里把这套流程跑过很多遍之后最深刻的体会是无限滚动这个功能代码量其实不长真正决定上线体验的是那些看不见的状态判断和响应式细节。很多项目初期功能看着都能跑但数据量一大、网络一波动各种问题就全冒出来了。如果让我给一个最优先级的建议那就是先把状态机写好——loading、finished、error 三个变量一定得有而且要在正确的时机切换。在此基础上再去优化滚动体验、图片加载、虚拟列表一切才有意义。最后分享一个小技巧调试无限滚动的时候把接口响应时间人为调成 2000ms——正常加载、失败重试、下拉刷新、触底连续滑动这四类操作各走一遍很快就能发现大多数隐藏问题。这个办法帮我揪出过不少只在异常情况下才会出现的 bug你也值得试试。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑