无限滚动加载在 UniApp 里其实是个高频需求:下拉加载更多、列表分页、瀑布流、消息记录、订单列表,几乎每个带列表的 App 和小程序都逃不过这关。而且这需求看着简单——滚动到底就加载下一页呗——真写完问题一个接一个,什么滚动不触发、重复 loading、列表抖动、内存疯涨、页面卡死,新手写崩了,老手写烦了。
这篇文章我就从实际项目出发,把 Vue 2 + UniApp 和 Vue 3 + UniApp 两套无限滚动加载的实现思路、核心代码、踩坑排查一次讲透。你用 Vue 2 还是 Vue 3,都能直接抄作业;已经在写但总有小毛病的,看完也能对号入座找到问题。我先说清楚:这篇不是讲第三方插件怎么配置,而是从零手写整个滚动加载体系——因为只有你自己搞清楚原理,才不会被各种诡异 bug 折腾得怀疑人生。
无限滚动加载的整体设计与方案选型
无限滚动加载在移动端绝不是简单监听一个 scroll 事件然后发请求。它背后涉及分页机制、滚动容器的选择、触发阈值的设定、数据追加策略、防抖节流、加载态和空态管理,以及列表长到一定程度后的性能压制。这一整套设计要是不在写代码之前想清楚,后面必然堆一堆临时补丁。
为什么用 UniApp 而不直接写原生小程序或 Web
UniApp 的核心价值在于一套代码编译到多端:微信小程序、QQ 小程序、支付宝小程序、App(到原生端)、H5,甚至部分平台快应用。如果你是从零起步做项目,UniApp 是性价比最高的选择——不用每个端单独维护一套列表逻辑,滚动加载这种相对通用的功能,理论上在 UniApp 层写一次即可覆盖全部平台。
但实际开发里你会发现:不同端的滚动事件来源不一样,比如小程序端页面滚动用的是onReachBottom这类页面生命周期,App 端和 H5 端用的是 DOM 的scroll事件,两者混在一起就会出岔子。所以我做滚动加载前一定会先想清楚:当前项目主要跑在什么端,滚动事件的来源是页面级还是组件级,这对后续选型影响很大。
无限滚动加载的整体架构思路
我不太建议把无限滚动加载写成一个“神圣不可侵犯”的组件,动辄封装出几百行代码的复杂抽象。真实项目里我更喜欢搭一套“轻量 + 可扩展”的结构,由以下几块组成:
- 数据仓库层:负责分页数据状态、页码管理、是否还有下一页、加载状态
- 事件监听层:负责监听滚动容器、判定是否触发加载、节流防抖
- UI 反馈层:负责 loading 加载中、下拉状态、底部文案、没有更多数据时的占位
- 分页适配层:负责把不同端的滚动触发方式统一成一个内部接口——比如通过
guideToNextPage()方法统一触发
这套结构的好处是:不管底层是页面滚动还是组件滚动,数据层和 UI 层完全不动,只需替换事件监听层即可。下面我分别用 Vue 2 和 Vue 3 来实现这一套。
开发环境说明
本文实操环境如下:
- HBuilderX 3.x 及以上
- Vue 2 项目:Vue 2.6.x + UniApp(默认模板)
- Vue 3 项目:Vue 3.x + UniApp(Vite 模板或 HBuilderX 内置 Vue 3 模板)
- 测试平台:微信小程序 + H5 + App 平台
注意:Vue 2 对应的是 UniApp 默认的 Webpack 构建方案,Vue 3 对应的是 Vite 构建方案。两者的监听滚动方式、文件结构有一些差异,但核心逻辑可以互相借鉴,我在下面的实现部分会分别给出完整代码。
核心细节解析与滚动加载原理
要把无限滚动写好,先要把原理吃透。滚动加载本质上是四个问题的组合:
- 何时触发加载新数据?
- 如何防止重复触发?
- 新数据如何追加到列表末尾?
- 列表无限变长后,页面如何不崩溃?
这四个问题就是我下面展开的核心框架。
触发时机的判定:滚动位置与阈值计算
无论是页面级滚动还是组件级滚动,触发逻辑都基于同一个公式:当滚动容器的滚动位置 + 容器可视高度 ≥ 内容总高度 - 触发阈值时,开始加载下一页。
换算成代码维度:
- 滚动位置:
scrollTop(页面或组件滚动的垂直偏移) - 可视高度:
windowHeight(视口可见区域高度,在组件里是容器高度) - 内容总高度:
scrollHeight(内容总高度) - 触发阈值:比如 80px 或 200px,表示离底部还有多远就开始触发
举个例子:一个组件容器可视高度是 600px,内容总高度是 2500px,当前滚动了 1920px,此时滚动位置 + 视口高度 = 1920 + 600 = 2520px,而总高度是 2500px,离底部还差 0 但超过了阈值,那么就该触发。
但实际开发里,不建议直接等滚动到底部才触发,那样用户会有明显的“卡一下”感觉。更合理的是提前一个阈值触发,比如离底部还剩 100px 甚至 300px 时就预加载下一页,用户滚动过程中几乎无缝续接,体验好很多。
防止重复触发的核心:请求锁与页码控制
重复触发是无限滚动最常见的 bug,症状是页面快速滚到底后连续发起多个同页码请求,或者数据错乱。解决思路是“锁机制”:
- 当上一页请求未完成时,不触发新加载
- 当没有更多数据时,不再触发加载
- 当一个加载动作正在执行时,重复的触发直接忽略
我习惯用一个状态变量loading来控制,请求发出去置 true,请求结束无论成功失败都置 false。触发条件是loading === false && hasMore === true。这个锁法简单粗暴但极其有效,能堵住绝大多数重复触发漏洞。
数据追加策略:不替换、只追加
无限滚动加载的数据累积方式是追加而非替换。也就是说,下一次请求返回的数据要concat到原有数组末尾,而不是重新赋值。某些新手会在回调里写list = res.data.list,这相当于每次加载都清空重置,滚动加载就永远停在第一页了。
正确写法是在 Vue 2 里用this.list.push(...res.data.list),在 Vue 3 里用list.value.push(...res.data.list)。这一点看着简单,但实际很多人栽在这里,尤其是从“接口返回全量数据”的模式转过来时容易写错。
性能保障:虚拟列表、节流与分页容量
列表无限加载以后,DOM 节点会越来越多,低端手机上必卡。所以有两个思路要并行:
- 分页容量控制:每页不要太大,我常用 10 到 20 条。如果接口允许,用 15 条是比较平衡的选择——少于 10 条容易频繁触发加载,多于 20 条在低端机上渲染压力大。
- 滚动事件节流:
scroll事件触发频率极高,每秒可能几十次,如果每次触发都去计算判断和加载,性能损耗巨大。用节流函数控制在 100ms 左右执行一次判断即可。
对于超长列表(比如 500 条以上),单纯靠节流还是不够。这时我会考虑分页滚动渲染或者虚拟列表,但这已经超出无限滚动加载的基础范畴。实际项目中,如果单列表数据真的到达几千条,我更倾向于用“滚动分页 + 虚拟列表”组合方案,后面有机会单独展开讲。
加载状态与底部反馈
无限滚动加载还需要处理几种状态:
- 首次加载(loading 态):页面或列表初始化时展示 loading
- 加载中(表示正在请求下一页):底部出现加载动画或文案
- 没有更多数据(懒加载结束):底部显示“没有更多了”或隐藏加载控件
- 加载失败(网络异常/接口异常):底部提示失败,并可点击重试
这三种状态如果不在 UI 层做明确区分,用户会困惑:到底还能不能继续滚动?网络失败了我是不是应该刷新?下拉还能用吗?
我在真实项目里会做一个bottomStatus字段,取值是loading/empty/nomore/error。模板里根据这个字段展示不同底部文案和控件,这是最直观也最不容易漏的处理方式。
实操过程与核心代码实现
下面进入真正动手环节。我分别给出 Vue 2 + UniApp 和 Vue 3 + UniApp 两套完整实现,并解释每一步在做什么,以及两套实现的差异点在哪里。
Vue 2 + UniApp 无限滚动加载完整实现
Vue 2 的 UniApp 项目里,我建议把“页面级滚动加载”和“组件级滚动加载”分开处理。微信小程序端页面滚动用onReachBottom,H5 端用onPageScroll,App 端同样用onPageScroll。 UniApp 的页面生命周期里已经统一了滚动监听,所以可以优先用页面级方案。
页面级滚动加载(含全端适配)
我在 Vue 2 页面里的核心代码如下:
<template> <view class="list-container"> <view class="list-item" v-for="(item, index) in list" :key="item.id"> <text>{{ item.title }}</text> </view> <!-- 底部状态区域 --> <view class="list-bottom"> <view v-if="bottomStatus === 'loading'"> <text>加载中...</text> </view> <view v-else-if="bottomStatus === 'nomore'"> <text>没有更多了</text> </view> <view v-else-if="bottomStatus === 'error'" @click="retryLoad"> <text>加载失败,点击重试</text> </view> <view v-else-if="bottomStatus === 'empty'"> <text>暂无数据</text> </view> </view> </view> </template> <script> export default { data() { return { list: [], page: 1, pageSize: 15, loading: false, hasMore: true, bottomStatus: 'loading' } }, onLoad() { this.fetchList(); }, // 页面滚动到底部时自动触发(微信小程序、App、H5均可用) onReachBottom() { this.loadNextPage(); }, methods: { // 请求列表数据 async fetchList() { if (this.loading) return; this.loading = true; this.bottomStatus = 'loading'; try { // 模拟接口请求,实际项目用 uni.request 或自己的请求封装 const res = await this.fetchApi(this.page, this.pageSize); const newList = res.data.list || []; const totalPages = Math.ceil(res.data.total / this.pageSize); // 追加数据,注意是 push 而不是直接赋值 this.list = this.list.concat(newList); // 判断是否还有下一页 this.hasMore = this.page < totalPages; if (!this.hasMore) { this.bottomStatus = 'nomore'; } else if (this.list.length === 0) { this.bottomStatus = 'empty'; } else { this.bottomStatus = ''; } this.page += 1; } catch (error) { this.bottomStatus = 'error'; } finally { this.loading = false; } }, // 触发加载下一页 loadNextPage() { if (this.loading || !this.hasMore) return; this.fetchList(); }, // 重试加载 retryLoad() { this.bottomStatus = 'loading'; this.fetchList(); }, // 模拟接口 fetchApi(page, pageSize) { return new Promise((resolve) => { setTimeout(() => { const list = []; for (let i = 0; i < pageSize; i++) { list.push({ id: (page - 1) * pageSize + i + 1, title: '第' + ((page - 1) * pageSize + i + 1) + '条数据' }); } resolve({ data: { list: list, total: 56 // 模拟总共 56 条,分 4 页多一点 } }); }, 800); }); } } } </script>这段代码基本实现了完整的分页加载逻辑,包含首次加载、滚动触发、追加数据、判断未页、错误重试和底部状态切换。
但这里有个关键点:onReachBottom在某些场景下(比如页面高度不够撑满一屏,或者数据过少)不会触发。用户第一次看到列表不到一屏,页面根本没有滚动空间,也就永远不会触底。所以我在fetchList首屏请求完成之后,会加一个“页面是否可滚动”的检测:如果内容高度小于视口高度,就直接再加载下一页。这个逻辑我在后面第 4 节“常见问题”里会展开讲。
如果要在 H5 端也监听组件内部滚动(比如弹层里做列表),Vue 2 + UniApp 下需要用scroll-view组件并监听它的@scrolltolower事件,实现方式如下。
组件级滚动加载(scroll-view 方案)
<template> <scroll-view class="scroll-container" scroll-y="true" @scrolltolower="loadNextPage" > <view class="list-item" v-for="(item, index) in list" :key="item.id"> <text>{{ item.title }}</text> </view> <!-- 底部状态 --> <view class="list-bottom"> <text v-if="bottomStatus === 'loading'">加载中...</text> <text v-else-if="bottomStatus === 'nomore'">没有更多了</text> <text v-else-if="bottomStatus === 'error'" @click="retryLoad">加载失败,点击重试</text> <text v-else-if="bottomStatus === 'empty'">暂无数据</text> </view> </scroll-view> </template>scroll-view的@scrolltolower事件在滚动到底部时自动调用,不需要手动计算滚动位置,更简单。但要注意scroll-view必须要有一个明确高度,不然它不知道“自己滚到哪”。实际布局里我把scroll-view的高度设置为撑满剩余空间,一般是:
.scroll-container { position: absolute; top: 44px; left: 0; right: 0; bottom: 0; }这样就避开了写死像素高度的问题,在各种屏幕尺寸下都能撑满剩余区域。
防抖节流的落地写法
即使有onReachBottom和@scrolltolower,某些端上滚动事件依然会高频触发。我在项目里写了一个简易节流函数放到公共工具里:
// utils/throttle.js export function throttle(fn, wait = 100) { let lastTime = 0; return function(...args) { const now = Date.now(); if (now - lastTime >= wait) { lastTime = now; fn.apply(this, args); } }; }在页面里这样用:
import { throttle } from '@/utils/throttle.js'; export default { data() { return { scrollFn: null }; }, onLoad() { this.scrollFn = throttle(this.onScrollCheck, 100); // 若要监听页面滚动计算位置,可在 onPageScroll 中调用 }, onPageScroll() { if (this.scrollFn) { this.scrollFn(); } } }只有当你需要做更精细的滚动位置判断(比如次屏列表、自定义触发距离)时,才需要自己监听滚动计算。一般情况下,onReachBottom和@scrolltolower就够了,别为了“显得专业”而乱加监听,反而增加性能负担。
Vue 3 + UniApp 无限滚动加载完整实现
Vue 3 的 UniApp 项目,整体思路与 Vue 2 完全一致,只是数据响应式变成setup+ref/reactive的组合式 API 写法,以及生命周期、方法引用方式有差异。
Vue 3 页面级滚动加载完整代码
<template> <view class="list-container"> <view class="list-item" v-for="(item, index) in list" :key="item.id"> <text>{{ item.title }}</text> </view> <view class="list-bottom"> <view v-if="bottomStatus === 'loading'"> <text>加载中...</text> </view> <view v-else-if="bottomStatus === 'nomore'"> <text>没有更多了</text> </view> <view v-else-if="bottomStatus === 'error'" @click="retryLoad"> <text>加载失败,点击重试</text> </view> <view v-else-if="bottomStatus === 'empty'"> <text>暂无数据</text> </view> </view> </view> </template> <script setup> import { ref } from 'vue'; import { onLoad, onReachBottom } from '@dcloudio/uni-app'; const list = ref([]); const page = ref(1); const pageSize = 15; const loading = ref(false); const hasMore = ref(true); const bottomStatus = ref('loading'); // 模拟接口请求 const fetchApi = (pageNo, size) => { return new Promise((resolve) => { setTimeout(() => { const newList = []; for (let i = 0; i < size; i++) { newList.push({ id: (pageNo - 1) * size + i + 1, title: 'Vue3第' + ((pageNo - 1) * size + i + 1) + '条数据' }); } resolve({ data: { list: newList, total: 56 } }); }, 800); }); }; // 请求列表数据 const fetchList = async () => { if (loading.value) return; loading.value = true; bottomStatus.value = 'loading'; try { const res = await fetchApi(page.value, pageSize); const newList = res.data.list || []; const totalPages = Math.ceil(res.data.total / pageSize); // 追加数据 list.value = list.value.concat(newList); hasMore.value = page.value < totalPages; if (!hasMore.value) { bottomStatus.value = 'nomore'; } else if (list.value.length === 0) { bottomStatus.value = 'empty'; } else { bottomStatus.value = ''; } page.value += 1; } catch (error) { bottomStatus.value = 'error'; } finally { loading.value = false; } }; // 加载下一页 const loadNextPage = () => { if (loading.value || !hasMore.value) return; fetchList(); }; // 重试加载 const retryLoad = () => { bottomStatus.value = 'loading'; fetchList(); }; onLoad(() => { fetchList(); }); onReachBottom(() => { loadNextPage(); }); </script>看到这里你会发现,Vue 3 版的逻辑和 Vue 2 版几乎一一对应,区别只在于:
list、page、loading等数据从this.xxx变成了xxx.value- 生命周期从 option API 的
onLoad变成了组合式 API 的onLoad回调函数 - 方法不再写进
methods,而是直接写成普通函数 page和hasMore等变量必须用ref包裹才能响应式更新
Vue 3 组件级滚动加载(scroll-view 方案)
scroll-view方案在 Vue 3 里差异不大,只在事件的绑定和 setup 上有所不同:
<template> <scroll-view class="scroll-container" scroll-y="true" @scrolltolower="loadNextPage" > <view class="list-item" v-for="(item, index) in list" :key="item.id"> <text>{{ item.title }}</text> </view> <view class="list-bottom"> <text v-if="bottomStatus === 'loading'">加载中...</text> <text v-else-if="bottomStatus === 'nomore'">没有更多了</text> <text v-else-if="bottomStatus === 'error'" @click="retryLoad">加载失败,点击重试</text> <text v-else-if="bottomStatus === 'empty'">暂无数据</text> </view> </scroll-view> </template> <script setup> import { ref } from 'vue'; const list = ref([]); const page = ref(1); const pageSize = 15; const loading = ref(false); const hasMore = ref(true); const bottomStatus = ref('loading'); const fetchApi = (pageNo, size) => { return new Promise((resolve) => { setTimeout(() => { const newList = []; for (let i = 0; i < size; i++) { newList.push({ id: (pageNo - 1) * size + i + 1, title: '第' + ((pageNo - 1) * size + i + 1) + '条数据' }); } resolve({ data: { list: newList, total: 100 } }); }, 800); }); }; const fetchList = async () => { if (loading.value) return; loading.value = true; bottomStatus.value = 'loading'; try { const res = await fetchApi(page.value, pageSize); const newList = res.data.list || []; const totalPages = Math.ceil(res.data.total / pageSize); list.value = list.value.concat(newList); hasMore.value = page.value < totalPages; if (!hasMore.value) { bottomStatus.value = 'nomore'; } else if (list.value.length === 0) { bottomStatus.value = 'empty'; } else { bottomStatus.value = ''; } page.value += 1; } catch (error) { bottomStatus.value = 'error'; } finally { loading.value = false; } }; const loadNextPage = () => { if (loading.value || !hasMore.value) return; fetchList(); }; const retryLoad = () => { bottomStatus.value = 'loading'; fetchList(); }; // scroll-view 不需要 onReachBottom,直接用事件 </script>Vue 2 与 Vue 3 实现差异对照表
说完两套代码,我用一张表把关键差异点梳理清楚,方便切换框架时快速对照:
| 对比维度 | Vue 2 + UniApp | Vue 3 + UniApp |
|---|---|---|
| 数据定义 | data() { return { list: [] } } | const list = ref([]) |
| 方法定义 | methods: { fetchList() {} } | 普通函数const fetchList = () => {} |
| 页面生命周期 | onLoad()、onReachBottom() | onLoad(() => {})、onReachBottom(() => {}) |
| 页面滚动事件 | onPageScroll()方法声明 | onPageScroll(() => {})回调 |
| 响应式更新 | this.list = this.list.concat(...) | list.value = list.value.concat(...) |
| 构建工具 | Webpack | Vite |
| 浏览器兼容 | 兼容性较好 | 新项目主流,编译速度更快 |
| 第三方库兼容性 | 生态较老,部分库直接支持 | 新生态,部分老库需要适配 |
我实际迁移项目的感受是:从 Vue 2 切 Vue 3 最难受的不是语法差异,而是思维方式的转变——习惯了this.xxx到处飞的人,一开始写xxx.value总会漏。漏了之后视图不更新,半天找不到原因,后来我给自己定了个规矩:所有页面里的响应式变量,全部用ref包裹,用reactive只在需要深层嵌套对象时用,减少心智负担。
常见问题与排查技巧实录
写到这里,我已经把 Vue 2 和 Vue 3 两套无限滚动加载的核心实现讲完了。但实际项目里,光有核心代码还不够,很多问题都出在边缘场景。下面我把过去踩过的坑和排查经验全部列出来,这些都是常规文档不会写的东西,个顶个有用。
页面高度不足一屏,滚动加载无法触发
这是新手最容易忽视的问题。当第一页数据太少,比如接口只返回了 3 条数据,页面内容高度远小于屏幕高度,用户根本没法往下滚,onReachBottom也永远不会触发,下一页永远加载不出来。
解决方案有两种,我个人推荐方案一:
- 方案一:首屏加载完成后判断:如果内容区域高度小于可视区域高度,自动补加载下一页,直到内容充满一屏或没有更多数据为止。
- 方案二:在最底部加一个“查看更多 / 点击加载更多”的按钮,让用户能主动触发加载。这种方式更保守,但不如方案一顺滑。
在 UniApp 里判断内容高度是否小于视口高度,我可以借助uni.createSelectorQuery()或者简单点:直接判断list.length < pageSize且hasMore为 true,就自动加载下一页。因为当数据条数少于页大小时,说明内容大概率撑不满一屏。
网络错误后无限滚动直接卡死
如果不做错误处理,loading状态一直卡在 true,后续所有滚动加载都会失效,整个列表看起来“死了”。我在代码里已经加了finally { loading = false },但更重要的是要给用户一个“重试”的出口。
真实场景里我遇到过接口偶发 500,用户下拉页面空白,连个提示都没有。所以我坚持在底部状态里做error态,并绑定点击重试事件。这看起来是小事,但在用户体感上是天壤之别。
重复请求同一个页码
重复请求的原因基本有三类:
- 缺少
loading锁 - 滚动事件触发太快,连续调了多次
loadNextPage page自增逻辑写错位置——比如在请求前就自增,导致失败后页码已经跳过
我的习惯是:
- 请求成功后再自增页码
loadNextPage开头加双重判断:if (this.loading || !this.hasMore) return- 网络失败时
page不自增,保证重试时请求的页码仍然正确
数据错乱:最后一页数据重复或丢失
这个问题多半是后端把total返回错了,或者前端对totalPages的计算有偏差。前端能做的:
- 用
Math.ceil向上取整,而不是Math.floor - 用“当前页返回的条数是否少于页大小”作为辅助判断:如果
newList.length < pageSize,直接认为没有下一页 - 如果后端不给
total,就通过newList.length判断:少于页大小即没有更多
代码示意:
const hasMore = newList.length >= pageSize;这种做法不依赖total字段,适用于一些最简化接口。但它也有个缺点:最后一页刚好等于 pageSize 时,会多请求一次接口,返回空数组后才停止。所以有total时优先用total判断,没有total再用长度判断,两者结合最稳妥。
App 端滚动事件失效
App 端(尤其原生渲染时)的滚动事件和 H5 端不完全一致。某些情况下onPageScroll在 App 端不触发,或者触发频率极低。排查方法是先区分端:
- 微信小程序端:用
onReachBottom最稳 - App 端:用
onReachBottom或scroll-view更稳 - H5 端:用
onReachBottom在普通页面可能无效,推荐用scroll-view或手动监听滚动
我写跨端项目时的经验是:优先用scroll-view包裹列表区域,然后监听@scrolltolower。这个方案在微信小程序、App、H5 三端表现都比较一致,反而比页面级滚动方案更省心。
列表渲染大量 DOM 导致卡顿
当列表长度到了几百条,滚动明显掉帧。此刻能做的:
- 图片懒加载:列表里的图片用
lazy-load属性(如果平台支持) - 减少容器节点层级:避免多层嵌套 view,尤其避免在列表项里大量使用计算属性或复杂样式
- 分页渲染:每次只渲染当前可视区域前后的数据,这是虚拟列表的思路
- 按平台取舍:小程序端用
virtual-list组件(比如@virt-list或第三方库),H5 端用v-for+ 节流勉强顶住
有一个细节:在 UniApp 小程序端,v-for的key一定不要用index,除非你的列表是静态不增删的。因为它关系着小程序端列表 diff 的性能。我看过不少代码这里直接写:key="index",无限滚动加载几次后,整个列表重新渲染,性能直接崩掉。
下拉刷新与无限滚动加载的冲突
很多页面同时需要“下拉刷新”和“滚动到底加载更多”,这两个动作要是配合不好会打架。核心原则:
- 下拉刷新时,重置页码为 1,清空列表
- 刷新完成前,禁止滚动加载触发
- 刷新动作完成后再恢复滚动加载
伪代码如下:
async function refresh() { if (this.loading) return; this.isRefresh = true; this.page = 1; this.list = []; this.hasMore = true; await this.fetchList(); this.isRefresh = false; }注意:isRefresh和loading要分开管理,否则下拉刷新时滚动加载判断会被锁住。
页面离开后异步回调更新界面
页面已经onUnload或者onHide,但之前发出的请求这时候才返回,然后还在更新list,这在某些端上会报错,或者造成无意义的渲染。一个实用的办法是加一个“页面是否存活”的标记:
onUnload() { this.isActive = false; }然后在请求回调里判断:
if (!this.isActive) return;Vue 3 写法:
onUnload(() => { isActive.value = false; });我在实际开发中就因为这个踩过坑:用户快速退出列表页,请求回调还在执行,结果页面刷新时报“Cannot read property 'xxx' of undefined”,排查半天才发现是异步回调越界激活导致的。
Vue 3 与 Vue 2 的迁移注意事项
最后说说从 Vue 2 迁移到 Vue 3 时,滚动加载相关特别容易踩的几个点:
- 数据响应式差异:Vue 3 用 Proxy 实现响应式,
list.value = ...的赋值方式意味着整个数组被替换。大多数情况下没问题,但如果子组件里持有旧数组引用,可能不会同步更新。 - 生命周期差异:
onLoad、onShow、onHide在<script setup>模式里必须从@dcloudio/uni-app导入,不要用 Vue 自带的onMounted替代——onMounted在页面生命周期里触发时机不对。 - 事件传递差异:Vue 3 中自定义事件用
emit('eventName'),Vue 2 用this.$emit('eventName'),如果滚动加载封装成子组件,这个差异一定会遇到。 - 兼容性差异:一些 Vue 2 时代常用的第三方列表组件在 Vue 3 下可能不可用,写滚动加载前先确认依赖是否兼容。
无限滚动加载的扩展与性能压榨
如果你已经跑通了基础版,下面这几个方向值得考虑——它们能大幅提升滚动加载的工程化程度和用户体验。
封装通用的滚动加载 Hook 或 Mixin
如果项目里有多个页面都需要无限滚动加载,我会把它抽取成公共逻辑。Vue 2 里用 Mixin,Vue 3 里用自定义 Hook。
Vue 3 里一个简单的useInfiniteListHook 示例:
// composables/useInfiniteList.js import { ref } from 'vue'; export function useInfiniteList(fetchApi, pageSize = 15) { const list = ref([]); const page = ref(1); const loading = ref(false); const hasMore = ref(true); const bottomStatus = ref('loading'); const loadNextPage = async () => { if (loading.value || !hasMore.value) return; loading.value = true; bottomStatus.value = 'loading'; try { const res = await fetchApi(page.value, pageSize); const newList = res.data.list || []; const total = res.data.total; list.value = list.value.concat(newList); hasMore.value = page.value * pageSize < total; bottomStatus.value = hasMore.value ? '' : 'nomore'; page.value += 1; } catch (error) { bottomStatus.value = 'error'; } finally { loading.value = false; } }; return { list, page, loading, hasMore, bottomStatus, loadNextPage, }; }页面使用:
const { list, bottomStatus, loadNextPage } = useInfiniteList(fetchApi, 20); onLoad(() => loadNextPage()); onReachBottom(() => loadNextPage());封装 Hook 的好处是:滚动加载逻辑被收敛在同一个地方,后续加“缓存上一次滚动位置”“刷新时重置状态”这类需求时,只改 Hook 内部即可,不用动每个页面。Vue 2 里用 Mixin 思路完全一致,只是换成mixins: [infiniteListMixin]。
图片懒加载与列表渲染优化
无限滚动列表最常见的内容组合是“标题 + 封面图”,图片加载是性能大户。我通常会:
- 给图片加懒加载:小程序端用
lazy-load,H5 端用loading="lazy" - 用
image组件的@load事件做防抖,避免图片加载后频繁触发布局变化 - 列表项样式写精简,不要用大阴影、大背景渐变等重渲染样式
另外,长列表里不要塞“复杂计算属性”的解析过程,比如在v-for里调用一个处理函数:
<view v-for="item in list" :key="item.id"> <text>{{ formatTime(item.createTime) }}</text> </view>每渲染一项就会调用一次formatTime,几千项就是几千次函数调用。正确的做法是在请求回来后就处理数据,把格式化结果直接存进列表项里:
newList.forEach(item => { item.timeText = this.formatTime(item.createTime); });请求竞态处理
无限滚动加载里有个隐蔽问题:第一页请求很慢,用户连续触发了好几次加载,导致第一页和第二页的返回顺序颠倒。如果后返回的覆盖先返回的,数据就乱了。
我通常用“请求序号”来处理竞态:
let requestSeq = 0; async function fetchList() { const currentSeq = ++requestSeq; // 请求... if (currentSeq === requestSeq) { // 只有最后一次请求才更新数据 } }或者用 AbortController(仅在支持的环境里)取消上一次请求。不过在 UniApp 小程序环境里,uni.request本身不支持取消,所以我更习惯用请求序号方案,几行代码就能覆盖常见竞态场景。
统一的分页请求封装
实践中,我还会把分页请求封装成一个统一返回格式的方法,避免每个页面重复写“解析 total、计算 totalPages、拼接 list”这些既繁琐又容易错的代码。
我做过的最省心的一次封装是这样的:
// 分页请求工具 function createPaginator(requestFn) { return { async load(page, pageSize) { const res = await requestFn(page, pageSize); return { list: res.data.list, total: res.data.total, hasMore: page * pageSize < res.data.total }; } }; }然后业务页面只需关心list怎么渲染、bottomStatus怎么展示,分页算法已经在工具层统一写好。越到后期,这种封装带来的收益越大。
结尾:我自己反复绕过的那些坎
做了几年 UniApp 开发,无限滚动加载算是我写得最多的功能之一。它看起来很小,但坑一点都不少。我个人的经验是:不要一上来就追求“完美封装”,先把最核心的“请求 + 追加 + 锁”跑通,再去补加载态、空态、错误重试、首屏撑满、竞态保护这些边角。基础稳了,什么端都能适配。
最后再分享一个小技巧:在开发阶段,我会在模拟接口里故意加随机延迟(300ms 到 1200ms之间),专门用来测试快速滚动时会不会重复触发请求。很多隐藏 bug 只有在“慢接口”场景下才会暴露出来——页面快速滚动到底部,接口还没返回,用户又往上滚又往下滚,这时候如果你的锁没写好,就会出现一连串的重复请求。多测这种极端场景,比写一千行“完美代码”更能提升工程可靠性。
希望这篇关于 Vue 2 + UniApp 和 Vue 3 + UniApp 无限滚动加载的实战总结能帮到你。有更好的实现思路,或者踩到我没提到的坑,欢迎在评论区交流。