uni-router新推useUniEventChannel,彻底解决页面通信难题

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' }) // 无法通信

实际业务中,replacerelaunch 同样需要与目标页面通信的场景很常见:

  • 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>),确保不同导航之间的事件不会串扰。

通道生命周期

scss 复制代码
发起页调用 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
}

push / replace / relaunch 的返回值从 RouteLocation 扩展为 NavigationResult

typescript 复制代码
interface NavigationResult extends RouteLocation {
	eventChannel?: EventChannel
}
导航方式 默认模式(useUniEventChannel: false) useUniEventChannel: true
push eventChannel 为原生 EventChannel eventChannelUniEventChannel
replace eventChannelundefined eventChannelUniEventChannel
relaunch eventChannelundefined eventChannelUniEventChannel

类型向后兼容:NavigationResult extends RouteLocation,原有的 const route: RouteLocation = await router.push(...) 仍可用。

配合 NavigationResultRouterLinknavigated 事件现对所有导航方式统一触发,并传递 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 在导航链路中传递:

scss 复制代码
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 类、generateNavIdwrapEventNamenoopChannelNAV_ID_KEY
  • src/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 }
	}
}
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/relaunchevents 参数 忽略 + 警告 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 生效
相关推荐
IT_陈寒1 小时前
Vite冷启动快?我遇到了个奇怪的依赖问题
前端·人工智能·后端
2603_955279701 小时前
通过 brew upgrade cmake 升级到最新版本
前端
FREEDOM_X1 小时前
Linux 进程间通讯(IPC)——总结
linux·c语言·前端·嵌入式硬件·struts
WWBQU2 小时前
前端day06
前端
岁月宁静2 小时前
DeepSeek Harness 团队 招聘 :JD反推:新手如何系统掌握AI Agent开发技术?
前端·人工智能·后端
小则又沐风a2 小时前
深入理解文件系统(二)
java·服务器·前端
sztomarch2 小时前
Disable-Download-Chrome-AI-Mode
前端·chrome
禅思院2 小时前
AI对话前端从入门到崩溃:一个长对话引发的五层优化战争【三】
前端·架构·前端框架
DianSan_ERP11 小时前
电商架构演进:如何在高并发场景下实现多平台API的标准化履约?
运维·前端·网络·安全·架构·自动化