Toggle navigation
集客麦麦@谢坤
首页
随笔
首页
>>
创作中心
>>
BroadcastC...
BroadcastChannel + 发布订阅模式:打造轻量可扩展的跨窗口通信系统
在复杂的 Web 应用中,多窗口协同工作越来越常见——从多屏数据看板到协同编辑,从弹窗管理到消息同步,都需要不同浏览器上下文之间能够高效、可靠地通信。今天我们就来聊聊如何利用 **BroadcastChannel API** 与**发布订阅模式**,打造一个轻量、解耦、可扩展的跨窗口通信系统。 ## 一、BroadcastChannel:浏览器原生的一对多广播 BroadcastChannel 是浏览器提供的一个简单而强大的 API,它允许**同源**的不同浏览上下文(窗口、标签页、iframe、Web Worker)之间进行**一对多**的消息广播。每个频道就像一个“聊天室”,所有加入该频道的页面都能收到任意一方发送的消息。 ### 核心特性 - **同源限制**:只有协议、域名、端口完全相同的页面才能互通。 - **自动分发**:浏览器负责将消息分发给所有订阅了该频道的上下文,无需手动维护连接列表。 - **双向通信**:任何一方都可以发送消息,所有其他方都能接收。 - **简单 API**:只需 `new BroadcastChannel(name)` 创建频道,通过 `postMessage` 发送,通过 `onmessage` 接收。 ### 基本用法 ```javascript // 创建频道 const channel = new BroadcastChannel('my_channel'); // 发送消息 channel.postMessage({ type: 'GREETING', payload: 'Hello' }); // 接收消息 channel.onmessage = (event) => { console.log(event.data); }; // 关闭频道 channel.close(); ``` ### 适用场景 - **多窗口状态同步**:如多个仪表盘同时显示相同数据,数据变化时同步更新。 - **跨标签页登录状态**:一个标签页登出,其他标签页自动登出。 - **弹窗与父页面通信**:弹出窗口与主页面通过广播交换数据。 - **协同编辑**:多个用户在同一文档的不同窗口中协作。 ## 二、发布订阅模式:解耦的事件总线 发布订阅模式(Pub/Sub)是一种消息范式,它将消息的**发送者**(发布者)与**接收者**(订阅者)解耦,通过一个“事件总线”来管理所有事件。在 JavaScript 中,我们可以用简单的 Mitt 或自己实现一个迷你事件总线。 ### 核心要素 - **订阅(on)**:将回调函数注册到某个事件名称上。 - **发布(emit)**:触发某个事件,执行所有注册的回调。 - **取消订阅(off)**:移除特定事件上的回调。 ### 手写一个迷你发布订阅 ```javascript class EventBus { constructor() { this.events = {}; } on(event, handler) { if (!this.events[event]) this.events[event] = []; this.events[event].push(handler); } emit(event, ...args) { (this.events[event] || []).forEach(handler => handler(...args)); } off(event, handler) { if (!this.events[event]) return; this.events[event] = this.events[event].filter(h => h !== handler); } } ``` ### 优势 - **解耦**:发布者和订阅者不需要知道彼此的存在。 - **灵活性**:可以动态添加、移除监听器。 - **可扩展**:很容易增加新的事件类型而不影响现有代码。 ## 三、强强联合:BroadcastChannel + 发布订阅 单个 BroadcastChannel 只能接收原始消息,如果我们在每个组件中直接处理 `channel.onmessage`,代码会变得难以维护。更优雅的做法是:**将 BroadcastChannel 作为底层传输层,在其上构建一个发布订阅系统**,让应用层只关心自己订阅的事件,完全屏蔽通信细节。 ### 架构图 ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Window A │ │ Window B │ │ Window C │ │ EventBus │ │ EventBus │ │ EventBus │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ └────────────────────┼────────────────────┘ │ ┌───────▼───────┐ │BroadcastChannel│ │ 'my_app' │ └───────────────┘ ``` - 每个窗口内部维护一个**本地事件总线**(发布订阅)。 - 当本地事件需要跨窗口广播时,通过 BroadcastChannel 发送给其他窗口。 - 接收到广播消息后,再将其转为本地事件,触发相应监听器。 ### 实战:构建跨窗口通信服务 下面我们实现一个 `CrossWindowBus` 类,它封装了 BroadcastChannel 和发布订阅,提供统一的 API。 ```javascript // cross-window-bus.js class CrossWindowBus { constructor(channelName) { this.channel = new BroadcastChannel(channelName); this.localBus = new EventBus(); // 本地事件总线 this._setupListener(); } // 监听来自其他窗口的消息,并转为本地事件 _setupListener() { this.channel.onmessage = (event) => { const { eventType, data } = event.data; // 将广播消息触发到本地事件总线 this.localBus.emit(eventType, data); }; } // 订阅本地事件 on(eventType, handler) { this.localBus.on(eventType, handler); } // 取消订阅 off(eventType, handler) { this.localBus.off(eventType, handler); } // 发布事件:如果指定跨窗口,则广播到其他窗口 emit(eventType, data, broadcast = true) { // 先触发本地监听器 this.localBus.emit(eventType, data); // 如果需要广播,则发送给其他窗口 if (broadcast) { this.channel.postMessage({ eventType, data }); } } // 销毁频道 destroy() { this.channel.close(); } } ``` ### 使用示例 ```javascript // 在父窗口 const bus = new CrossWindowBus('dashboard'); // 订阅数据更新事件 bus.on('dataUpdate', (newData) => { console.log('收到数据更新', newData); // 更新界面 }); // 发布数据更新(会触发本窗口和其他窗口的监听器) bus.emit('dataUpdate', { temperature: 23.5 }); ``` ```javascript // 在子窗口(由父窗口打开) const bus = new CrossWindowBus('dashboard'); bus.on('dataUpdate', (data) => { console.log('子窗口收到数据', data); }); ``` ### 高级功能扩展 #### 1. 区分本地和远程触发 有时我们想只广播而不触发本地的重复处理,可以增加一个标志: ```javascript emit(eventType, data, options = { broadcast: true, local: true }) { if (options.local) this.localBus.emit(eventType, data); if (options.broadcast) this.channel.postMessage({ eventType, data }); } ``` #### 2. 窗口生命周期管理 我们可以为每个窗口分配一个唯一 ID,并在窗口关闭时发送“离线”消息,让其他窗口知道它的状态变化。类似原题中的“上线/下线”机制。 ```javascript class ManagedWindowBus extends CrossWindowBus { constructor(channelName, windowId) { super(channelName); this.windowId = windowId; this._announcePresence(); window.addEventListener('beforeunload', () => { this.emit('windowOffline', { id: this.windowId }, { broadcast: true, local: false }); }); } _announcePresence() { this.emit('windowOnline', { id: this.windowId }, { broadcast: true, local: false }); } } ``` #### 3. 消息序列化与安全 BroadcastChannel 会自动序列化结构化数据,但要注意不要传递不可序列化的对象(如函数、DOM 元素)。对于敏感数据,建议在发送前加密。 ## 四、实战案例:多屏协同数据看板 假设我们要实现一个数据监控面板,允许用户打开多个浏览器窗口,每个窗口显示相同的实时数据,并且当某个窗口修改数据时,其他窗口自动同步。 ### 步骤 1. **建立共享频道**:所有窗口都创建名为 `'monitor'` 的 BroadcastChannel。 2. **窗口上线广播**:每个窗口打开时,发送 `WINDOW_ONLINE` 消息,告知其他窗口自己的存在。 3. **维护在线窗口列表**:某个“主窗口”负责维护在线列表(可通过 sessionStorage 或由第一个窗口担当)。 4. **数据变更广播**:任意窗口修改数据时,发送 `DATA_CHANGE` 消息,携带新的数据。 5. **其他窗口更新**:收到 `DATA_CHANGE` 后,更新自己的 UI。 ### 关键代码 ```javascript // monitor-bus.js class MonitorBus { constructor() { this.channel = new BroadcastChannel('monitor'); this.localBus = new EventBus(); this.onlineWindows = new Map(); this._setup(); } _setup() { this.channel.onmessage = (e) => { const { type, payload } = e.data; switch (type) { case 'WINDOW_ONLINE': this.onlineWindows.set(payload.id, payload); this.localBus.emit('windowOnline', payload); break; case 'WINDOW_OFFLINE': this.onlineWindows.delete(payload.id); this.localBus.emit('windowOffline', payload); break; case 'DATA_CHANGE': this.localBus.emit('dataChange', payload); break; } }; // 广播自身上线 this.id = Date.now() + '_' + Math.random(); this.channel.postMessage({ type: 'WINDOW_ONLINE', payload: { id: this.id } }); window.addEventListener('beforeunload', () => { this.channel.postMessage({ type: 'WINDOW_OFFLINE', payload: { id: this.id } }); }); } onDataChange(handler) { this.localBus.on('dataChange', handler); } updateData(newData) { // 本地触发(可选)并广播 this.localBus.emit('dataChange', newData); this.channel.postMessage({ type: 'DATA_CHANGE', payload: newData }); } } ``` ### 在页面中使用 ```html
0
+1
``` 当用户打开多个该页面时,任意一个窗口点击 +1,所有窗口都会同步更新数值。 ## 五、注意事项与最佳实践 ### 1. 同源限制 BroadcastChannel 要求所有通信页面**同源**,子域名也不行。如果需要跨域通信,可以考虑 `postMessage` + iframe 代理。 ### 2. 生命周期管理 确保在窗口关闭时调用 `channel.close()` 释放资源,避免内存泄漏。同时,最好发送一个“下线”消息,让其他窗口清理对该窗口的引用。 ### 3. 消息体积 BroadcastChannel 对消息大小没有硬性限制,但传递大量数据会影响性能。对于大数据,可以考虑通过 IndexedDB 或共享 Worker 中转,只传递引用。 ### 4. 避免循环广播 当多个窗口同时更新数据时,容易形成广播风暴。可以通过添加消息来源标识,让窗口忽略自己发出的消息。 ```javascript // 发送时带上 senderId channel.postMessage({ type: 'DATA', data, senderId: myId }); // 接收时判断 if (payload.senderId === myId) return; ``` ### 5. 备用方案 BroadcastChannel 的兼容性很好(除 IE 外),但若需要支持老浏览器,可以回退到 `localStorage` 事件或 `postMessage` + 弹窗方式。 ## 六、总结 通过将 BroadcastChannel 与发布订阅模式结合,我们获得了: - **简洁的跨窗口通信**:利用原生 API,无需维护复杂连接。 - **解耦的架构**:应用层只需监听事件,不关心消息来自本地还是远程。 - **易于扩展**:可以轻松增加新的事件类型、窗口管理功能。 这种组合模式非常适合需要多窗口协同的应用场景,如多屏看板、协同工具、后台管理系统的多标签页同步等。希望本文能帮助你更好地理解 BroadcastChannel 和发布订阅,并在实际项目中灵活运用。 --- **参考资料** - [MDN - BroadcastChannel](https://developer.mozilla.org/zh-CN/docs/Web/API/BroadcastChannel) - [Mitt - 极简的事件发射器](https://github.com/developit/mitt)