airi × VueUse useBroadcastChannel:用响应式通道实现跨窗口通信与舞台状态同步
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
VueUse 的useBroadcastChannel将浏览器原生的 BroadcastChannel API 封装为一个响应式 Composable:以shallowRef承载最新收到的消息,用post()向命名信道广播,并在组件卸载时自动关闭信道。airi 仓库将其作为舞台(Stage)子系统的基础设施,在桌面版 stage-tamagotchi 的多窗口之间、以及 live2d / mmd / spine / three 多个渲染器包之间同步字幕、语音输入、模型参数等状态。读完本文,你可以掌握该 Composable 的完整 API、类型约束与生命周期行为,并参考 airi 仓库中真实存在的信道命名、消息定义、跨窗口收发与单元测试写法,在自己的 Vue 3 项目中落地类似的跨上下文状态同步方案。
BroadcastChannel API 概览
根据 VueUse 参考文档,useBroadcastChannel是对浏览器 BroadcastChannel API 的响应式封装,且会在组件卸载时自动关闭信道。其底层 API 的语义是:
- BroadcastChannel 接口代表一个命名信道,同一 origin(源)下的任意浏览上下文(browsing context)都可以订阅它;
- 它允许同一 origin 的不同文档(不同窗口、标签页、frame 或 iframe)之间通信;
- 消息的广播方式是:在信道上的所有BroadcastChannel 对象上触发
message事件(发送方自己不会收到自己发出的消息); useBroadcastChannel在该 API 之上补足了 Vue 生态最缺的三件事:响应式数据绑定(data自动随message事件更新)、SSR 兼容性(isSupported守卫)以及生命周期托管(自动close())。
基本用法
以下是参考文档中给出的完整用法示例,展示了从创建信道到发送、关闭的全流程:
import { useBroadcastChannel } from '@vueuse/core' import { shallowRef } from 'vue' const { isSupported, channel, post, close, error, isClosed, } = useBroadcastChannel({ name: 'vueuse-demo-channel' }) const message = shallowRef('') message.value = 'Hello, VueUse World!' // Post the message to the broadcast channel: post(message.value) // Option to close the channel if you wish: close()几个要点:
- 唯一的必填选项是信道名
name,同名的信道互相收得到对方的消息,不同名互不可见——因此信道名本身就是消息协议的第一层路由; post(data)即调用底层channel.postMessage(data),消息按结构化克隆(structured clone)传递,因此可以传对象、Map、ArrayBuffer等可克隆值;close()是显式关闭手段。即便忘记调用,组件卸载时也会自动关闭,通常只需关注close()即可覆盖绝大多数场景。
选项与类型声明
参考文档同时给出了完整的类型声明,这里原样继承并逐项解读:
export interface UseBroadcastChannelOptions extends ConfigurableWindow { /** * The name of the channel. */ name: string } /** * Reactive BroadcastChannel * * @see https://vueuse.org/useBroadcastChannel * @see https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel * @param options * */ export declare function useBroadcastChannel<D, P>( options: UseBroadcastChannelOptions, ): UseBroadcastChannelReturn<D, P> export interface UseBroadcastChannelReturn<D, P> extends Supportable { channel: ShallowRef<BroadcastChannel | undefined> data: ShallowRef<D> post: (data: P) => void close: () => void error: ShallowRef<Event | null> isClosed: ShallowRef<boolean> }各字段的含义:
| 返回值 | 类型 | 说明 |
|---|---|---|
isSupported | ComputedRef<boolean>(来自Supportable) | 当前环境是否支持 BroadcastChannel。SSR(无window)或不支持该 API 的浏览器中为false,此时channel保持undefined,调用方应据此降级 |
channel | ShallowRef<BroadcastChannel \| undefined> | 底层BroadcastChannel实例的响应式引用。注意类型上允许undefined(不支持或未创建时) |
data | ShallowRef<D> | 最近一条收到的消息。每收到一次message事件就更新,是"接收端"的响应式入口 |
post | (data: P) => void | 向信道发送消息 |
close | () => void | 显式关闭信道(幂等安全,配合isClosed判断状态) |
error | ShallowRef<Event \| null> | 底层信道error事件捕获到的Event对象 |
isClosed | ShallowRef<boolean> | 信道是否已关闭的响应式标记 |
两个泛型参数D(Data,接收消息类型)与P(Post,发送消息类型)解耦了收发两端,允许"只收不发"或"收发不同结构"的场景。airi 中的用法绝大多数是useBroadcastChannel<T, T>({ name }),即收发同构;需要单向接收时(如纯监听端)则只解构data,不传或忽略post亦可,因为类型上仍要求P,airi 会显式写成同名类型。
选项基类型ConfigurableWindow是 VueUse 的通用可配置项,允许注入自定义window/navigator实例(例如 Electron 中针对特定webContents的window对象),isSupported的判定也基于注入的 window。airi 中的所有调用点均只传了name,即默认使用当前全局window。
生命周期:自动关闭
参考文档明确说明 "Closes a broadcast channel automatically component unmounted"——Composable 将close()注册到当前组件的onScopeDispose,组件卸载(effect scope 释放)时信道被自动关闭。这意味着:
- 在组件
setup中调用时,无需在onUnmounted里手动清理,避免"泄漏的订阅窗口"; - 在 Pinia store 或应用级 Composable 中调用时,信道生命周期跟随 store 的 scope,通常与整个应用等长,这正是 airi 中 store 层大量使用它的原因。
airi 中的真实应用
以下用例均来自当前仓库源码,可逐一在对应文件路径中查看。
桌面端跨窗口字幕覆盖层
stage-tamagotchi 是一个 Electron 应用(源码按main/preload/renderer分层,见 electron 主配置),其中"字幕浮窗"是独立于主舞台的窗口。主窗口侧负责发送,见 Stage.vue:
const { post: postCaption } = useBroadcastChannel<CaptionChannelEvent, CaptionChannelEvent>({ name: 'airi-caption-overlay' }) const { post: postPresent } = useBroadcastChannel<PresentEvent, PresentEvent>({ name: 'airi-chat-present' })独立字幕窗口侧只接收,见 caption.vue:
const { data } = useBroadcastChannel<CaptionChannelEvent, CaptionChannelEvent>({ name: 'airi-caption-overlay' })两端共享同一个事件类型CaptionChannelEvent与同一个信道名'airi-caption-overlay',靠post/data的单向流动完成"主窗口 → 浮窗"的推送。由于 BroadcastChannel 的发送方不会收到自己的消息,这里的单向性天然成立,不需要额外的去回环逻辑。
语音输入信道:事件协议 + 源标识
use-hearing-input-channel.ts 展示了更完整的"协议设计"模式——信道消息携带operation与sourceId两个字段,解决"多来源轮流写入同一输入框"的竞争问题:
const { data } = useBroadcastChannel<HearingInputChannelEvent, HearingInputChannelEvent>({ name: hearingInputChannelName, }) watch(data, (event) => { if (!event) return if (event.operation === 'replace') { if (!event.text.trim()) return if (activeSourceId && activeSourceId !== event.sourceId) streamingInput.clear() activeSourceId = event.sourceId streamingInput.replace(event.text) return } if (event.sourceId !== activeSourceId) return streamingInput.clear() activeSourceId = undefined })要点:
- 信道名常量化:
hearingInputChannelName与事件类型HearingInputChannelEvent都从共享包@proj-airi/stage-shared导入,收发两端引用同一常量与类型,避免字符串漂移; data+watch是接收端的标准接线方式:useBroadcastChannel把消息事件收敛为 ref 更新,业务逻辑用 Vue 原生watch消费,保持了与组件内其他响应式逻辑一致的心智模型;sourceId防止过期消息污染:旧一轮语音(utterance)的clear事件到达时,若新来源已经开始写入,直接忽略,避免覆盖新内容。
对应的单元测试 use-hearing-input-channel.test.ts 值得注意,它验证了三个关键行为:
- 监听端确实以共享的
hearingInputChannelName创建信道(expect(...).toHaveBeenCalledWith({ name: hearingInputChannelName })); replace操作会替换"由本来源拥有的后缀",保留用户手动输入的前缀('manual note' + 'hello world');- 新一轮语音开始后,旧轮次的
clear被安全忽略(stale cleanup)。
多渲染器包之间的模型参数同步
airi 把不同 3D/2D 渲染器拆成独立的包,但它们在同一个页面/应用中会共享"用户调整模型参数"这类状态,各自通过useBroadcastChannel挂到独立信道上,从源码看属于"参数面板 → 各渲染器 store"的广播同步模式:
| 包 | 信道名 | 源码位置 |
|---|---|---|
| live2d | airi-stores-stage-ui-live2d | model-parameters.ts |
| mmd | (BroadcastChannelEvents泛型) | mmd.ts |
| spine | (BroadcastChannelEvents泛型) | spine.ts |
| three(VRM) | airi-stores-stage-ui-three-vrm | model-store.ts |
典型写法一致:
const { post, data } = useBroadcastChannel<BroadcastChannelEvents, BroadcastChannelEvents>({ name: 'airi-stores-stage-ui-three-vrm' })post用于参数变更后向外广播,data用于接收其他端(例如参数面板所在上下文)的更新。信道名中带有包名前缀(airi-stores-stage-ui-*),是 airi 中信道命名的一种实际惯例:以airi-开头标识归属,中段标识模块,可搜索、可区分。
其他用途:流式控制、后台同步与跨标签页
同一机制还出现在若干 store 级场景中,均遵循"命名信道 + 泛型消息类型"的模式:
- 流式对话的远端调用(turn calls):streaming-control.ts 中
const { post: postRemoteCall, data: incomingRemoteCall } = useBroadcastChannel<RemoteCallMessage, RemoteCallMessage>({ name: 'airi-streaming-control-turn-calls' }); - 后台模块的同步信号:background.ts 中
const { data: syncSignal, post: broadcastSync } = useBroadcastChannel({ name: 'airi:background-sync' })——这里展示了不带泛型的调用方式,D/P退化为unknown,适合纯信号(无 payload)场景; - 性能追踪桥:perf-tracer-bridge.ts;
- Spark 通知桥:context-bridge.ts 使用常量
SPARK_NOTIFY_BRIDGE_CHANNEL_NAME作为信道名,再次印证"信道名常量化"的项目惯例。
isSupported:多环境下的守卫模式
web 应用侧同样使用该 Composable。例如邮件验证页 verify-email.vue:
const { post, data, isSupported } = useBroadcastChannel<VerifyEmailEvent, VerifyEmailEvent>({ ... })这里额外解构了isSupported:对于运行在浏览器中的 Web 应用,它天然为true,但在 SSR 渲染阶段或极少数不支持 BroadcastChannel 的环境中为false,此时channel为undefined,UI 逻辑应基于isSupported降级而不是直接访问channel.value。这是文档类型Supportable基接口给出的通用契约,airi 的调用点按需选用isSupported或省略(同页多标签页同步这种纯浏览器场景下省略也安全)。
测试模式:mock 掉 Composable
airi 的测试并不在 Node 环境里真实创建BroadcastChannel,而是把@vueuse/core整体 mock 成可控对象,直接驱动data。参考 use-hearing-input-channel.test.ts:
const broadcastChannelMock = vi.hoisted(() => ({ useBroadcastChannel: vi.fn(), })) vi.mock('@vueuse/core', () => ({ useBroadcastChannel: broadcastChannelMock.useBroadcastChannel, })) beforeEach(() => { data = shallowRef<HearingInputChannelEvent>() broadcastChannelMock.useBroadcastChannel.mockReset() broadcastChannelMock.useBroadcastChannel.mockReturnValue({ data }) })这种模式的好处:测试只关注"收到某条消息后状态如何变化",而把"信道是否正确建立"压缩成一条toHaveBeenCalledWith({ name: ... })断言。eye-tracking.test.ts 等测试也采用同样的 mock 形状,说明这是仓库内处理该 Composable 的统一测试约定。
实践建议与适用边界
结合参考文档与 airi 的源码模式,可以总结出以下可复用的做法:
- 信道名是唯一的路由键:使用带项目前缀、带模块名的字符串(airi 惯例为
airi-*/airi:*),并集中为常量放在共享包中,收发两端引用同一常量; - 消息即协议:为每条信道定义独立的 TS 事件类型(如
CaptionChannelEvent、HearingInputChannelEvent、RemoteCallMessage),并在泛型上显式写出useBroadcastChannel<D, P>,让收发结构在编译期可见; - 接收端用
data+watch接线,发送端只取post;单向场景(浮窗、监听器)只解构data,发送方(主窗口)只解构post,职责在代码层面即分离; - 消息携带来源标识:当多个生产者竞争同一状态时,参考 hearing 信道的
sourceId做法在消息中带上轮次/来源 ID,避免过期消息覆盖新状态; - 注意 origin 隔离:BroadcastChannel 只在同一 origin 的浏览上下文之间通信,跨 origin(如嵌入的第三方 iframe)无法互通;从源码结构看,airi 桌面端用它同步的是同一应用内的多个窗口/上下文,正处在该 API 的设计范围内;
- 生命周期无需手工管理:组件内使用时卸载即自动
close();如需提前释放(如切换信道、退出页面阶段),再显式调用close()并用isClosed观察状态; - SSR / 兼容性:在服务端渲染或非浏览器环境,优先检查
isSupported再决定 UI 分支,不要假设channel一定可用。
参考
- VueUse Composable 参考文档(含用法与类型声明,由 vendor 同步维护,同步信息见 SYNC.md):useBroadcastChannel.md
- Composable 选型总表(
useBroadcastChannel列于 Browser 分类,AUTO调用级别):SKILL.md - airi 内真实调用点:Stage.vue、use-hearing-input-channel.ts、use-hearing-input-channel.test.ts、model-store.ts、streaming-control.ts、background.ts、verify-email.vue
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考