1. 从“聊天鬼打墙”到“全局状态同步”的痛点
你有没有遇到过这样的场景?在同一个网站里,你打开了两个标签页,一个在后台挂着,一个在前台操作。你在前台标签页的聊天窗口里,和同事热火朝天地讨论一个技术方案,然后切到后台标签页,想看看刚才提到的某个文档。结果,你发现后台标签页的聊天窗口,还停留在你切走之前的状态,新消息一条都没收到。等你切回前台,可能已经错过了好几条关键信息,或者更糟——你在两个标签页里,对着不同步的消息历史,发出了完全矛盾的回复。这就是典型的“多Tab聊天鬼打墙”,用户体验割裂,甚至可能引发沟通事故。
这个问题,本质上是一个前端状态同步问题。在单页应用(SPA)时代,我们习惯了应用内的状态管理(如Vuex, Redux),但这些状态是“标签页私有”的。浏览器出于安全考虑,天然地将不同标签页(即使是同源)视为独立的执行环境。这就意味着,你在Tab A里通过WebSocket收到的新消息,不会自动出现在Tab B的聊天记录里;你在Tab A里标记的“已读”状态,Tab B也一无所知。
因此,要实现“多Tab聊天不翻车”,核心目标就是构建一个跨标签页的实时状态同步机制。这不仅仅是聊天场景的需求,任何需要保持多视图状态一致的应用都会遇到,比如:后台管理系统的全局通知、在线文档的协同编辑提示、电商网站的购物车同步等。
今天,我们就来深入拆解一个我称之为“三层协同架构”的纯前端解决方案。它不依赖后端广播特定事件,而是利用浏览器提供的原生能力,在客户端层面优雅地解决跨Tab通信与状态同步问题。这个架构清晰、可靠,并且拥有极佳的降级与容错能力。
2. 架构核心:理解“存储、通信、协调”三层分工
为什么叫“三层协同架构”?因为它将复杂的跨Tab同步问题,分解为三个职责清晰、相互协作的层次。每一层解决一个子问题,三层叠加,共同构建出健壮的同步能力。理解这个分层思想,比直接看代码更重要。
第一层:可信源存储层这是整个架构的“单一数据源”。所有标签页需要共享的状态,都必须存储在这里。它的核心职责是提供持久化、跨Tab可访问的存储能力。我们通常会选择localStorage或sessionStorage作为实现载体。为什么是它们?因为同源下的所有标签页都能读写同一个Storage对象,任何修改都会触发storage事件(注意:触发事件的标签页除外)。这为我们提供了最基础的“数据变更广播”机制。这一层只负责存和取,不关心业务逻辑。
第二层:消息通信层存储层的storage事件虽然能通知变更,但信息比较原始。消息通信层的作用是封装与路由。它监听存储层的事件,将其转化为内部统一的、带有业务语义的消息(例如:{type: ‘NEW_MESSAGE‘, payload: {...}}),并负责将消息分发给当前标签页内所有关心它的模块。同时,当本标签页需要主动发起同步时(例如用户发送了新消息),也通过这一层将消息写入存储层,从而间接广播给其他标签页。这一层是中枢神经,负责消息的编码、解码和派发。
第三层:业务协调层这是最上层,直接面向具体功能。它订阅消息通信层发出的特定类型消息,并根据消息内容更新本地的UI状态(例如,将新消息追加到聊天列表)。同时,它也负责在合适的时机(如初始化、用户操作后)向通信层发出同步请求。这一层的逻辑是幂等的,即无论收到多少次相同的状态同步消息,最终UI状态都是一致的。它还需要处理潜在的冲突,例如两个标签页几乎同时修改了同一条消息的“已读”状态。
用一个简单的类比:可信源存储层就像公司的共享云盘(所有人可读写,修改有通知);消息通信层就像公司的内部通讯软件(把云盘文件变动通知转化成@某人的任务消息);业务协调层就是各个部门的员工(收到任务消息后,更新自己电脑上的工作文件)。
接下来,我们逐层深入,看看每一层的具体实现与核心细节。
3. 第一层实现:以LocalStorage为基石的可靠存储
我们选择localStorage作为可信源存储层的实现。相比sessionStorage,它的生命周期更长(关闭浏览器后依然存在),更适合需要持久化状态的聊天场景。这一层的设计,关键在于解决两个问题:存储结构和变更触发机制。
3.1 设计一个高效的状态存储结构
我们不能把整个应用的庞大状态树都塞进一个localStorage键值对里。每次微小的状态变更都会触发整个对象的写入和读取,效率低下,且storage事件携带的是整个新值,不利于差分处理。正确的做法是按状态域进行分片存储。
// 不好的做法:单一巨型状态 localStorage.setItem(‘appState‘, JSON.stringify(giantStateObject)); // 推荐做法:分片存储 const STATE_KEYS = { CHAT_MESSAGES: ‘chat_messages‘, UNREAD_COUNT: ‘unread_count‘, USER_PRESENCE: ‘user_presence‘, // 用户在线状态 }; // 存储时,按需更新特定片段 function updateStorageSlice(key, sliceData) { try { localStorage.setItem(key, JSON.stringify(sliceData)); } catch (e) { // 处理QuotaExceededError等异常 console.error(‘写入Storage失败:‘, e); // 触发降级策略,例如清理旧数据或提示用户 } }对于聊天消息,我们可能存储一个消息ID到消息对象的映射,或者一个有序的消息ID数组加上一个消息详情Map。这取决于你具体的查询和更新模式。
3.2 利用Storage Event实现跨标签页通知
这是本层的核心魔法。当localStorage或sessionStorage在其他标签页被修改时,当前标签页会接收到一个storage事件。
window.addEventListener(‘storage‘, (event) => { // event.key: 被修改的键名 // event.newValue: 修改后的新值(字符串,可能为null) // event.oldValue: 修改前的旧值(字符串,可能为null) // event.url: 触发修改的页面URL // event.storageArea: 被操作的storage对象(localStorage或sessionStorage) if (event.storageArea !== localStorage) return; // 只关心localStorage if (!event.key) return; // 某些清除操作可能key为null console.log(`检测到外部变更: Key="${event.key}", NewValue="${event.newValue}"`); });重要提示:
storage事件不会在触发修改的那个标签页本身被触发。这是由浏览器安全模型决定的,防止出现无限循环。这个特性决定了我们的架构是“他驱”的:一个标签页的修改,驱动其他标签页更新。
3.3 处理序列化与异常
localStorage只能存储字符串。因此,我们需要可靠地进行JSON.stringify和JSON.parse。这里必须做好错误处理。
function getStorageSlice(key, defaultValue = null) { try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : defaultValue; } catch (e) { console.error(`解析Storage数据失败[key=${key}]:`, e); // 可选:尝试修复损坏的数据,或清除该键值 // localStorage.removeItem(key); return defaultValue; } }此外,localStorage有容量限制(通常5MB)。对于聊天消息这种可能无限增长的数据,我们需要设计归档或清理策略,例如只保留最近500条消息的ID索引,更早的消息仅在本标签页内存中保留或需从服务器拉取。
4. 第二层实现:构建稳健的消息通信中枢
存储层提供了原始的“数据变更”信号,通信层则需要将其转化为业务可理解的“消息”。这一层是整个架构的消息总线。
4.1 定义统一的消息协议
首先,我们需要定义跨Tab通信的消息格式。一个健壮的消息协议应包含:
// 消息类型枚举 const MESSAGE_TYPES = { SYNC_CHAT_MESSAGE: ‘SYNC_CHAT_MESSAGE‘, // 同步新消息 MARK_MESSAGE_READ: ‘MARK_MESSAGE_READ‘, // 标记消息已读 USER_ACTIVITY: ‘USER_ACTIVITY‘, // 用户活动(如输入中) REQUEST_FULL_SYNC: ‘REQUEST_FULL_SYNC‘, // 请求全量同步(新Tab加入) }; // 基础消息结构 interface CrossTabMessage { type: keyof typeof MESSAGE_TYPES; // 消息类型 payload: any; // 消息负载 timestamp: number; // 消息创建时间戳 sourceId: string; // 发送源标签页ID(用于去重和跟踪) version?: string; // 协议版本(用于兼容性) }sourceId非常重要,通常可以用Date.now() + Math.random()在标签页初始化时生成。它有两个作用:1) 在消息处理时,可以判断消息是否来自自身(避免处理自己发出的广播);2) 在调试时,可以追踪消息的来源。
4.2 实现消息的发送与接收
发送消息的本质是:将结构化消息写入存储层的一个特定键(例如_cross_tab_message_queue_)。我们采用队列的思想,但为了简单,可以每次覆盖。
class CrossTabCommunicator { constructor(tabId) { this.tabId = tabId; this.messageKey = ‘_cross_tab_msg_‘; this.listeners = new Map(); // type -> Set<callback> // 监听storage事件,接收消息 window.addEventListener(‘storage‘, this._handleStorageEvent.bind(this)); } // 发送消息到其他Tab postMessage(type, payload) { const message: CrossTabMessage = { type, payload, timestamp: Date.now(), sourceId: this.tabId, version: ‘1.0‘ }; try { // 写入Storage,触发其他Tab的storage事件 localStorage.setItem(this.messageKey, JSON.stringify(message)); // 重要:立即移除或置空,避免重复处理(可选策略,见下文) // setTimeout(() => localStorage.removeItem(this.messageKey), 0); } catch (e) { console.error(‘发送跨Tab消息失败:‘, e); } } // 处理来自Storage的事件 _handleStorageEvent(event) { if (event.key !== this.messageKey || event.storageArea !== localStorage) return; if (!event.newValue) return; // 可能是被移除操作 try { const message = JSON.parse(event.newValue); // 可选:忽略自己发出的消息(根据sourceId判断) if (message.sourceId === this.tabId) return; // 分发给对应的监听器 const callbacks = this.listeners.get(message.type); if (callbacks) { callbacks.forEach(cb => cb(message.payload, message)); } } catch (e) { console.error(‘解析跨Tab消息失败:‘, e); } } // 订阅特定类型的消息 on(type, callback) { if (!this.listeners.has(type)) { this.listeners.set(type, new Set()); } this.listeners.get(type).add(callback); // 返回取消订阅函数 return () => this.listeners.get(type).delete(callback); } }4.3 关键细节:消息去重与防循环
这里有一个经典的陷阱:如果我们在postMessage中写入Storage,并在_handleStorageEvent中监听到变更后,又触发了一个可能导致再次postMessage的操作,就可能形成循环。我们的架构通过sourceId在接收端过滤自身消息,基本可以避免。但还有更隐蔽的情况:多个标签页几乎同时写入同一个Key。
考虑这个场景:Tab A和Tab B同时发送了一条SYNC_CHAT_MESSAGE。由于localStorage.setItem是同步的,后写入的会覆盖先写入的。如果覆盖发生得极快,可能导致其中一个Tab完全没收到这条消息(它的storage事件还没来得及触发,值就被覆盖了)。
一种更稳健的模式是使用消息队列数组而非单条消息覆盖。我们将Storage的对应Key值设为一个数组,发送消息时JSON.parse出数组,追加新消息,再JSON.stringify写回。接收方处理数组中的所有消息。但这带来了新的复杂度:并发写操作下的数据竞争(两个Tab同时读、改、写同一数组)。虽然storage事件是顺序触发的,但JavaScript的执行是异步的,竞争条件依然可能发生。
在实践中,对于聊天消息同步这种对绝对顺序要求不是极端严苛的场景(消息顺序由服务端时间戳保证,前端同步主要用于“有无”),使用单消息覆盖+接受极低概率丢失,并依靠业务层的周期性全量同步或服务端推送作为兜底,是简单有效的选择。如果要求强一致,则需要引入更复杂的机制,如操作转换(OT)或冲突自由复制数据类型(CRDT),这远超本文范围。
实操心得:在通信层,我通常会为每类消息设置一个独立的Storage Key,例如
_msg_sync_chat_,_msg_user_activity_。这减少了单Key的写入冲突,也使得监听逻辑可以更精细。代价是Storage事件监听器里需要判断的Key变多了。
5. 第三层实现:业务层的幂等协调与UI更新
这是最贴近用户的一层。通信层告诉我们“发生了什么”,业务层需要决定“我该怎么办”。它的核心原则是幂等性和状态合并。
5.1 聊天消息同步的协调逻辑
以同步新消息为例。假设我们的聊天消息在内存中是一个按时间排序的数组localMessages。
class ChatCoordinator { constructor(communicator) { this.communicator = communicator; this.localMessages = []; // 本地消息状态 this.subscriptions = []; this._setupMessageHandlers(); } _setupMessageHandlers() { // 订阅新消息同步事件 const off1 = this.communicator.on(MESSAGE_TYPES.SYNC_CHAT_MESSAGE, (payload) => { this._handleIncomingMessage(payload); }); this.subscriptions.push(off1); // 订阅标记已读事件 const off2 = this.communicator.on(MESSAGE_TYPES.MARK_MESSAGE_READ, (payload) => { this._handleMarkRead(payload.messageId); }); this.subscriptions.push(off2); } // 处理外部同步来的新消息 _handleIncomingMessage(newMessage) { // 幂等性检查:根据消息ID去重 const existingIndex = this.localMessages.findIndex(msg => msg.id === newMessage.id); if (existingIndex >= 0) { // 消息已存在,可以选择更新(如果消息内容可能被编辑)或忽略 // this.localMessages[existingIndex] = { ...this.localMessages[existingIndex], ...newMessage }; console.log(`收到重复消息,已忽略: ${newMessage.id}`); return; } // 插入到正确的位置(按时间戳排序) const insertIndex = this.localMessages.findIndex(msg => msg.timestamp > newMessage.timestamp); if (insertIndex === -1) { this.localMessages.push(newMessage); } else { this.localMessages.splice(insertIndex, 0, newMessage); } // 触发UI更新 this._notifyUI(‘messagesUpdated‘, this.localMessages); // 如果当前窗口不是活跃的,可以增加未读计数 if (!document.hasFocus()) { this._increaseUnreadCount(); } } // 本地用户发送消息 async sendMessage(content) { // 1. 乐观更新UI:立即将消息添加到本地列表,显示“发送中” const tempMessage = { id: `temp_${Date.now()}`, content, timestamp: Date.now(), status: ‘sending‘, sender: currentUser }; this.localMessages.push(tempMessage); this._notifyUI(‘messagesUpdated‘, this.localMessages); // 2. 实际调用发送API try { const realMessage = await api.sendChatMessage(content); // 3. 发送成功,用服务端返回的真实消息替换本地临时消息 const tempIndex = this.localMessages.findIndex(msg => msg.id === tempMessage.id); if (tempIndex >= 0) { this.localMessages[tempIndex] = realMessage; } else { // 没找到临时消息,直接插入 this._handleIncomingMessage(realMessage); } // 4. 将最终消息同步给其他标签页 this.communicator.postMessage(MESSAGE_TYPES.SYNC_CHAT_MESSAGE, realMessage); this._notifyUI(‘messagesUpdated‘, this.localMessages); } catch (error) { // 5. 发送失败,更新消息状态为“失败” const tempIndex = this.localMessages.findIndex(msg => msg.id === tempMessage.id); if (tempIndex >= 0) { this.localMessages[tempIndex].status = ‘failed‘; this._notifyUI(‘messagesUpdated‘, this.localMessages); } } } // 标记消息已读 markAsRead(messageId) { // 更新本地状态 const message = this.localMessages.find(msg => msg.id === messageId); if (message && !message.read) { message.read = true; this._notifyUI(‘messageRead‘, messageId); // 同步给其他标签页 this.communicator.postMessage(MESSAGE_TYPES.MARK_MESSAGE_READ, { messageId }); } } _notifyUI(event, data) { // 这里可以通过自定义事件、回调函数或状态管理库(如Vue的emit,React的context)通知UI组件更新 // 例如:EventBus.emit(event, data); console.log(`[UI通知] ${event}:`, data); } _increaseUnreadCount() { // 更新本地未读计数,并可能同步到Storage // ... } }5.2 处理更复杂的业务状态:用户在线状态
聊天消息是相对简单的“追加”操作。像“用户在线状态”这种状态,处理起来需要更小心。假设我们在Storage中存储一个userPresence对象,记录用户ID和最后活动时间戳。
// 用户活动时(如输入、移动鼠标),在本标签页更新状态并广播 function updateUserActivity(userId) { const newPresence = { userId, lastActive: Date.now(), status: ‘active‘ }; // 更新Storage中的全局状态 const allPresence = getStorageSlice(STATE_KEYS.USER_PRESENCE, {}); allPresence[userId] = newPresence; updateStorageSlice(STATE_KEYS.USER_PRESENCE, allPresence); // 这会触发storage事件 // 同时,也通过消息通信层广播一个快速更新事件,让其他Tab UI响应更及时 communicator.postMessage(MESSAGE_TYPES.USER_ACTIVITY, newPresence); } // 在其他标签页监听 communicator.on(MESSAGE_TYPES.USER_ACTIVITY, (presence) => { // 更新本地内存中的状态 localPresenceMap[presence.userId] = presence; // 判断是否“离开”(比如超过30秒无活动) // 更新UI上的用户状态指示器 });这里,我们实际上用了双写策略:既更新了Storage中的权威状态,又通过消息总线发送了一个即时事件。为什么?因为storage事件在某些浏览器中可能有一定延迟,或者被节流。对于“用户正在输入”这种需要快速反馈的状态,直接的消息广播能提供更佳体验。而Storage中的状态则作为持久化、可靠的来源,用于标签页初始化或恢复时获取完整状态。
5.3 新标签页的初始化与全量同步
当一个新标签页打开时,它需要获取当前应用的最新状态。这个过程称为“全量同步”。
class ChatCoordinator { // ... 其他代码 ... initialize() { // 1. 从可信源Storage加载所有持久化状态 const savedMessages = getStorageSlice(STATE_KEYS.CHAT_MESSAGES, []); const userPresence = getStorageSlice(STATE_KEYS.USER_PRESENCE, {}); const unreadCount = getStorageSlice(STATE_KEYS.UNREAD_COUNT, 0); // 2. 用这些状态初始化本地内存状态 this.localMessages = savedMessages; this.presenceMap = userPresence; this._unreadCount = unreadCount; // 3. 通知UI进行首次渲染 this._notifyUI(‘initialStateLoaded‘, { messages: this.localMessages, presence: this.presenceMap, unreadCount: this._unreadCount }); // 4. (可选)广播一个“我上线了”的请求,让其他活跃标签页触发一次全状态同步 // 这对于确保新Tab状态绝对最新很有用,尤其是在有非持久化状态时。 this.communicator.postMessage(MESSAGE_TYPES.REQUEST_FULL_SYNC, { tabId: this.tabId }); } } // 在已有的活跃标签页中,监听全量同步请求 communicator.on(MESSAGE_TYPES.REQUEST_FULL_SYNC, ({ tabId }) => { // 忽略自己发出的请求 if (tabId === selfTabId) return; // 将当前内存中的完整状态,通过一系列SYNC消息发送出去 // 或者,更简单地将所有状态重新写入Storage,触发对方的storage事件 const fullState = { messages: getAllMessages(), presence: getPresenceMap(), // ... }; // 注意:需要分片写入,避免Storage大小限制 updateStorageSlice(STATE_KEYS.CHAT_MESSAGES, fullState.messages); updateStorageSlice(STATE_KEYS.USER_PRESENCE, fullState.presence); });6. 进阶考量:性能、降级与浏览器兼容性
一个生产级的架构,必须考虑边界情况。
6.1 性能优化:节流与批量更新
频繁地触发storage事件(例如用户快速输入触发USER_ACTIVITY同步)可能会导致性能问题,尤其是在低端设备或复杂UI下。
- 对高频事件进行节流:比如“用户正在输入”状态,可以用
lodash.throttle包装更新函数,限制为每500ms同步一次。 - 批量更新消息:如果短时间内收到多条消息,不要每条都触发一次UI重渲染。可以收集在一个队列里,用
requestAnimationFrame或setTimeout在下一个事件循环周期进行批量更新。
let messageQueue = []; let isUpdating = false; function scheduleMessageUpdate(message) { messageQueue.push(message); if (!isUpdating) { isUpdating = true; // 在微任务或下一帧批量处理 Promise.resolve().then(() => { const toProcess = [...messageQueue]; messageQueue = []; isUpdating = false; _batchUpdateUI(toProcess); }); } }6.2 降级策略:当Storage不可用或受限时
localStorage可能被禁用(隐私模式)、已满或抛出异常。我们的架构不能崩溃。
- 异常捕获:所有
localStorage.setItem/getItem操作必须用try-catch包裹。 - 降级检测:在应用启动时,可以尝试读写一个测试值,检测
localStorage是否可用。 - 降级方案:如果完全不可用,则退化为“单标签页”模式,跨Tab同步功能失效,但核心聊天功能(基于WebSocket和服务端)应依然可用。可以通过状态管理库的Memory模式维持单页内状态。同时给用户一个温和的提示:“检测到浏览器存储设置限制,跨窗口消息同步功能不可用”。
- 优雅清理:当捕获到
QuotaExceededError时,可以尝试清理我们自己的、非核心的缓存数据(如过期的消息草稿、历史状态快照),然后重试。如果还是失败,再触发降级。
6.3 浏览器兼容性与隐私模式
- Storage Event兼容性:
storage事件是HTML5标准,现代浏览器支持良好。但对于IE等老旧浏览器,需要考虑降级或使用其他Polyfill方案(如轮询Storage变化,但效率低)。 - 隐私/无痕模式: Safari的隐私浏览模式、Chrome的隐身模式下,
localStorage虽然存在,但其行为有差异(例如关闭标签页可能被清空)。sessionStorage在隐私模式下通常更可靠,但生命周期短。需要根据产品需求权衡选择。一个常见的做法是:优先尝试localStorage,如果发现其行为异常(如写入后立即读取不到),则自动降级到sessionStorage或纯内存模式。
6.4 调试与监控
在开发中,为跨Tab消息流添加清晰的日志至关重要。
// 在Communicator的postMessage和_handleStorageEvent中加入可开关的调试日志 if (DEBUG_CROSS_TAB) { console.groupCollapsed(`[CrossTab][${this.tabId}] ${type}`); console.log(‘Payload:‘, payload); console.log(‘Full Message:‘, message); console.groupEnd(); }甚至可以建立一个简单的监控事件,发送到你的应用监控系统,统计跨Tab同步的成功率、消息延迟等。
7. 完整工作流与一个实战案例
让我们串联起整个流程,看一个用户从打开新标签页到完成一次跨Tab交互的完整故事。
场景:用户Alice已经在https://chat.example.com上有一个标签页(Tab1),正在与Bob聊天。她复制链接,在新标签页(Tab2)中打开了同一个聊天室。
Tab2初始化:
- Tab2的
ChatCoordinator执行initialize()。 - 从
localStorage读取chat_messages,user_presence等状态片段。 - 用这些数据渲染出当前的聊天记录和用户列表。
CrossTabCommunicator初始化,生成自己的tabId(如tab_1654321000123)。- Tab2通过Communicator发送一条
REQUEST_FULL_SYNC消息。
- Tab2的
Tab1响应同步请求:
- Tab1的Communicator收到
REQUEST_FULL_SYNC消息,发现sourceId不是自己。 - 触发对应的监听器。监听器函数将Tab1内存中最新的消息列表和在线状态,分别写入
localStorage的对应键中。
- Tab1的Communicator收到
Tab2接收全量状态:
- 由于Tab1写入了
localStorage,Tab2的window对象触发storage事件。 - Tab2的Communicator的
_handleStorageEvent被调用,识别出键是chat_messages和user_presence。 - 因为这是直接对状态键的写入,Communicator可能没有为这种“直接存储变更”注册监听器。在我们的架构中,业务协调层(
ChatCoordinator)除了监听Communicator的自定义消息,也应该直接监听storage事件中对应业务状态键的变更,作为兜底。或者,Tab1在响应全量同步请求时,不直接写状态键,而是通过发送多条SYNC_CHAT_MESSAGE等消息来实现同步,这样就走统一的消息通道了。两种方式各有利弊,前者简单直接,后者逻辑统一。
- 由于Tab1写入了
Alice在Tab2发送消息:
- Alice在Tab2的输入框打字,按下发送。
- Tab2的
ChatCoordinator.sendMessage被调用。 - 乐观更新:一条状态为
sending的临时消息被加入Tab2的本地消息列表,UI立即更新。 - 调用服务端发送API。
- 服务端确认接收,返回包含服务器生成ID和 timestamp 的真实消息对象。
- Tab2用真实消息替换本地临时消息。
- Tab2的Communicator
postMessage一条SYNC_CHAT_MESSAGE消息,payload为这条真实消息。
Tab1接收新消息:
- Tab1的Communicator监听到
storage事件(因为Tab2的postMessage写入了_cross_tab_msg_键)。 - 解析消息,发现类型是
SYNC_CHAT_MESSAGE,且sourceId不是自己。 - 调用注册在该类型下的所有回调函数(即
ChatCoordinator._handleIncomingMessage)。 _handleIncomingMessage进行幂等检查(通过消息ID),将新消息按时间戳插入本地消息数组。- 触发UI更新。此时,Tab1的聊天窗口也实时显示了Alice刚发送的消息。
- 如果Tab1当前不是焦点窗口,可能还会增加未读计数角标。
- Tab1的Communicator监听到
Bob回复消息:
- Bob回复的消息通过WebSocket推送到两个标签页。
- 两个Tab的WebSocket客户端分别收到消息,各自调用
_handleIncomingMessage(可能来自服务端推送处理函数)。 - 两个Tab都会更新自己的本地列表,并且都会尝试通过Communicator
postMessage同步给对方。 - 由于
sourceId过滤,每个Tab会忽略自己发出的同步消息,只处理对方Tab发来的那条。最终,两个Tab的状态保持一致。
整个过程中,用户感知是无缝的。无论在哪个标签页操作,另一个标签页的状态都能几乎实时地同步更新,真正实现了“多Tab聊天不翻车”。
这个三层架构的优美之处在于它的清晰和解耦。存储层提供基础的跨进程通信能力,通信层将其标准化为消息流,业务层则专注于处理具体的状态逻辑。你可以轻松地将这个模式应用到其他需要跨Tab状态同步的场景,只需替换第三层的业务逻辑即可。它纯粹基于前端技术,不增加后端负担,是一种高效、可靠的客户端协同解决方案。