背景和目标
Workspace v2 要把原来偏“单页面、单上下文”的工作台,改成可以同时承载多个工作流、多个子应用视图、多个工单对象的工作空间。对用户来说,这个体验更接近浏览器:我打开了几个工作流,切来切去状态还在;我刷新页面或者把链接发给别人,应该还能回到正确的业务页面;我在一个子应用里打开弹窗,不能盖到另一个 tab 上。
图 0:一个通用工作台里的 tab system 演示。用户可以同时打开多个工作对象,像浏览器一样切回;只有当前 tab 拥有 URL、弹层、事件和前台 CPU。
业务目标:
| 业务目标 | 用户看到的体验 |
|---|---|
| 多任务并行 | 同时打开多个 workstream / subapp / ticket,不需要反复回到首页找入口 |
| 上下文不丢 | 切回之前打开过的 tab,表格筛选、iframe 内状态、Workstream 内部 view 尽量保留 |
| 链接仍然可靠 | 刷新、复制链接、外部 deep link 都能恢复到合理 tab |
| 子应用无感接入 | 子应用继续表达“我要打开某个页面”,不需要理解宿主 tab 内部实现 |
| 体验接近浏览器 | 切换快,后台 tab 不抢当前 tab 的资源 |
技术目标:
| 用户期望 | 工程上必须解决的问题 | 如果没解决,实际后果 |
|---|---|---|
| 打开多个 workstream | 持久化 opened tabs、顺序、pin 状态 | 刷新后 tab 丢失,或者不同窗口看到的 tab 不一致 |
| 切回来状态还在 | 保留有限数量的 DOM / iframe runtime | 每次切换都重新加载,筛选、滚动、iframe 内状态丢失 |
| URL 可刷新、可分享 | 从浏览器 URL 找回对应 tab;tab 激活时同步业务 URL | 复制链接打开后变成“没有 tab 归属的页面”,或者地址栏指向错误 tab |
| 多窗口同时使用 | 窗口之间同步 tab list 变化 | 一个窗口关闭 tab,另一个窗口仍显示旧 tab |
| 子应用能打开页面 | SDK / event bus 只表达意图,由宿主决定打开方式 | 业务方直接改宿主状态,tab 行为分散到各子应用里 |
| hidden tab 不影响当前 tab | history、DOM、overlay、event、focus 都按 tab 隔离 | 隐藏 iframe 改掉当前 URL;弹窗出现在另一个 tab;后台应用抢 CPU |
| 切换不卡 | 首屏、热切换、后台任务分别测量和调度 | 页面已经显示但点击无响应,旧指标还误报“只用了几十毫秒” |

图 A1:从产品目标到工程约束。它把“用户想要的浏览器级体验”拆成具体技术问题,也把失败后果写出来。
我们最终的设计原则是:
用真实业务 URL 判断页面应该落到哪个 tab,用服务端状态记录 tab 是否打开,用有限热池保留最近工作的 runtime,用宿主边界隔离子应用的 history、DOM、overlay 和事件。
核心是:
- 怎么判断两个入口是不是同一个 tab。 例如同一个 workstream 应该复用一个 tab,具体内部 view 通过 URL/subPath 保留。
- 谁拥有当前页面的运行时权力。 当前 focused tab 才能写浏览器 URL、显示 overlay、接收 focus event、占用前台 CPU。
架构分层

图 A2:Tab system 的最终分层。Isolation 和 Observability 是两件事:Isolation 是运行时边界,负责阻止 hidden runtime 改当前 tab 的 URL、弹层和事件;Observability 是横切能力,负责定位哪里慢、哪里被拦、哪里异常。
这套架构可以按七类职责理解:
| 职责 | 用户能感知的问题 | 核心机制 |
|---|---|---|
| Intent Interface | 同一个业务对象从菜单、子应用按钮、URL 打开时,不应该有时重复开 tab、有时只在当前 tab 里跳转 | 所有入口先归一成打开意图;宿主统一决定新窗口、吸收当前 tab、聚焦已有 tab,还是新增 tab |
| URL and Tab Ownership | 用户刷新或分享链接后,应该回到同一个业务页面,而不是丢到一个没有 tab 归属的页面 | 地址栏始终保留业务 URL;宿主从 URL 解析业务对象,再判断它属于哪个 tab |
| Persistent Tab State | 刷新后 tab 不该消失;两个浏览器窗口看到的 tab list 不该互相分裂 | 服务端保存 opened tabs 事实;前端做乐观更新;mutation 成功后通知其它窗口失效重拉 |
| Runtime Cache | 切回最近 tab 要快,但打开很多 tab 后当前页面不能变卡、内存不能无上限上涨 | opened tabs 记录用户语义;hot runtime pool 只保留最近工作集;淘汰 runtime 不删除 tab |
| Isolation Boundary | 切到 tab B 后,tab A 不能改地址栏、弹窗盖到 B、或在后台刷新抢资源 | history、window.parent、document/body、overlay、focus event 都按当前 owner 过滤 |
| Rendered Runtimes | 用户只看到一个当前 tab,点击、弹层、URL 写入也必须属于这个 tab | 每个 hot runtime 放进稳定容器;只有 focused owner 可见、可点、可接收前台事件 |
| Observability | 首屏慢、切换卡、弹层串 tab、URL 写错时,必须能定位是哪一层出问题 | FMP 看首屏;tab switch v3 看切换;long task 看可见后卡顿;scope drop 看越界写入;stress gate 看回归 |
第一层:Intent Interface
问题场景
用户打开同一个业务对象的入口很多:左侧菜单、tab 点击、子应用内部按钮、MF event、iframe postMessage、复制来的业务 URL。用户期望很简单:同一个对象不要重复开多个 tab;已经打开的 tab 应该被聚焦;在子应用 root 里打开同应用 view 时,能在当前 tab 内继续导航。
如果每个入口自己决定怎么打开,就会出现用户能直接看到的问题:从菜单打开会复用 tab,从子应用按钮打开却新开重复 tab;从分享链接进入能恢复,从 SDK 进入却丢掉上次 view;超过 tab 上限时有的入口会拦住,有的入口绕过上限。背后的原因才是接口散落:有的子应用改 React state,有的发路由,有的自己调 BFF。
解决方案
我们把子应用能力收口成 intent:业务方只表达“我要打开什么”,宿主决定“怎么打开”。
| 来源 | 输入 | 宿主决策 |
|---|---|---|
| 用户点击 tab | tab row id | 读保存的 tab URL,激活 runtime,写 browser URL |
| React 子应用 SDK | openWorkstreamTab、openSubappViewTab、openSubApp |
归一化 payload,查找已有 tab,必要时新增 |
| MF event bus | TAB_OPEN_REQUEST、NAVIGATE_TO_URL |
聚焦已有 tab、吸收到当前 tab、打开新 tab 或新浏览器窗口 |
| Seto iframe | window.postMessage envelope |
校验 origin 和 payload,再转成宿主 event bus 事件 |
简化后的伪代码:
1 | // 子应用只发意图,不碰宿主内部状态 |
这一层同时解释了为什么“Interface”不应该单独放到后面:它就是入口层。SDK / event bus 的作用是把复杂性挡在宿主里面。
难点
难点不是发一个事件,而是 用户从任何入口做同一件事,结果都必须一致。用户点击、SDK、postMessage、直接 URL 恢复,最后都要落到同一套 find / absorb / add / focus / capacity 控制上。否则用户看到的就是重复 tab、错误聚焦、上限绕过和返回路径不一致。
第二层:URL and Tab Ownership
问题场景
浏览器地址栏只有一个,但工作台内部可能同时保活多个 tab runtime。我们不能把 URL 改成 /tabs/:id。根本原因不是“难看”,而是它会把链接从业务语义变成用户私有会话语义:
| URL 形态 | 用户复制给别人后发生什么 |
|---|---|
/tabs/abc123 |
只说明“我的 tab list 里有一个 id=abc123 的 tab”。别人没有这个 tab id,也不知道它对应哪个 workstream / ticket / view |
/workspace/workstream/123/schedule/456 |
链接本身包含业务对象。刷新、收藏、IM 分享、外部系统 deep link 都能恢复到同一个业务页面 |
所以 /tabs/:id 虽然让宿主实现更简单,但会牺牲刷新、分享和跨端恢复能力。对工作台这种协作产品来说,这是不可接受的。
所以我们保留真实业务 URL,例如:
/workspace/workstream/:id/.../workspace/scheduling/schedule/view/:viewId/workspace/audit_workbench/ticket/custom_view/:viewId
同时,宿主内部用这些 URL 推导“这个页面属于哪个 tab”。更具体地说,就是从 URL 里抽出一组业务字段,用它判断两个入口是否应该复用同一个 tab。
| Tab 类型 | 判定是否同一个 tab 的字段 | 说明 |
|---|---|---|
| Workstream | workstreamId |
内部 view 变化不一定开新 runtime,而是作为这个 Workstream tab 的路径 |
| SubApp root | subAppType |
子应用根入口是稳定 tab |
| SubApp view | viewType + viewId |
具体业务视图可以独立打开,也可能被当前 subapp tab 吸收 |
| Ticket | ticketId + viewType |
工单对象适合作为独立 tab |
| Non-tab route | 不进入 tab list | Home、notification、unknown route 不污染 tab lifecycle |

图 A3:URL 和 tab 的双向同步。重点是:浏览器地址栏仍然是业务 URL,宿主在内部把它映射到 tab。
Browser URL -> Tab
这条链路处理刷新、复制链接、外部 deep link。
1 | function onRouteChanged(location) { |
Tab -> Browser URL
这条链路处理用户点击 tab。
1 | function activateTab(tab) { |
Window -> Window
多个浏览器窗口不能共享 React state,所以同步的是“事实”,不是组件状态。
1 | function onTabMutationSuccess() { |
难点
这层最大的难点是 URL 既是用户契约,也是 runtime 定位输入。用户看到的必须是业务 URL;宿主内部又必须知道这次 URL 变化属于哪个 tab。我们通过 URL resolver + activation state 同时满足这两个约束。
第三层:Persistent Tab State
问题场景
用户能感知到的目标是:刷新后刚才打开的 tab 还在;点“打开”后 tab row 立刻出现;在窗口 A pin / close / reorder 后,窗口 B 不会继续显示旧状态。
如果 tab list 只放在前端内存里,刷新后所有 tab 都会消失;如果每次 mutation 都等服务端返回,用户点击后 tab row 会慢半拍;如果多窗口不通知,A 已经关掉的 tab,B 里还会显示成可点击。
解决方案
持久状态由 BFF 和 React Query 共同处理:
| 模块 | 负责什么 |
|---|---|
| BFF tab controller | list/add/remove/pin/unpin/reorder,合并 opened tabs 和 pinned tabs |
| React Query | 单一 tab list cache key;staleTime;focus refetch |
| optimistic mutation | 新增 tab 时先插临时 tab,服务端返回后替换 |
| BroadcastChannel | mutation 后通知其它窗口失效并 refetch |

图 A3.5:Persistent tab state 的模块关系。React Query 让当前窗口先快起来,BFF 保存最终事实,BroadcastChannel 只通知其它窗口“事实变了”,其它窗口再自己从 BFF 拉最新 tab list。
伪代码:
1 | function addWorkspaceTab(input) { |
难点
这里的难点是 既要快,又要以服务端事实为准。Optimistic UI 只能改善交互延迟,不能绕开 BFF。最终 tab id、pin 状态、跨窗口一致性都必须回到服务端 tab list。
第四层:Runtime Cache
问题场景
用户打开很多 tab 后,有两个相互冲突的感受:切回最近几个 tab 应该很快,表格滚动位置、iframe 状态、内部 view 都最好还在;但如果所有 tab 都热运行,当前 tab 会变卡,后台 iframe 会继续跑任务,浏览器内存也会一路上涨。
因此 cache 不能只说“keep alive”。它有三种完全不同的职责。

图 A4:Runtime cache 的三层职责。Opened tabs 是持久化事实;Hot runtime pool 是有限热运行资源;Scoped view cache 是 Workstream runtime 内部的局部保活。Idle prewarm 是后台准备策略,不是无限后台加载。
| 层 | 回答的问题 | 生命周期 |
|---|---|---|
| Opened Tabs | 这个 tab 是否存在 | BFF 持久化;最多 20 个 opened tabs;pin 不计入 cap |
| Hot Runtime Pool | 切回时能不能马上显示 | 最多 5 个 hot frames;保留 DOM 或 Seto sandbox;LRU demote |
| Cold Tab | tab 存在但 runtime 不热 | 保留 tab row 和 URL;聚焦时重建 runtime |
| Scoped View Cache | Workstream 内部 view 能不能快速回来 | 按 workstream scopeKey 缓存;最多 30 个 inner views |
| Idle Prewarm | 用户点击前能不能先准备一部分 | 首屏后执行;native 更早,Seto 更晚;切换中暂停 |
核心原理
热池只回答一个问题:哪些 runtime 现在值得保活。它不决定 tab 是否存在,也不修改服务端 tab list。
1 | class WarmPool { |
内容区根据当前 URL 选择渲染方式:
1 | if (resolved.kind === 'nonTab') { |
Idle prewarm 的策略也不是“能预热就预热”:
1 | afterFirstScreenReady(() => { |
难点
这层难点是 性能收益和资源风险相互冲突。全部保活最简单,但会把后台 runtime 变成无上限。我们把 opened tabs 和 hot runtimes 分开,保留用户语义,同时给内存和 CPU 一个硬上限。
第五层:Isolation Boundary
问题场景
用户切到 tab B 后,tab A 里的子应用虽然看不见,但它的 iframe 仍然可能在后台运行。用户能看到的异常不是抽象的“隔离失败”,而是这些具体后果:
| 用户能看到的问题 | 背后的原因 |
|---|---|
| 当前 tab 地址栏突然变成另一个 tab 的 URL | hidden iframe 仍然能写 history.pushState / replaceState |
| 按浏览器返回键时,非当前 tab 的内部路由被唤醒 | 所有 iframe 都可能收到同一个 popstate / hashchange |
| 切到 tab B 后,tab A 的 Modal/Toast 盖在 B 上;Dropdown 位置漂移 | 子应用或组件库把弹层 append 到全局 document.body |
| 后台 tab 以为自己被聚焦,开始拉数据或执行重任务 | lifecycle event 没有按 tabId 过滤,所有子应用都收到 TAB_FOCUSED |

图 A5:Seto integration and tab isolation。Seto 负责加载 HTMLSandbox;Workspace host 在 Seto 暴露的生命周期和 sandbox window 外围加 tab 归属上下文。
Seto 接入:从目标倒推能力
这一层的目标不是“用了哪些 Seto API”。目标是更具体的四个不变量:
| 目标 | 不变量 |
|---|---|
| 复用 Seto 加载能力 | 子应用仍然由 Seto HTMLSandbox 加载,不重造一套 runtime |
| 每个 tab 有自己的挂载边界 | 子应用 DOM 必须落到当前 tab 的容器里,不能落到全局容器 |
| hidden runtime 不能越界 | hidden sandbox 不能改当前 tab 的 URL、弹层、focus event |
| 子应用不感知 tab 细节 | 子应用仍然按 window.parent、document.body、history、event bus 的旧方式写代码 |
为了满足这些目标,我们把“用户可见问题”和“背后原因”分开写:
| 用户能看到的问题 | 背后原因 / Seto 约束 | 需要的边界 |
|---|---|---|
| 用户切到 tab B 后,地址栏变成 tab A 的 URL,tab A 的弹窗盖到 B,或 tab A 开始刷新抢资源 | Seto 默认解决“怎么加载子应用”,不知道这次 DOM / history / event 操作应该归属于哪个 Workspace tab | 宿主必须在 Seto 外围补一层 tab 归属上下文,把 DOM、history、event 都挂到目标 tab |
| 切回某个 tab 时,看到的是另一个 tab 的内容或滚动状态 | sandbox 内容如果挂到全局容器,就没有 per-tab DOM root | getContainer() 必须返回当前 HotTabFrame 的 root |
| 首次进入子应用时偶发地址栏写错、或 patch 太早导致空白 / 异常 | sandbox window 只有 ready 后才能访问;太晚 patch 又可能漏掉首次 history 写入 | onSandboxReady() 后立刻注册 runtime frame |
| 用户没有切回 tab A,但当前地址栏被 tab A 的内部跳转改掉 | Seto 自己维护 RAW_HISTORY,只 patch host history 不够 |
patch sandbox.raw.win.RAW_HISTORY,按 tab target 校验 push / replace |
| 点击子应用按钮后,当前 tab 被跳到其它页面,或者其它 tab 也被刷新 | 子应用可能通过默认 window.parent 拿到裸 host history、document、event bus |
给 parent 返回按 tab 裁剪后的 Proxy |
| 切到 tab B 后,tab A 的 Modal/Toast 仍盖在页面上;Dropdown 跟触发器错位 | 组件库会把 Modal/Dropdown/Toast append 到全局 document.body |
通过 document/body API 把节点路由到 tab-owned overlay root |
| Modal 被限制住了,但 Dropdown / Tooltip 坐标漂移 | overlay 类型不一样:Modal 属于内容区,Dropdown/Tooltip 需要按触发器定位 | 区分 content overlay 和 floating overlay;大面积判断只用于 position: fixed |
后台 tab 收到 TAB_FOCUSED 后开始刷新、轮询或执行重任务 |
lifecycle event 默认是全局广播 | event bus 按 tabId 过滤 focus / blur |
所以我们最后用到的 Seto 能力,其实是被这些边界倒推出来的:
| Seto 能力 | 满足哪个要求 | 接入方式 |
|---|---|---|
HTMLSandbox |
继续复用 Seto 的加载、entry、basename 和生命周期 | 宿主只包一层 tab owner,不替换 Seto runtime |
getContainer() |
让 DOM 挂到当前 tab,而不是全局页面 | 返回当前 HotTabFrame 内部的 root;root 变化时重新注册 DOM scope |
onSandboxReady(sandbox) |
拿到可 patch 的 sandbox window | 拿 sandbox.raw.win 后注册 runtime frame,patch history / parent / event |
BaseSandbox |
识别“这次 document/body 调用来自哪个 sandbox” | 用 WeakMap 把 sandbox 关联到 tab root |
DocExternals / document plugin context |
接管 document.body、query、append 等 API |
查询限制在 scoped root;body portal 路由到 tab overlay root |
sandbox.raw.win.RAW_HISTORY |
接管 Seto 真正使用的 history | 对 pushState / replaceState 做 tab target 校验,再决定是否同步 host history |
接入顺序可以简化成这样:
1 | function SetoTabRuntime({ tabId, entry, initialUrl }) { |
这个顺序很关键:getContainer() 解决“挂到哪里”,onSandboxReady() 解决“拿到哪个 window 可以 patch”,DOM scope 解决“document/body 属于谁”,runtime frame 解决“history、parent、event 属于谁”。
History / Window scope
核心逻辑不是“禁止所有 history 写入”,而是只允许目标 tab 写。
1 | function scopedPushState(state, unused, url) { |
window.parent 也不是裸宿主窗口,而是 Proxy:
1 | parentProxy.get('history') -> scopedHistory |
这样 Seto 子应用仍然按原接口访问 window.parent,但拿到的是按 tab 裁剪后的对象。
Event scope
URL 类事件只投给目标 tab:
1 | function addEventListener(type, listener) { |
MF event bus 也做生命周期事件过滤:
1 | scopedEventBus.listen(listener) { |
DOM / Overlay scope
这里的用户问题是:弹窗和下拉看起来是“当前 tab 的 UI”,但底层组件库经常把节点挂到全局 document.body。如果宿主不接管,hidden tab 的弹窗会盖到当前 tab,或者下拉框因为坐标系变了而漂移。
子应用仍然认为自己在 append 到 document.body,但宿主会按规则把节点路由到当前 tab 的内容层或弹层层。
1 | function appendToRuntimeBody(node) { |
分类规则里最容易出 bug 的是 floating overlay 和 content overlay:
- Select、Dropdown、Tooltip 这类 floating overlay 需要跟触发器定位;
- Modal、Drawer、Toast、Notification 这类 content overlay 需要限制在 tab 内容区域;
- 大面积 overlay 的几何启发式只应用于
position: fixed,避免 absolute 下拉层因为坐标系变化而漂移。
难点
这层难点是 用户看不到的 runtime 仍然会产生用户看得见的副作用。如果不在 Seto 的 sandbox window 和 document API 边界加 owner,hidden tab 就不是“隐藏”,而是“后台仍然能改当前页面”。
第六层:Rendered Runtimes
问题场景
用户看到的是一个当前 tab,但异常会很明显:切到 B 后 A 的 iframe 还挡着,点击落到 A,URL 写到 A,或者指标说切换完成但页面不能操作。背后原因才是宿主同时管理 native Workstream、Seto iframe、MF subapp 三种 runtime,它们的生命周期和 location 来源都不一样。
如果只是把这些 runtime 当普通 React component 渲染,就会出现这些具体问题:
| 用户能看到的问题 | 背后原因 |
|---|---|
| 从 tab A 切到 tab B 后,A 的 DOM / iframe 还挡在页面上,或者还能接收点击 | hot runtime 只是被保活,不等于已经从交互层移除 |
| B 已经显示,但一次点击、弹层或 URL 写入仍然作用到 A | 视觉 focused tab 和 runtime owner 没有同步 |
| 后台 tab 因当前 URL 变化而重新渲染,切回时内容变了 | 所有保活 runtime 都读到同一个 location,没有 per-tab location |
| 同样是切 tab,有的页面状态保留,有的页面被重建 | native route、Seto sandbox、MF iframe 的生命周期不同,没有统一容器收口 |
| 指标显示“切换完成”,但页面已经 visible 后仍然点不动 | 有的 runtime 只是 mounted,不代表 focused frame 已经可交互 |
所以这里真正的问题不是“组件类型多”,而是 用户视觉上的 focused tab,必须和 router location、runtime owner、overlay owner、event owner、metric owner 同步。只要其中一个慢半拍,用户看到的就会是 B,但后台仍然按 A 在工作。
解决方案
我们给每个热运行 tab 一个统一的 frame。这个 frame 不是装饰层,它负责把不同 runtime 收敛成同一套宿主语义:
| Frame 负责的事 | 为什么需要 |
|---|---|
| 稳定 DOM 容器 | tab 切走时不卸载 hot runtime,切回来可以保留 DOM / iframe 内状态 |
| focused / hidden 状态 | 只有 focused tab 可见、可点;hidden tab 只保活,不参与当前交互 |
| per-tab location | focused tab 使用当前浏览器 URL;hidden tab 使用自己上次保存的 URL,避免被当前 URL 带跑 |
| owner 同步 | 切换时同步 Seto runtime owner、overlay owner、event owner |
| 统一 visible 时机 | tab switch 指标在 frame 真正可见时上报,而不是各 runtime 自己随便报 |
1 | function HotTabFrame({ tab, isFocused, location }) { |
聚焦 tab 时,宿主同步三件事:
1 | // 运行时 owner:history / parent / popstate 应该属于哪个 Seto frame。 |
这样视觉 owner、Seto runtime owner、overlay owner、event owner 是同一个 tab。
难点
这层难点是 用户看见的 focused tab 必须和运行时 owner 一致。如果视觉上切到了 A,但 Seto scope 还认为 B 是 focused,history、overlay、event 都会错。
这套设计的关键取舍
| 取舍 | 为什么不选更简单方案 | 最终选择 |
|---|---|---|
| URL | /tabs/:id 实现简单,但链接只对当前用户的 tab 会话有意义,别人拿到后无法恢复业务对象 |
保留业务 URL,宿主内部解析到 tab |
| Cache | 全部 keep-alive 切换快,但资源无上限 | opened tabs 和 hot runtime 分离,热池有 cap |
| Seto | 每次切换 reload 最干净,但状态丢失、切换慢 | 保留 Seto runtime,并在 host 边界加 scope |
| Interface | 让各子应用直接理解 tab 最省宿主代码,但耦合扩散 | 子应用只发 intent,宿主统一决策 |
| Observability | 只看旧 duration 数字简单,但会漏 post-visible 卡顿 | FMP、switch v3、long task、scope drop 分线观测 |
最终,这个系统的价值不是“页面上多了一排 tab”。真正的技术点是:在单页工作台里同时运行多个业务 runtime 时,宿主必须明确谁拥有 URL、谁拥有 DOM、谁拥有 overlay、谁能接收事件、谁能占用前台资源。把这些所有权定义清楚,tab system 才能像浏览器,而不是像一堆互相干扰的隐藏页面。