
1. 先聊聊场景为什么非要跨标签页通信做前端这么多年我印象里第一个让我觉得这问题必须得解决的场景就是用户在多标签页下的登录态同步。你想想这个画面用户开着A、B两个标签页都在用你的系统A标签页里点了退出登录后端session销毁了token也清掉了但B标签页这时候还浑然不知用户切到B页面继续操作一顿操作猛如虎提交的时候后端甩回来一个401用户当场懵了。更麻烦的是有些单页应用对401的处理是跳回登录页如果B标签页刚好有未保存的表单这一跳数据全没了。这还不算最糟糕的。有些团队为了让用户无感把token存到localStorage里多个标签页共享同一份存储。用户在一个标签页退出登录把localStorage清了但其他标签页的JS内存里还留着用户信息和权限数据页面看起来一切正常实际上一提交就报错。这种表面登录、实际已退出的状态比直接跳登录页更坑因为用户根本不知道发生了什么。所以问题的本质是单标签页内的状态管理已经解决得很好了但多标签页之间的状态同步一直是个容易被忽略的盲区。broadcastTools.js这个工具做的就是这件事——在同一个浏览器内让不同标签页之间能够实时通信借助它来监听用户退出登录这个全局事件让所有标签页一起收手保住数据和用户体验。这里要先澄清一个概念。跨浏览器标签页通信里的跨浏览器容易让人误以为是Chrome和Firefox之间通信其实不是。它指的是跨过单个标签页的边界在同一浏览器内的多个标签页tab、窗口window、甚至iframe之间传递消息。因为每个标签页本质上是独立的JS运行环境内存互不相通只能靠浏览器提供的特定API来做越界通信。broadcastTools.js就是把这些API封装成一套好用的工具让业务侧不需要关心底层差异。这个工具适合谁来用前端开发、全栈工程师尤其是做后台管理系统、SaaS平台、多开标签页操作频繁的web应用的团队。哪怕你不是写框架的只要项目里有多标签页场景这套思路都值得参考——它可以帮你避免一大类用户状态不一致的线上事故。2. 跨标签页通信的主流方案我为什么最终选了这条技术路线在动手写broadcastTools.js之前我先把市面上能用的跨标签页通信方案过了一遍。各有各的适用场景不能说谁绝对好关键看你要解决什么问题。2.1 四种主流方案的特点对比方案通信方向兼容性是否需要同源典型场景localStorage storage事件广播所有同源标签页可收非常好IE8是轻量消息、状态同步BroadcastChannel API广播所有同源标签页可收现代浏览器可用是通用消息传递SharedWorker广播可持久化连接中老年浏览器支持一般是复杂状态共享、长连接postMessage 窗口引用点对点很好可跨域父子窗口、弹窗间通信localStorage方案大家应该不陌生。它的原理是当一个标签页修改localStorage时浏览器会把变化广播给所有同源的其他标签页其他标签页通过监听storage事件拿到变更的key和value。这个方案的优点是非常稳兼容性几乎拉满缺点也明显——它只能传字符串传不了复杂对象而且Web Storage的存储空间就5MB左右你不可能拿它传大流量数据。BroadcastChannel是我个人更偏爱的方案。它是浏览器专门为同源上下文之间的通信设计的API语义更清晰能直接传结构化克隆的对象性能也比storage事件好一个量级。Chrome 54、Firefox 38、Safari 15.4都支持现代项目基本可以放心用。SharedWorker和postMessage我这次没有采用。SharedWorker需要单独维护worker生命周期调试起来麻烦对于退出登录这种低频事件有点杀鸡用牛刀postMessage虽然灵活但需要先拿到目标窗口的引用适合已知的父子窗口关系不适合全页面广播这种场景。2.2 为什么选 BroadcastChannel 做主力、localStorage 做兜底我的设计思路是双通道打通默认走BroadcastChannel遇到不支持的旧浏览器自动降级到localStorage方案这样既保证了现代浏览器下的体验又不放弃旧环境。这里有个关键点很多人容易忽略BroadcastChannel和localStorage虽然都能广播消息但它们的机制完全不同。BroadcastChannel是临时连接消息发出去没有标签页在监听消息就丢了localStorage则是持久化存储写入就一直存在晚来的标签页也能读到。所以两者在语义上有微妙差别封装的时候一定要区分清楚BroadcastChannel适合发通知类消息localStorage适合发状态类数据。broadcastTools.js的定位是通用消息总线所以默认用BroadcastChannel把连接管理、消息序列化、订阅分发都做在里头。同时保留一个localStorage通道用于兼容旧浏览器以及需要初始化时读取当前是否已是退出状态这种场景。2.3 设计目标它要解决的不只是退出登录虽然这次的标题聚焦在监听用户退出登录但我在设计broadcastTools.js时没有只做一个硬编码的logout事件。原因很简单一旦你把多标签页通信的基础能力做好了能同步的就不只是登录态了。比如你的系统里可能有这些需要全局同步的场景用户在一个标签页修改了昵称其他标签页的头像昵称要跟着变后台管理系统的角色权限被调整后其他标签页需要刷新菜单用户在一个标签页上传了文件另一个标签页的上传列表要实时更新系统通知、消息红点这类全局UI状态需要多标签页保持一致。所以broadcastTools.js的定位是一个轻量级的同源跨标签页消息总线退出登录只是它最典型、最刚需的一个应用场景。工具本身提供的是通用的发送消息、监听消息、管理连接能力具体业务事件交给使用方按需扩展。3. broadcastTools.js 核心实现从骨架到完整代码这章我会把工具的核心实现一段段拆开讲最后给出一份可以直接复制用的完整代码。别担心看不懂我在关键位置都加了注释和设计说明。先看整体结构一个典型的消息总线要有连接管理、消息序列化、事件订阅分发三个核心模块再叠加工具类的静态方法约束保证全局只有一份实例。3.1 骨架与连接管理BroadcastChannel的使用非常简单new一个实例指定一个频道名然后postMessage发消息、onmessage收消息。但直接裸用有几个问题业务代码耦合了通信细节无法做到只发消息不关心接收方的解耦全局需要维护唯一个连接避免重复创建。所以broadcastTools.js用一个单例模式管理底层的BroadcastChannel实例对外暴露统一的方法。连接建立的逻辑放在init中class BroadcastChannelService { constructor() { this.channel null; this.eventHandlers new Map(); // 事件名 - Set(回调函数) this.useLocalStorageFallback false; this._storageEventHandler null; } init(channelName broadcast_tools_channel) { if (this.channel) { console.warn([broadcastTools] 重复初始化直接复用已有连接); return; } if (BroadcastChannel in window) { this.channel new BroadcastChannel(channelName); this.channel.onmessage (event) this._dispatch(event.data); this.useLocalStorageFallback false; } else { // 旧浏览器降级使用 localStorage storage 事件 this.useLocalStorageFallback true; this._storageEventHandler (event) { const { key, newValue } event; if (key key.startsWith(this._getStoragePrefix())) { try { const data JSON.parse(newValue); this._dispatch(data); } catch (e) { console.warn([broadcastTools] 解析 storage 消息失败, e); } } }; window.addEventListener(storage, this._storageEventHandler); } // 向外暴露实例 broadcastTools.instance this; } _getStoragePrefix() { return broadcast_tools_; } }这段代码里有几个值得留意的细节。一是_dispatch方法它是整个消息分发的中枢后面会细讲。二是降级方案里关键不是监听storage事件本身而是要约定一个消息前缀防止把业务里其他localStorage的变化误当成通信消息。这里我用broadcast_tools_作为前缀相当于给消息划了一块专用区域。还有一点BroadcastChannel的onmessage收到的event.data可以是任意结构因为浏览器内部会做结构化克隆。这意味着你可以传对象、数组、甚至嵌套的复杂数据结构。降级方案里就不行了localStorage只能存字符串所以必须用JSON.stringify序列化、JSON.parse反序列化这就是我在降级分支里做try/catch的原因。3.2 消息格式与序列化消息总线的核心是消息格式约定。我定义了一个统一的消息协议// 消息格式 { // 事件类型如 user_logout type: user_logout, // 时间戳毫秒 timestamp: 1691234567890, // 发送方标识用于避免自己收自己的消息部分场景需要 originId: tab_abcdef123456, // 业务数据载荷 payload: { userId: 1024, reason: manual_logout } }type是必填的用于路由到对应的回调函数。timestamp用来标识消息产生的时刻某些场景下需要判断消息的时效性。originId是发送方标签页的唯一标识在多标签页调试时可以准确追踪某个消息从哪个页面来。payload就是业务数据了可以是空对象。这个协议看着简单但定好了它能解决很多后续麻烦事。比如我在调试时遇到过一个场景一个改动不能在自己当前标签页生效否则会重复执行。通过判断originId就能很轻松地过滤掉自己发出的消息。这属于用多了才会发现的细节。3.3 发布订阅与消息分发消息总线对外暴露的核心方法就四个on订阅、off取消订阅、emit发送、close关闭。发布订阅模式用Map来管理事件名到回调集合的映射// 订阅事件 on(eventType, callback) { if (!this.eventHandlers.has(eventType)) { this.eventHandlers.set(eventType, new Set()); } this.eventHandlers.get(eventType).add(callback); return () this.off(eventType, callback); } // 取消订阅 off(eventType, callback) { const handlerSet this.eventHandlers.get(eventType); if (handlerSet) { handlerSet.delete(callback); } } // 发送消息 emit(type, payload {}) { const message { type, timestamp: Date.now(), originId: this.getTabId(), payload }; if (this.channel !this.useLocalStorageFallback) { this.channel.postMessage(message); } else if (this.useLocalStorageFallback) { const bodyKey this._getStoragePrefix() type; localStorage.setItem(bodyKey, JSON.stringify(message)); // 主动触发本地分发因为当前标签页不会收到自己的 storage 事件 this._dispatch(message); } } // 内部消息路由 _dispatch(message) { if (!message || typeof message.type ! string) return; const handlerSet this.eventHandlers.get(message.type); if (handlerSet) { handlerSet.forEach((callback) { try { callback(message.payload, { originId: message.originId, timestamp: message.timestamp, type: message.type }); } catch (error) { console.error([broadcastTools] 事件处理回调异常, error); } }); } }发布订阅模式的好处不用多说发送方和接收方完全解耦发消息的不需要知道谁在听监听的不需要知道谁发的。这对跨标签页场景特别友好因为不同标签页的代码执行顺序、加载时机都不确定解耦以后就不会有那边消息发了这边还没注册好的问题。on方法我特意返回了一个unsubscribe函数这在React、Vue等框架的组件销毁阶段非常实用。比如在Vue组件的onUnmounted里直接调用返回的函数就能清理监听不用先存引用再off。关于降级方案有一段代码我做了注释localStorage.setItem之后当前标签页并不会收到storage事件。因为storage事件只在其他同源页面修改localStorage时触发自己修改自己是不触发的。但业务上往往需要本页发起所有页面包括自己都感知到的效果所以我在降级分支里写完后主动调用了一次_dispatch把消息在本地也分发一遍。这个细节如果忘了处理降级模式下就会出现点退出登录自己都没反应的诡异问题。3.4 完整工具类代码再补上标签页唯一ID的生成和关闭逻辑一个完整的broadcastTools.js就出来了。把前面几段拼起来再加上一点工具函数// broadcastTools.js // 跨浏览器标签页通信工具基于 BroadcastChannel localStorage 降级 (function (global) { const STORAGE_PREFIX broadcast_tools_; let instance null; class BroadcastTools { constructor() { this.channel null; this.eventHandlers new Map(); this.useLocalStorageFallback false; this._storageEventHandler null; this._tabId this._generateTabId(); } // 初始化建立跨标签页通信通道 init(channelName broadcast_tools_channel) { if (this.channel || this.useLocalStorageFallback) { return; } if (BroadcastChannel in window) { this.channel new BroadcastChannel(channelName); this.channel.onmessage (e) this._dispatch(e.data); } else { this.useLocalStorageFallback true; this._storageEventHandler (e) { const { key, newValue } e; if (key key.startsWith(STORAGE_PREFIX)) { try { this._dispatch(JSON.parse(newValue)); } catch (err) { console.warn([broadcastTools] storage 消息解析失败, err); } } }; window.addEventListener(storage, this._storageEventHandler); } } // 订阅事件返回取消订阅函数 on(eventType, callback) { if (!this.eventHandlers.has(eventType)) { this.eventHandlers.set(eventType, new Set()); } this.eventHandlers.get(eventType).add(callback); return () this.off(eventType, callback); } // 取消订阅 off(eventType, callback) { const handlerSet this.eventHandlers.get(eventType); if (handlerSet) { handlerSet.delete(callback); } } // 发送事件 emit(type, payload {}) { const message { type, timestamp: Date.now(), originId: this._tabId, payload }; if (this.channel) { this.channel.postMessage(message); } else if (this.useLocalStorageFallback) { localStorage.setItem(STORAGE_PREFIX type, JSON.stringify(message)); this._dispatch(message); } } // 关闭连接清理监听 close() { if (this.channel) { this.channel.close(); this.channel null; } if (this._storageEventHandler) { window.removeEventListener(storage, this._storageEventHandler); this._storageEventHandler null; } this.eventHandlers.clear(); } // 获取当前标签页的唯一ID getTabId() { return this._tabId; } _dispatch(message) { if (!message || typeof message.type ! string) return; const handlers this.eventHandlers.get(message.type); if (!handlers) return; handlers.forEach((cb) { try { cb(message.payload, { originId: message.originId, timestamp: message.timestamp, type: message.type }); } catch (e) { console.error([broadcastTools] 事件处理回调异常, e); } }); } _generateTabId() { if (window.crypto typeof window.crypto.randomUUID function) { return tab_ window.crypto.randomUUID(); } return tab_ Date.now().toString(36) _ Math.random().toString(36).slice(2, 10); } } global.broadcastTools { getInstance() { if (!instance) { instance new BroadcastTools(); } return instance; } }; })(window);这个文件的核心价值不在代码量而在于它把底层差异完全封住了。业务代码只需要记住四个方法init()初始化、on()监听、emit()发送、close()清理。至于底层走的是BroadcastChannel还是localStorage根本不用关心。getTabId用crypto.randomUUID生成唯一ID这个方法在现代浏览器很好用旧浏览器降级用时间戳加随机数拼接碰撞概率也足够低。其实在这个场景里ID的绝对唯一性没那么重要它更多是用来做调试追踪和避免自己收自己的消息。4. 实战用 broadcastTools.js 监听用户退出登录工具准备好了现在来看最关键的场景落地。这一章我会把从初始化、改造退出登录方法、到其他标签页收到消息后如何处理完整走一遍。4.1 在应用入口初始化工具项目里我用的是Vue 3 Pinia但这个流程换到React、或原生JS都一样的。第一步是在应用启动时初始化broadcastTools// main.js 或入口文件 import { broadcastTools } from /utils/broadcastTools; const bus broadcastTools.getInstance(); bus.init(my_app_channel);建议把频道名放成一个常量多个模块引用同一个名称避免拼写不一致导致连不上。4.2 改造退出登录方法之前的退出登录逻辑一般是这样的调后端接口成功后清token、清用户信息、跳登录页。现在要加一步通过broadcastTools广播退出登录事件。// 用户管理模块 store/user.js 或 auth.js async function logout() { try { // 1. 调用后端退出接口销毁服务端 session await request.post(/api/auth/logout); // 2. 清理本地登录凭证 localStorage.removeItem(access_token); localStorage.removeItem(refresh_token); // 3. 通过 broadcastTools 通知所有其他标签页 bus.emit(user_logout, { userId: currentUser.id, reason: manual_logout, logoutAt: Date.now() }); // 4. 本标签页跳转登录页 router.replace(/login); } catch (error) { // 即使接口失败本地也要清理防止状态残留 // 但不要盲目清理避免把另一个标签页的登录态误伤 console.error(退出登录调用失败, error); } }这个改造看起来只是加了一行emit但背后有几个设计选择值得说明。第一要在跳转登录页之前emit。因为一旦跳到登录页当前页面组件可能被卸载事件定时的数据可能就丢了而且逻辑上通知其他页面是退出动作的一部分应该在状态切换前完成。第二payload里带上userId和logoutAt。这是给接收方用的。比如你的系统支持多账号在同一浏览器不同标签页登录有些场景不允许但行政后台偶尔会出现接收方可以根据userId判断退出的账号是不是我这边的账号避免误伤其他账号的标签页。第三接口失败时不要盲目清理本地状态。这里有个容易踩的坑如果后端接口因为网络问题超时了但你又怕状态残留把本地cookie和localStorage全清了那其他标签页的用户直接在不知情的情况下被踢下线。更合理的处理是接口失败时弹提示、保留状态除非你的安全策略要求只要发起退出就必须清。4.3 其他标签页收到消息后的处理这是整个场景的核心。在其他标签页的入口文件或较高级别的组件里提前注册监听// App.vue 或布局组件二选一放这里就行 import { broadcastTools } from /utils/broadcastTools; import { useUserStore } from /stores/user; import { useRouter } from vue-router; export default { setup() { const userStore useUserStore(); const router useRouter(); const bus broadcastTools.getInstance(); bus.init(my_app_channel); // 监听退出登录事件 const unsub bus.on(user_logout, (payload, meta) { console.log([broadcastTools] 收到退出登录事件, payload, 来自, meta.originId); // 判断是否是当前标签页自己发出的消息 // 如果是自己发出的说明当前页面已经走完退出流程不用重复处理 if (meta.originId bus.getTabId()) { return; } // 1. 清理本地状态 localStorage.removeItem(access_token); localStorage.removeItem(refresh_token); userStore.reset(); // 2. 如果当前不在登录页并且存在未保存的编辑数据先给用户一个提示 if (router.currentRoute.value.name ! login) { const hasUnsavedDraft checkUnsavedDraft(); if (hasUnsavedDraft) { Modal.warning({ title: 账号已在其他窗口退出登录, content: 你当前的编辑内容尚未保存是否保留, onOk: () { saveDraftToLocal(); router.replace(/login); }, onCancel: () { router.replace(/login); } }); } else { router.replace(/login); } } }); // 组件销毁时自动取消订阅 onUnmounted(unsub); } };这段代码里有几个关键点。为什么要判断originId因为bus.emit不是只发给别人而是广播给所有同源标签页包括自己。如果你的BroadcastChannel实现里没有做排除自己的过滤当前标签页也会收到自己发出的user_logout。如果不判断当前页面就会走两次退出逻辑虽然大部分情况下幂等但会产生多余的跳转或弹窗。处理未保存数据的细节我见过很多方案一收到退出消息就立刻跳登录页完全不考虑用户有没有正在编辑的内容。但实际体验中这种强制踢出会让用户非常恼火尤其是他正在填一个复杂表单。所以我在跳转前检查了一遍是否有未保存草稿有的话先提醒用户给出保存后退出或直接放弃的选择。把草稿存到localStorage再退出如果用户选择保留草稿我先把内容写到localStorage里的草稿区等他重新登录后在表单初始化时读回来。这个设计在实际项目里用户反馈非常好减少了很多为什么我刚才填的东西没了的工单。4.4 多账号场景下的精确处理刚才提到payload里带了userId这里展开说说。如果你的应用允许同浏览器下多标签页分别登录不同账号比如一个运营后台员工可能同时管多个店铺的后台那退出事件不能一波带走所有标签页得按userId精准处理const unsub bus.on(user_logout, (payload, meta) { if (meta.originId bus.getTabId()) { return; } const currentUserId userStore.user?.id; // 只处理同一个账号的标签页 if (payload.userId currentUserId payload.userId ! currentUserId) { console.log([broadcastTools] 忽略非当前账号的退出事件); return; } // 执行退出逻辑... });这种场景下originId和userId两个字段配合使用能非常精准地锁定谁退了、哪个标签页该响应、哪个不该响应。这也是为什么我坚持在消息协议里同时带上这两个信息。5. 常见问题与排查技巧实录这部分是我在实际使用中积攒下来的经验选几个最典型的写在这里基本覆盖了踩坑的高发区。5.1 常见问题速查表问题现象可能原因排查与解决方法另一标签页收不到消息BroadcastChannel因跨域或未初始化确认两个页面完全同源协议、域名、端口一致初始化时打印bus.channel是否为空降级模式下当前页不响应localStorage.setItem不触发自身storage事件检查emit降级分支是否主动_dispatch了消息收到消息但回调不执行事件名不一致或订阅晚于事件发送检查type拼写工具类里加console.log追踪所有收到的raw消息组件销毁后仍收到消息忘记调用off或onUnmounted清理利用on()返回的取消函数在销毁生命周期中调用自己发出的消息被自己处理未判断originId在回调开头比较meta.originId bus.getTabId()localStorage被意外写满降级模式下每条消息都setItem用完后可从storage中删除对应key或仅保存最近N条消息多标签页同时退出出现竞态多个页面同时emit利用originId去重幂等处理退出逻辑5.2 兼容性细节与降级策略BroadcastChannel在Safari 15.4之前是不支持的如果你是面向苹果用户的移动端H5或后台系统这一点必须提前评估。降级到localStorage方案虽能用但有一个天然缺陷——storage事件的触发有微小的异步延迟而且不同浏览器对storage事件触发的时机略有差异。在极端情况下用户清空浏览器数据的瞬间storage事件可能来不及触发其他标签页就收不到退出消息了。所以如果你是金融、医疗等对安全性要求极高的系统我建议别只依赖消息通知还得做一层兜底每次请求时检查token有效性后端返回401就强制清当前页面的登录态。也就是说跨标签页通信是优化体验的手段后端鉴权才是最后的防线。另外还要注意浏览器隐私模式。Chrome无痕模式下localStorage在某些策略下可能不可用写操作会抛异常。我在emit的降级分支里建议加一层try/catch哪怕存储失败也不影响主流程} else if (this.useLocalStorageFallback) { try { localStorage.setItem(STORAGE_PREFIX type, JSON.stringify(message)); } catch (e) { console.warn([broadcastTools] localStorage 写入失败, e); } this._dispatch(message); }5.3 排查技巧如何在多标签页环境里看清消息流跨标签页通信一旦出问题最大的困难是看不见。单个页面里的console还能开DevTools看多页面之间的消息流你根本不知道发没发出去、另一个页面收没收到。我的排查习惯是这三个步骤。第一步给消息加日志。在emit和_dispatch里各打一条日志格式里带上事件类型、originId、timestamp。日志加上以后打开两个标签页的DevTools能很清楚地看到页面A发出了user_logout页面B收到了user_logout问题在哪一眼就能定位。第二步查看浏览器自带的BroadcastChannel调试面板。Chrome DevTools的Application面板里展开Storage下的Local Storage能看到localStorage通道写的消息在Sources面板或console里输入new BroadcastChannel(my_app_channel)手动连一下也可以验证频道是否存在。第三步做一个回声测试。在页面console里手动执行const bus broadcastTools.getInstance(); bus.on(__ping__, (payload, meta) { console.log(收到ping来源, meta.originId); }); bus.emit(__ping__, { from: 论坛求助专用 });如果其他标签页能打印出日志说明通信链路是通的问题大概率在业务代码如果连日志都没有那就是链路本身断掉了回到兼容性那一节继续排查。5.4 一个我踩过的真实坑Vue组件里重复订阅最开始我在多个组件里都注册了bus.on(user_logout, ...)结果用户退出后同一个标签页里回调被触发了三五次跳转逻辑执行了多遍页面上弹了好几个提示框。原因很简单每个组件实例都向eventHandlers里加了一个回调组件没销毁监听就一直积压。解决办法有两个方向。一是把监听尽量集中到顶层组件比如App.vue或根布局业务组件里不要散落监听二是如果实在需要在多个组件监听必须保证组件的销毁生命周期里调用了返回的取消订阅函数。我自己比较推荐第一种集中管理减少心智负担排查起来也简单。6. 工具还能怎么扩展最后聊一点扩展思路。broadcastTools.js虽然是为退出登录这个场景设计的但它的内核是通用消息总线稍微扩展一下能解决很多其他问题。根据我的经验以下几个方向在真实项目中很容易用上。方向一扩展成全局状态同步器。监听的不再是事件而是某个key对应的状态变化。比如用户在任意标签页更新了主题色其他标签页自动跟随。实现思路是统一封装setGlobalState(key, value)和useGlobalState(key)两个API内部走emit事件再在接收端更新一份共享状态对象。方向二加一个主动拉取能力。目前的工具是实时通知型但有一种场景需要迟来者补课——比如一个新标签页刚打开它需要知道用户是不是已经在别的标签页退出了。这时可以让新页面发一个__sync_request__事件其他在线标签页收到后把自己的状态广播回来新页面做个合并恢复到一致状态。方向三和service worker配合做离线状态同步。如果项目用了service workerBroadcastChannel可以和它互通把登录态变化推送到离线缓存层做到更细粒度的数据一致性控制。这些扩展思路的共同点是底层通信能力只需要一份基础代码变化的是业务事件的语义和数据格式。所以工具层的价值是自来水管道真正让水流动起来的是业务侧怎么定义事件、怎么处理事件。我个人的实际体会是跨标签页通信这个能力属于平时不起眼用到才喊香的那种工具。把退出登录这种高频、高危的场景先做扎实后续其他全局状态同步的需求都是在同一套管道上挂新的事件而已。保持工具层的简洁稳定业务层的扩展才会顺手。