ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前端跨标签页状态同步:三层架构解决多Tab聊天鬼打墙

前端跨标签页状态同步:三层架构解决多Tab聊天鬼打墙 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 - Setcallback // 监听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的emitReact的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消息。Tab1响应同步请求Tab1的Communicator收到REQUEST_FULL_SYNC消息发现sourceId不是自己。触发对应的监听器。监听器函数将Tab1内存中最新的消息列表和在线状态分别写入localStorage的对应键中。Tab2接收全量状态由于Tab1写入了localStorageTab2的window对象触发storage事件。Tab2的Communicator的_handleStorageEvent被调用识别出键是chat_messages和user_presence。因为这是直接对状态键的写入Communicator可能没有为这种“直接存储变更”注册监听器。在我们的架构中业务协调层ChatCoordinator除了监听Communicator的自定义消息也应该直接监听storage事件中对应业务状态键的变更作为兜底。或者Tab1在响应全量同步请求时不直接写状态键而是通过发送多条SYNC_CHAT_MESSAGE等消息来实现同步这样就走统一的消息通道了。两种方式各有利弊前者简单直接后者逻辑统一。Alice在Tab2发送消息Alice在Tab2的输入框打字按下发送。Tab2的ChatCoordinator.sendMessage被调用。乐观更新一条状态为sending的临时消息被加入Tab2的本地消息列表UI立即更新。调用服务端发送API。服务端确认接收返回包含服务器生成ID和 timestamp 的真实消息对象。Tab2用真实消息替换本地临时消息。Tab2的CommunicatorpostMessage一条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当前不是焦点窗口可能还会增加未读计数角标。Bob回复消息Bob回复的消息通过WebSocket推送到两个标签页。两个Tab的WebSocket客户端分别收到消息各自调用_handleIncomingMessage可能来自服务端推送处理函数。两个Tab都会更新自己的本地列表并且都会尝试通过CommunicatorpostMessage同步给对方。由于sourceId过滤每个Tab会忽略自己发出的同步消息只处理对方Tab发来的那条。最终两个Tab的状态保持一致。整个过程中用户感知是无缝的。无论在哪个标签页操作另一个标签页的状态都能几乎实时地同步更新真正实现了“多Tab聊天不翻车”。这个三层架构的优美之处在于它的清晰和解耦。存储层提供基础的跨进程通信能力通信层将其标准化为消息流业务层则专注于处理具体的状态逻辑。你可以轻松地将这个模式应用到其他需要跨Tab状态同步的场景只需替换第三层的业务逻辑即可。它纯粹基于前端技术不增加后端负担是一种高效、可靠的客户端协同解决方案。
返回列表