
如果你最近在用 uniapp 做商城、社区或资讯类应用八成会遇到这么一个问题页面结构拆得挺舒服列表被抽成了子组件触底加载逻辑顺手写在子组件里结果真机一滑到底接口纹丝不动。后台看不到请求控制台也不报错。很多人第一反应是去查分页参数、检查接口地址折腾半天才意识到——onReachBottom 从一开始就不该写在子组件里。这篇文章就把“组件嵌套子组件不触发 onReachBottom”这件事彻底讲透包括原因、四种绕过方案、完整的落地代码以及我在实际项目里踩过的坑。不管你是做微信小程序还是 H5、App 端只要用 uniapp 开发这套思路都适用。1. 先搞懂 onReachBottom 到底是什么1.1 它是页面级生命周期不是组件方法onReachBottom 这个 API 源自微信小程序 Page 对象后来被支付宝、百度等小程序平台相继兼容uniapp 也把它映射成了页面级生命周期。触发条件是“页面滚动条接近底部”精度通过 onReachBottomDistance 控制默认是 50px。这里的重点是“页面”两个字。在小程序运行时注册在 pages.json 里的每个路由对应一个 Page 实例自定义组件则是 Component 实例。Page 实例才拥有 onReachBottom、onPullDownRefresh、onPageScroll 这些滚动相关事件Component 实例没有。uniapp 在编译时会把页面 .vue 文件包装成 Page把子组件包装成 Component。你在子组件里写的 onReachBottom编译后并不存在于 Component 的定义里自然也就不会有人调用它。我见过最迷惑的一段代码就是这样// 子组件 GoodsList.vue export default { methods: { loadMore() { console.log(加载更多) } }, onReachBottom() { this.loadMore() } }看起来没毛病放页面里也能跑但只要挪进子组件控制台就开始装死。不是语法错了也不是方法没挂上而是小程序自定义组件根本不认识这个生命周期。Vue 选项合并的时候onReachBottom 会被当成普通属性放进组件实例但平台底层不会触发它。1.2 写在子组件里为什么不报错很多刚踩坑的朋友以为代码写错了其实“不报错”这件事才是误导人的关键。小程序组件配置文件里可以声明 pageLifetimes支持 show、hide、resize但从未开放 onReachBottom。uniapp 编译子组件的时候如果识别到 onReachBottom 这个选项最可能的结果是直接忽略。组件原样运行表面上一切正常等到滚动到底部什么都不会发生。我最初排查这个问题时还以为是组件里 v-for 渲染太慢导致触底事件被吞掉后来在页面 onReachBottom 里打了一个 console.log发现页面根本没执行。把同样的代码从子组件搬到页面立刻恢复正常。这时候我才确定不是事件被“吞”而是事件从头到尾就没有注册到组件上。1.3 整页滚动和局部滚动是两套逻辑搞清楚 onReachBottom 属于页面之后还有一个更关键的判断你的页面到底是整页滚动还是局部滚动。整页滚动页面根节点 view 随内容变高由页面容器滚动。这时候页面会触发 onReachBottom。局部滚动页面高度固定内容放在一个设定好高度的 scroll-view 里由 scroll-view 内部滚动。这时候转轴在 scroll-view 上页面本身没有滚动onReachBottom 永远不会触发应该使用 scroll-view 的 scrolltolower。一句话总结onReachBottom 是整页滚动的“到底通知”scrolltolower 是 scroll-view 自己的“到底通知”。前者只能由页面接收后者可以在任意组件中接收。注意如果子组件内部有一个纵向 scroll-view同时页面自身也可以滚动一定要确认滚动到底发生在哪个容器上。两边事件都监听很可能会出现“加载一次请求两次”的重复问题。2. 四种绕过方案选合适你的那一款2.1 方案一页面监听props 通知子组件最符合单向数据流思路的方案onReachBottom 留在页面里页面用一个累加数字通知子组件“到底了该加载了”。子组件监听这个 props变化一次就触底一次。父页面模板template view classpage goods-list :reach-bottom-tickreachBottomTick load-morehandleLoadMore / /view /template script export default { data() { return { reachBottomTick: 0 } }, onReachBottom() { this.reachBottomTick }, methods: { handleLoadMore() { console.log(子组件请求加载更多) } } } /script子组件里用 watch 监听这个数字script export default { props: { reachBottomTick: { type: Number, default: 0 } }, watch: { reachBottomTick(newVal, oldVal) { if (newVal oldVal) { this.loadMore() } } } } /script优点是数据流清晰、好调试缺点是如果组件嵌套层级很深需要在每一层中转 props代码会变得啰嗦。适合页面直接包一层子组件的场景。2.2 方案二页面通过 ref 直接调用子组件方法如果站点开发快页面只包了一个列表子组件可以直接在 onReachBottom 里拿 ref 调方法。template view classpage goods-list refgoodsListRef / /view /template script export default { onReachBottom() { if (this.$refs.goodsListRef) { this.$refs.goodsListRef.loadMore() } } } /script子组件里不需要监听任何 props只需要对外暴露一个 loadMore 方法script export default { methods: { loadMore() { // 加载下一页 } } } /script这段代码明显更短缺点也明显父页面要知道子组件的内部方法名耦合度高一旦子组件用 v-if 控制显隐onReachBottom 触发时 ref 可能还是 null。如果页面里塞了五六个组件每个都调一遍写起来非常烦躁。2.3 方案三局部滚动场景直接用 scroll-view如果你的列表本来就在一个固定高度的 scroll-view 里不要再幻想页面 onReachBottom 会来帮忙。正确的接法是在 scroll-view 上监听滚动到底。template scroll-view scroll-y classlist-scroll lower-threshold80 scrolltolowerloadMore view classgoods-card v-foritem in list :keyitem.id {{ item.name }} /view /scroll-view /template script export default { data() { return { list: [], loading: false } }, methods: { loadMore() { if (this.loading) return this.loading true // 拉下一页 } } } /script style scoped .list-scroll { height: 70vh; } /style这段代码放在子组件里完全没问题因为 scroll-view 的滚动事件是组件自带的。唯一要牢记的坑scroll-view 必须有确定高度要么写死要么用 flex 布局撑出来否则滚动发生在页面上scrolltolower 依然不会触发。2.4 方案四uni.$on 全局广播临时解法层级特别深、又不想立刻重构的时候有人会想到 uni.$on 和 uni.$emit。页面触底后发一个全局消息组件里接收。// 页面中 onReachBottom() { uni.$emit(global-reach-bottom) } // 子组件中 created() { uni.$on(global-reach-bottom, this.loadMore) }, beforeDestroy() { uni.$off(global-reach-bottom, this.loadMore) }这个方案能用但我建议只在快速验证时用。全局事件的名字很容易撞车多个列表组件同时监听时一个触底消息会让所有组件都加载一次离开页面时如果忘记 $off还会出现“已经被销毁的组件仍然在监听事件”的内存泄漏。真到线上这类问题比 onReachBottom 本身难查得多。2.5 四个方案怎么选方案适用场景代码量推荐指数页面监听 props一层或两层组件嵌套数据流清晰中等高ref 调用子组件方法页面组件结构简单追求快速低中scroll-view scrolltolower局部固定高度滚动区域低高uni.$on 全局广播多层嵌套、临时验证低低我的默认选择是方案一。它最符合 uniapp 生命周期的工作方式也方便后面拆组件、加缓存、做埋点。方案二适合内脏简单的小页面方案三在局部滚动场景里是最正经的解法方案四能不用就不用。3. 完整落地推荐方案一步步写出来3.1 场景设定假设现在要做一个商品列表首页页面结构是页面层pages/index/index.vue负责页面生命周期和滚动事件。子组件components/goods-list/goods-list.vue负责展示商品卡片、加载状态和“没有更多”提示。子组件内部维护自己的列表数据、加载状态、分页页码但触底消息必须由页面告诉它。3.2 页面层改造页面需要做三件事注册 onReachBottom、维护一个触底计数、定义处理加载完成后的逻辑。template view classpage-container goods-list :reach-bottom-tickreachBottomTick request-moreonRequestMore / /view /template script import GoodsList from /components/goods-list/goods-list.vue export default { components: { GoodsList }, data() { return { reachBottomTick: 0 } }, onReachBottom() { this.reachBottomTick 1 }, methods: { onRequestMore(page) { // 这里可以通过 uni.request 请求真实接口 console.log(请求第, page, 页数据) } } } /script如果希望更早触发可以在 pages.json 里调整距离阈值{ path: pages/index/index, style: { navigationBarTitleText: 首页, onReachBottomDistance: 80 } }注意这个配置只能放在 pages.json 的 style 节点里放在 data 或者 onLoad 里是不会生效的。不同小程序平台对阈值的支持略有差异实测下来微信小程序表现最稳定H5 端如果页面滚动容器不是 window这个阈值可能不生效。3.3 子组件接收触底信号子组件不再写任何与页面生命周期相关的代码只用一个 props 接收“触底次数”然后用 watch 驱动加载。template view classgoods-list view classgoods-card v-foritem in goods :keyitem.id {{ item.name }} /view view v-ifloading classstatus-tip加载中.../view view v-else-if!hasMore classstatus-tip没有更多了/view /view /template script export default { props: { reachBottomTick: { type: Number, default: 0 } }, data() { return { goods: [], page: 1, loading: false, hasMore: true } }, watch: { reachBottomTick(newVal, oldVal) { if (newVal oldVal) { this.loadMore() } } }, methods: { async loadMore() { if (this.loading || !this.hasMore) return this.loading true try { const res await this.fetchGoods(this.page) this.goods this.goods.concat(res.list) this.page 1 if (this.goods.length res.total) { this.hasMore false } } finally { this.loading false } }, fetchGoods(page) { return new Promise((resolve) { setTimeout(() { resolve({ total: 30, list: [ { id: page * 10 1, name: 商品 page } ] }) }, 300) }) } } } /script这段代码有两个细节值得注意。一是 watch 里用了 newVal oldVal。触底次数只会增加所以这是最简单可靠的触发条件。二是在 loadMore 开头用 loading 和 hasMore 做了双重防护。快速滚动到底时 onReachBottom 可能连续触发多次如果没有这层防护同一页数据会被重复请求。3.4 为什么用累加数字而不是布尔值有人会把 props 设计成布尔值比如 :is-reach-bottomtrue。我强烈不建议这样做。布尔值在连续触发场景下会出现一个问题第一次触底时父组件把 isReachBottom 从 false 改成 true子组件加载如果第二次触底发生在子组件还没把状态重置回 false 的瞬间父组件再改成 trueVue 会认为值没有变化watch 不触发加载丢失。累加数字就不会有这个问题。每次触底数字都加一watch 每次都能识别到新的变化。数字还可以顺便充当事件唯一标识将来做埋点、排查问题都更容易。3.5 多个子组件都要触底加载怎么办页面里如果同时存在“推荐商品”和“热门文章”两个列表都希望触底加载我更建议把分页和列表数据收敛到父页面或者交给一个全局状态容器。二维列表各自维护 page、hasMore、loading 会让触底信号难以管理。用 ref 方案时有一个取巧的写法onReachBottom() { const refs this.$refs for (const key in refs) { const item refs[key] if (item typeof item.loadMore function) { item.loadMore() } } }前提是你要求每个子组件都实现一个同名 loadMore 方法。这个方案省事但如果两个子组件请求的是同一个分页接口会重复拉数据。更好的做法是由父页面统一维护分页页码在 onReachBottom 里请求下一个 page拿到数据后再通过 props 分发到不同子组件。4. 孙组件以及其他跨层传递的姿势4.1 三层结构props 中转太啰嗦页面包着一个“楼层”楼层里又包着一个“商品列表”这就是三层组件页面 pages/index/index.vue中间层 components/floor/floor.vue列表层 components/goods-list/goods-list.vue如果还是用 props页面要把触底信号传给 floorfloor 再传给 goods-list。中间层如果只是一个容器却要接收并转发这个信号写起来很啰嗦还容易在重构时漏掉。4.2 用 provide/inject 跳过中间层Vue 2 的 provide 并不默认具备响应性但是有一个很实用的技巧直接把整个页面实例 provide 出去。页面 data 里的属性变了实例本身始终是响应式的孙组件 inject 到同一个对象后watch 可以感知变化。// 页面中 export default { data() { return { reachBottomNum: 0 } }, provide() { return { pageReachBottom: this } }, onReachBottom() { this.reachBottomNum 1 } }// 任何层级的子组件里 export default { inject: [pageReachBottom], watch: { pageReachBottom.reachBottomNum() { this.loadMore() } } }这个方案的侵入性比 uni.$on 小不需要全局事件名不需要手动 off。不过也要注意页面和子组件生命周期的问题子组件挂载时页面 provide 的对象可能还没有完成 data 初始化所以 watch 里最好先判断一下值是否存在。Vue 3 组合式写法类似用 reactive 或 ref 生成响应式消息源// 页面 setup 里 import { reactive, provide } from vue const reachState reactive({ num: 0 }) provide(reachState, reachState) // 页面 onReachBottom 里 reachState.num 1孙组件可以用 inject 拿到 reachState再 watch 监听 num。4.3 用 Pinia 或 Vuex 彻底解耦如果项目已经上了 Pinia 或 Vuex触底通知根本不需要在组件树里传来传去。让页面只做一件事调用 store 的 loadMore 方法。列表数据和加载状态全部放 store子组件从 store 读取并渲染。// store/goods.js import { defineStore } from pinia export const useGoodsStore defineStore(goods, { state: () ({ list: [], page: 1, loading: false, hasMore: true }), actions: { async loadMore() { if (this.loading || !this.hasMore) return this.loading true try { const res await request(/goods, { page: this.page }) this.list this.list.concat(res.list) this.page 1 this.hasMore this.list.length res.total } finally { this.loading false } } } })页面 onReachBottom 里只要一行import { useGoodsStore } from /store/goods onReachBottom() { useGoodsStore().loadMore() }子组件完全不需要知道触底事件它只需要从 store 读取 list、loading、hasMore 并渲染。这套做法的好处是不管组件嵌套多少层都不会影响触底逻辑将来要做预加载、下拉刷新、消息推送刷新改的地方也很集中。4.4 局部滚动容器里的跨层触底如果最里层的列表不是整页滚动而是放在 scroll-view 里那么触底事件天然发生在 scroll-view 上。此时不要强行把事件往上层传直接在 scroll-view 内部用 scrolltolower 即可。跨层时最好把 scroll-view 作为独立组件封装把加载方法暴露出去或者触发事件由业务组件去决定加载什么数据。5. 那些容易被忽略的边界情况和排查技巧5.1 内容不满一屏onReachBottom 就是不触发页面内容太少根本没有滚动条自然谈不上“触底”。这种情况下无论你把 onReachBottom 写在哪里都不会触发。处理办法是在空数据和首屏数据不足时主动判断是否还需要加载如果接口返回的数据不足以填满一屏可以继续拉下一页直到屏幕可滚动为止。这块逻辑不要放在 onReachBottom 里应该在接口成功回调里再做一次检查。5.2 页面里塞了 scroll-viewonReachBottom 反而失效一个常见场景页面高度 100vh里面套了一个固定高度的 scroll-view 列表。页面本身不滚动触底事件发生在 scroll-view 内部页面的 onReachBottom 就永远不触发。遇到这种情况不要再纠结于页面事件直接在 scroll-view 的 scrolltolower 里处理。如果目录结构要求必须在父页面里处理加载可以让 scroll-view 触发 scrolltolower 后通过 $emit 向父页面发一个自定义事件父页面收到后再分发给其他兄弟组件。这样在组件内部保持事件来源清晰。5.3 onReachBottomDistance 写在代码里没用同一个页面有人设置的 onReachBottomDistance 生效有的人设置没有效果大概率是位置写错了。正确的写法{ pages: [ { path: pages/index/index, style: { navigationBarTitleText: 首页, onReachBottomDistance: 100 } } ] }这是原生小程序 pages.json 的配置项uniapp 页面内虽然能接受这个字段但不会像原生小程序那样从 page 配置里读取。如果你在 data 里写onReachBottomDistance: 100只是多了一个没有任何业务逻辑的普通数据。5.4 快速滑到底接口被反复请求onReachBottom 在快速滚动时是有可能连续触发的。页面代码里只负责递增计数真正的“防重”要放在加载逻辑里也就是子组件 loadMore 开头的if (this.loading || !this.hasMore) return。很多人只防 loading忘了 hasMore导致数据加载完后 onReachBottom 还不停触发每个触底都发一次无效请求。hasMore 判断能把这个无效请求直接挡在门外。5.5 从二级页面返回列表页触底加载不重新触发这不是 uniapp 的 bug而是 onReachBottom 本身只在“滚动到底部”时触发。页面从二级页返回如果滚动位置已经在底部小程序不会因为页面回到前台就重新触发一次 onReachBottom。如果你希望在返回时自动刷新列表数据应该在 onShow 里做判断不要把刷新逻辑寄托在 onReachBottom 上。5.6 问题排查速查表现象可能原因解决办法子组件里写了 onReachBottom 但没反应自定义组件没有页面生命周期把 onReachBottom 移到页面用 props/ref 通知子组件内容不满一屏触底不触发页面没有滚动条在接口成功回调里主动检查当前列表高度决定是否补拉下一页页面有 scroll-viewonReachBottom 失效滚动发生在 scroll-view 内部改用 scroll-view 的 scrolltolower设置了 onReachBottomDistance 无效配置写在 data 或组件里移到 pages.json 的 style 节点快速滑动触发多次 onReachBottom事件被连续触发加载方法里做 loading 和 hasMore 双重判断返回页面后触底不触发事件需要滚动动作在 onShow 里手动处理需要刷新的数据最后分享一点个人习惯踩过这个坑之后我现在写 uniapp 列表页有一个默认约束onReachBottom 永远只出现在页面层子组件一律不碰。哪怕只是一个极小的列表组件我也习惯在页面上维护一个 reachBottomTick用次数信号往下传。这样做的好处是组件可以随时被其他页面复用不会因为依赖页面生命周期就变成“一次性组件”。如果你的项目里已经有很多列表组件我建议先不要急着全量改选一两个活跃页面把方案一或者 Pinia 的方式落下去跑两周再复制到其他页面。触底加载这种逻辑改起来不复杂但改动牵涉到分页数据、loading 状态和空态展示一次性铺开容易翻车。希望这篇文章能帮你避开我当初踩过的坑少加班多睡觉。