v1.10.0 新增
useUniEventChannel选项与UniEventChannel类,基于uni.$emit/$on全局事件总线实现内置页面间通信,使所有导航方式(push/replace/relaunch)均支持双向通信;配合usePageChannel()组合式 API 与 Sticky 事件缓存,彻底解决原生 EventChannel 的平台限制与时序竞态问题
前言
@meng-xi/uni-router 在 v1.9.0 之前已经通过 uni.navigateTo 原生 EventChannel 支持了 push 导航的页面间通信,但存在以下痛点:
- 仅 push 支持通信 :
uni.redirectTo/uni.reLaunch不支持EventChannel,使用replace/relaunch导航时无法与目标页面通信 - 时序竞态 :发起页在
await router.push()后立即emit事件,但目标页setup注册on监听器可能晚于emit执行,导致事件丢失 - H5 刷新丢失 :原生
EventChannel基于内存,H5 刷新后通道不复存在 - API 不统一 :发起页通过
events参数传递监听器,目标页通过getOpenerEventChannel()获取通道,两套 API 风格差异大
v1.10.0 通过内置通信管理器(UniEventChannel)解决了这些问题:基于 uni.$emit/$on/$off/$once 全局事件总线,每次导航生成唯一 navigationId 隔离事件通道,配合 Sticky 缓存解决时序竞态,__nav_id 通过 URL
query 传递使 H5 刷新后仍可重建通道。
一、问题分析
1. 原生 EventChannel 的平台限制
uni-app 的 EventChannel 机制仅存在于 uni.navigateTo:
typescript
// 仅 push 有 eventChannel
const eventChannel = await uni.navigateTo({
url: '/pages/detail/index',
events: { receiveData: data => console.log(data) }
})
// replace / relaunch 不支持 events 参数
await uni.redirectTo({ url: '/pages/login/index' }) // 无法通信
await uni.reLaunch({ url: '/pages/index/index' }) // 无法通信
实际业务中,replace 和 relaunch 同样需要与目标页面通信的场景很常见:
- replace 登录页:登录成功后需要将用户信息传回替换前的页面上下文
- relaunch 首页:退出登录后跳转首页,需要传递退出原因
- replace 详情页:从 A 详情页跳转到 B 详情页时替换栈,需要传递来源信息
2. 时序竞态
typescript
// 发起页
const result = await router.push({ path: '/pages/detail/index' })
result.eventChannel.emit('data', { id: 123 }) // 何时执行?
// 目标页 setup
const channel = getOpenerEventChannel()
channel.on('data', payload => {
/* 能收到吗? */
})
问题在于:emit 在发起页的微任务中执行,而目标页的 setup 执行时机取决于平台和页面栈状态------如果 emit 先于 on 注册执行,事件就丢失了。
3. API 风格不统一
| 角色 | API | 说明 |
|---|---|---|
| 发起页 | events: { eventName: callback } |
通过导航参数传递监听器 |
| 目标页 | getOpenerEventChannel() |
Options API 风格获取通道 |
在组合式 API 中,getOpenerEventChannel() 不符合 useXxx 命名规范,也无法在 onUnmounted 中自动清理。
二、新增能力
1. 内置通信管理器(useUniEventChannel)
启用方式
typescript
import { createRouter } from '@meng-xi/uni-router'
const router = createRouter({
routes,
useUniEventChannel: true // 启用内置通信管理器
})
启用后,所有导航方式(push/replace/relaunch)均返回 NavigationResult,包含 eventChannel:
typescript
// push
const pushResult = await router.push({ path: '/pages/detail/index' })
pushResult.eventChannel.emit('fromSender', { msg: 'hello' })
// replace
const replaceResult = await router.replace({ path: '/pages/login/index' })
replaceResult.eventChannel.emit('logoutReason', { reason: 'expired' })
// relaunch
const relaunchResult = await router.relaunch({ path: '/pages/index/index' })
relaunchResult.eventChannel.emit('resetInfo', { from: 'logout' })
UniEventChannel 实现
UniEventChannel 基于 uni.$emit/$on/$off/$once 全局事件总线,通过 navigationId 隔离事件通道:
typescript
// 事件名格式:uni-router:<navId>:<eventName>
// 例如:uni-router:nav-1720519200000-1:data
每次导航生成唯一的 navigationId(格式 nav-<timestamp>-<seq>),确保不同导航之间的事件不会串扰。
通道生命周期
发起页调用 push/replace/relaunch
├─ generateNavId() 生成唯一 ID
├─ new UniEventChannel(navId) 创建通道
├─ registerChannel(navId, channel) 注册到全局 registry
├─ enrichLocationWithNavId() 将 __nav_id 注入 query
├─ matcher.resolve() 解析路由(从 query 中移除 __nav_id,写入 params.__navId)
└─ 实际导航 URL 保留 __nav_id(类似 __params_key 机制)
目标页 onShow → syncCurrentRoute → 从 URL 读取 __nav_id → 重建 params.__navId
目标页 setup → usePageChannel() → 读取 params.__navId → getOrCreateChannel(navId)
目标页 onUnmounted → destroyChannel(navId) → channel.destroy() → 清理监听器与缓存
2. Sticky 事件缓存
UniEventChannel 内置粘性事件缓存机制,解决 emit/on 的时序竞态:
typescript
// UniEventChannel 核心逻辑(简化)
class UniEventChannel implements EventChannel {
private pendingEvents: Map<string, any[]> = new Map()
emit(event: string, ...args: any[]): EventChannel {
// 总是更新缓存,使后续注册的 on/once 都能收到最后一次 emit 的数据
this.pendingEvents.set(event, args)
// 有监听器时同时通过 uni.$emit 触发
if (this.listeners.get(event)?.size > 0) {
uni.$emit(wrapEventName(this.navId, event), ...args)
}
return this
}
on(event: string, callback: Function): EventChannel {
uni.$on(wrapEventName(this.navId, event), callback)
// 有缓存时异步触发(保留缓存,使后续注册的 once 也能收到)
const pending = this.pendingEvents.get(event)
if (pending) {
Promise.resolve().then(() => callback(...pending))
}
return this
}
}
关键设计:
emit始终缓存 :无论是否有监听器,都保存最后一次emit的参数on/once异步触发缓存 :注册时若有缓存,通过Promise.resolve().then()微任务异步触发- 缓存不删除 :保留缓存确保后续注册的
once也能收到,直到destroy()时统一清理
typescript
// 发起页:导航后立即 emit,不担心目标页还没注册监听器
const result = await router.push({ path: '/pages/detail/index' })
result.eventChannel.emit('data', { id: 123 })
// 目标页:setup 中注册监听,即使晚于 emit 也能收到缓存事件
const channel = usePageChannel()
channel.on('data', payload => {
console.log(payload) // { id: 123 } --- 从缓存触发
})
3. usePageChannel() 组合式 API
目标页面通过 usePageChannel() 获取通信通道,替代原来的 getOpenerEventChannel():
typescript
import { usePageChannel, noopChannel } from '@meng-xi/uni-router'
export default {
setup() {
const channel = usePageChannel()
// 判断是否有可用通道
const hasChannel = channel !== noopChannel
// 监听发起页事件
channel.on('data', payload => {
console.log('收到数据:', payload)
})
// 向发起页发送事件
channel.emit('ready', { status: 'ok' })
return { channel, hasChannel }
}
}
noopChannel 安全降级
当页面不是通过带 __nav_id 的导航打开时(如直接从浏览器访问),usePageChannel() 返回 noopChannel------所有方法均为空操作并返回自身,调用方无需判空:
typescript
export const noopChannel: EventChannel = {
on: () => noopChannel,
once: () => noopChannel,
off: () => noopChannel,
emit: () => noopChannel
}
自动清理
usePageChannel() 在 onUnmounted() 时自动调用 destroyChannel(navId),清理该通道的所有监听器和缓存,防止内存泄漏:
typescript
export function usePageChannel(): EventChannel {
const router = useRouter()
const route = getReactiveRoute(router)
const navId = route.value.params?.__navId as string | undefined
if (!navId) return noopChannel
const channel = getOrCreateChannel(navId)
onUnmounted(() => destroyChannel(navId)) // 自动清理
return channel
}
4. NavigationResult 返回类型
push / replace / relaunch 的返回值从 RouteLocation 扩展为 NavigationResult:
typescript
interface NavigationResult extends RouteLocation {
eventChannel?: EventChannel
}
| 导航方式 | 默认模式(useUniEventChannel: false) | useUniEventChannel: true |
|---|---|---|
push |
eventChannel 为原生 EventChannel |
eventChannel 为 UniEventChannel |
replace |
eventChannel 为 undefined |
eventChannel 为 UniEventChannel |
relaunch |
eventChannel 为 undefined |
eventChannel 为 UniEventChannel |
类型向后兼容:NavigationResult extends RouteLocation,原有的 const route: RouteLocation = await router.push(...) 仍可用。
5. RouterLink navigated 事件扩展
配合 NavigationResult,RouterLink 的 navigated 事件现对所有导航方式统一触发,并传递 eventChannel:
html
<!-- 发起页模板 -->
<RouterLink to="/pages/about/index" @navigated="onNavigated"> 跳转到关于页 </RouterLink>
typescript
// 无论 push / replace / relaunch,都能通过 navigated 获取 channel
onNavigated(eventChannel) {
if (eventChannel) {
eventChannel.on('receiveData', (data) => console.log(data))
eventChannel.emit('fromOpener', { msg: '来自 RouterLink' })
}
}
三、架构设计
通道注册表
新增 channel/registry.ts,管理 navId → UniEventChannel 映射:
typescript
const channelRegistry: Map<string, UniEventChannel> = new Map()
export function registerChannel(navId: string, channel: UniEventChannel): boolean {
if (channelRegistry.has(navId)) return false // first-wins 策略
channelRegistry.set(navId, channel)
return true
}
export function getOrCreateChannel(navId: string): UniEventChannel {
const existing = channelRegistry.get(navId)
if (existing) return existing
const channel = new UniEventChannel(navId)
channelRegistry.set(navId, channel)
return channel
}
export function destroyChannel(navId: string): void {
const channel = channelRegistry.get(navId)
if (channel) {
channel.destroy()
channelRegistry.delete(navId)
}
}
__nav_id 注入与提取
类似 __params_key 机制,__nav_id 通过 URL query 在导航链路中传递:
enrichLocationWithNavId(location, navId) ← 注入到 query
↓
matcher.resolve(enrichedLocation) ← 从 query 移除,写入 params.__navId
↓
extractNavId(enrichedLocation) ← 从 enrichedLocation 提取
↓
实际导航 URL 保留 __nav_id ← 目标页面可从 URL 读取
↓
syncCurrentRoute → 读取 __nav_id → params.__navId ← 重建通道
新增文件
src/channel/uni-event-channel.ts---UniEventChannel类、generateNavId、wrapEventName、noopChannel、NAV_ID_KEYsrc/channel/registry.ts--- 通道注册表src/channel/index.ts--- 统一导出
新增导出
typescript
// src/index.ts 新增
export { usePageChannel } from '@/composables'
export { UniEventChannel, noopChannel } from '@/channel'
四、完整使用示例
场景:登录后传递用户信息
typescript
// 发起页:replace 到登录页,传递退出原因
const result = await router.replace({ path: '/pages/login/index' })
result.eventChannel?.on('loginSuccess', user => {
console.log('登录成功:', user)
})
typescript
// 登录页:登录成功后发送用户信息
import { usePageChannel } from '@meng-xi/uni-router'
export default {
setup() {
const channel = usePageChannel()
async function handleLogin() {
const user = await login()
channel.emit('loginSuccess', user)
}
return { handleLogin }
}
}
场景:RouterLink + navigated 事件
html
<RouterLink to="/pages/about/index" replace @navigated="onNavigated"> 关于页 </RouterLink>
typescript
onNavigated(eventChannel) {
if (eventChannel) {
eventChannel.on('replyData', (data) => {
this.log = `收到回复: ${JSON.stringify(data)}`
})
eventChannel.emit('fromOpener', { msg: '来自首页' })
}
}
升级指南
v1.10.0 是向后兼容版本,绝大多数项目无需修改代码即可升级。
行为变化
| 场景 | v1.9.0 | v1.10.0 |
|---|---|---|
push 返回类型 |
RouteLocation |
NavigationResult extends RouteLocation |
replace 返回类型 |
RouteLocation |
NavigationResult(含 eventChannel) |
relaunch 返回类型 |
RouteLocation |
NavigationResult(含 eventChannel) |
replace/relaunch 的 events 参数 |
忽略 + 警告 | useUniEventChannel: true 时注册到内置通道 |
RouterLink @navigated |
仅 push 触发 |
所有导航方式触发 |
| 原生 EventChannel | 默认使用 | useUniEventChannel: true 时替代为 UniEventChannel |
启用内置通信
typescript
// 方式一:createRouter 选项
const router = createRouter({
routes,
useUniEventChannel: true
})
// 方式二:渐进式------默认关闭,按需开启
// 不传 useUniEventChannel,保持 v1.9.0 行为不变
迁移建议
启用 useUniEventChannel 后,推荐将目标页的 getOpenerEventChannel() 替换为 usePageChannel():
diff
// 目标页
import { usePageChannel, noopChannel } from '@meng-xi/uni-router'
export default {
- onLoad() {
- const channel = getOpenerEventChannel()
- channel.on('data', (payload) => { ... })
- }
+ setup() {
+ const channel = usePageChannel()
+ channel.on('data', (payload) => { ... })
+ return { channel }
+ }
}
发起页的 events 参数可改为 result.eventChannel.on() 模式:
diff
// 发起页
-const result = await router.push({
- path: '/pages/detail/index',
- events: { receiveData: (data) => console.log(data) }
-})
+const result = await router.push({ path: '/pages/detail/index' })
+result.eventChannel?.on('receiveData', (data) => console.log(data))
+result.eventChannel?.emit('fromOpener', { msg: 'hello' })
新增导出
usePageChannel--- 组合式 API,获取当前页面通信通道UniEventChannel--- 内置通信通道类noopChannel--- 空操作通道常量
新增类型
NavigationResult--- 导航结果类型(extends RouteLocation,新增eventChannel?: EventChannel)
兼容性
- 默认模式(
useUniEventChannel: false)行为与 v1.9.0 完全一致 NavigationResult extends RouteLocation,原有类型声明无需修改events参数在默认模式下仍仅 push 生效