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 生效
相关推荐
mldong5 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排6 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60616 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角7 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!7 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰8 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi9 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen10 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马11 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒11 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端