必须在对应生命周期中显式调用 uni.$off 解绑,否则重复绑定导致事件多次触发;蓝牙监听需全局唯一,子组件下拉刷新应上移至父级;防抖无法解决监听器堆积,须严格配对注册与销毁。

uni.$on 重复绑定导致事件触发多次
直接在 onLoad 或 onShow 里反复调用 uni.$on,却不 uni.$off,是事件重复触发的最常见原因。页面每次显示、组件反复挂载,都会新增一个监听器,但旧的还活着——结果就是一次 uni.$emit,多个回调同时执行。
典型表现:列表页返回后刷新两次、表单提交成功后弹窗两次、蓝牙设备被处理三次。
- 永远不要在
onShow中无条件调用uni.$on;onShow是高频触发时机,极易累积监听器 - 必须在对应生命周期(如
onUnload、onHide或beforeUnmount)中显式调用uni.$off('xxx'),且传入的事件名要完全一致 - 如果用函数引用方式绑定(
uni.$on('foo', handler)),解绑时必须传入同一个handler引用,不能写匿名函数 - 在
setup中使用onBeforeUnmount比onUnload更可靠,尤其对子组件或v-if切换场景
uni.onBluetoothDeviceFound 反复注册监听
这个 API 不是「设置监听」,而是「追加监听」——每次调用都新增一个底层监听实例。很多开发者在扫描页 onLoad 里写一遍,在用户点击「重新扫描」时又调一遍,却没清理旧的,结果设备一出现,回调就跑 N 次。
iOS 尤其明显:系统上报频率高,叠加未清理的监听器,同一台设备可能在 1 秒内触发 5–8 次回调。
- 全局只保留一个
uni.onBluetoothDeviceFound调用,建议放在 App.vue 或单独的蓝牙管理模块中初始化 - 业务页不直接调用该 API,改为监听自定义事件(如
uni.$on('bluetooth-device-found')) - 扫描开关逻辑通过
uni.startBluetoothDevicesDiscovery/uni.stopBluetoothDevicesDiscovery控制,而非反复启停监听器 - 务必在 App 卸载前(
onShow→onHide→onUnload链路)调用uni.offBluetoothDeviceFound
子组件中 onPullDownRefresh 或 onReachBottom 多次绑定
当子组件被 v-if 控制显隐,或在 tab 切换中频繁销毁重建时,onPullDownRefresh 这类生命周期钩子若写在子组件内部,就会随组件重复注册——但 uni-app 不会自动帮你解绑,上一个实例的钩子依然有效。
后果是:下拉一次,多个子组件同时触发刷新请求;滚动到底部,发 3 次分页请求。
- 禁止在
v-if包裹的子组件内使用onPullDownRefresh;应统一提到父页面或路由级组件中处理 - 若必须由子组件控制,改用方法调用模式:
uni.startPullDownRefresh()主动触发,并在父组件中集中响应 - 检查是否在
setup中多次执行了onPullDownRefresh(() => {...})——闭包会保留旧函数,造成堆积 - 可在注册前加日志:
console.log('bind pull down:', Date.now()),快速验证是否重复绑定
防抖节流无法解决监听器堆积问题
有人试图用 debounce 包裹 uni.$on 的回调,这是方向错误。节流只能压制回调执行频率,但监听器本身还在内存里不断新增,最终导致内存泄漏和响应延迟加剧。
真正要防的是「监听器数量增长」,不是「回调执行次数」。
-
debounce/throttle适合处理用户交互(如按钮点击、输入),不适合治理事件总线或系统级 API 的监听生命周期 - 所有基于
uni.$on、uni.onXXX、uni.addInterceptor的注册行为,都必须配对uni.$off或uni.offXXX - 复杂场景建议封装统一管理器,例如
EventBus.register('auth-change', handler, 'page-profile'),销毁时按 scope 批量清理 - 真正在意性能的项目,应该用
uni.getConnectedBluetoothDevices主动轮询替代被动监听,规避事件机制本身的不确定性
关键点其实就一个:uni-app 的事件监听全是手动生命周期,没有自动 GC。你注册多少次,它就记多少次——哪怕页面已经看不见了。











