工程 项目复盘

Workspace v2 Tab System:把浏览器标签页带进工作空间

Workspace v2 要做的不是多画一排 tab。用户会同时打开多个业务对象,刷新和分享链接还要回到正确页面;后台 runtime 不能偷偷改 URL、弹层、事件或前台 CPU。最后真正要解决的是 ownership:intent、URL、tab 状态、runtime c…

发布于 12 分钟阅读 English

Workspace v2 要做的不是多画一排 tab。用户会同时打开多个业务对象,刷新和分享链接还要回到正确页面;后台 runtime 不能偷偷改 URL、弹层、事件或前台 CPU。最后真正要解决的是 ownership:intent、URL、tab 状态、runtime cache、sandbox 副作用和渲染 frame 分别归谁管。

背景和目标

Workspace v2 把原来偏“单页面、单上下文”的工作台,改成可以同时承载多个工作流、子应用视图和工单对象的工作空间。用户的预期其实很直接:我打开几个工作流,切回来状态还在;我刷新页面或者把链接发给别人,还能回到正确业务页;我在一个子应用里打开弹窗,不能盖到另一个 tab 上。

workspace tab system demo

图 0:一个通用工作台里的 tab system 演示。用户可以同时打开多个工作对象,像浏览器一样切回;只有当前 tab 拥有 URL、弹层、事件和前台 CPU。 generated by gpt-image-2.

业务目标:

业务目标用户看到的体验
多任务并行同时打开多个 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 不影响当前 tabhistory、DOM、overlay、event、focus 都按 tab 隔离隐藏 iframe 改掉当前 URL;弹窗出现在另一个 tab;后台应用抢 CPU
切换不卡首屏、热切换、后台任务分别测量和调度页面已经显示但点击无响应,旧指标还误报“只用了几十毫秒”
architecture goals

图 A1:从产品目标到工程约束。它把“用户想要的浏览器级体验”拆成具体技术问题,也把失败后果写出来。 generated by gpt-image-2.

我们最终的设计原则是:

用真实业务 URL 判断页面应该落到哪个 tab,用服务端状态记录 tab 是否打开,用有限热池保留最近工作的 runtime,用宿主边界隔离子应用的 history、DOM、overlay 和事件。

核心是:

  1. 怎么判断两个入口是不是同一个 tab。 例如同一个 workstream 应该复用一个 tab,具体内部 view 通过 URL/subPath 保留。
  2. 谁拥有当前页面的运行时权力。 当前 focused tab 才能写浏览器 URL、显示 overlay、接收 focus event、占用前台 CPU。

架构分层

architecture layers

图 A2:Tab system 的最终分层。Isolation 和 Observability 是两件事:Isolation 是运行时边界,负责阻止 hidden runtime 改当前 tab 的 URL、弹层和事件;Observability 是横切能力,负责定位哪里慢、哪里被拦、哪里异常。 generated by gpt-image-2.

这套架构可以按七类职责理解:

职责用户能感知的问题核心机制
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:业务方只表达“我要打开什么”,宿主决定“怎么打开”。

来源输入宿主决策
用户点击 tabtab row id读保存的 tab URL,激活 runtime,写 browser URL
MF event busTAB_OPEN_REQUEST、NAVIGATE_TO_URL聚焦已有 tab、吸收到当前 tab、打开新 tab 或新浏览器窗口
iframe 沙箱window.postMessage envelope校验 origin 和 payload,再转成宿主 event bus 事件
Subapp SDKopenWorkstreamTab、openSubappViewTab、openSubApp,通常通过宿主 event bridge 暴露归一化 payload,查找已有 tab,必要时新增

简化后的伪代码:

// 子应用只发意图,不碰宿主内部状态
function openSubappView(viewType, viewId) {
emit('TAB_OPEN_REQUEST', { itemType: 'SUBAPP_VIEW', viewType, viewId });
}
// iframe 只把意图发给宿主;校验、去重和路由仍由宿主负责。
window.parent.postMessage({
type: 'MF_EVENT',
payload: {
type: 'TAB_OPEN_REQUEST',
data: {
itemType: 'WORKSTREAM',
workstreamId: 'from-iframe',
},
metadata: {
source: 'REPORT_CENTER',
timestamp: Date.now(),
},
},
}, targetOrigin);
// 宿主统一处理
function handleTabOpenRequest(raw) {
const input = normalizeAndValidate(raw);
if (raw.openInNewBrowserTab) {
window.open(buildBusinessUrl(input));
return;
}
// existing:目标业务对象已经有自己的 tab。
// 例子:用户要打开 view A,而 view A 的独立 tab 已经在 tab list 里。
const existing = findExistingTab(tabs, input);
// absorbing:当前 focused tab 是一个可承载内部 view 的 carrier。
// 例子:用户正在某个 SubApp root / SubApp view 里打开同属一个 app 的 sibling view;
// 这时更像“在当前 app tab 内导航”,不是“打开一个全新工作上下文”。
const absorbing = findFocusedTabThatCanAbsorb(input);
// 这两个概念不是重叠的:
// - existing 处理“目标是否已经有 tab”;
// - absorbing 处理“当前 tab 是否可以直接承接这次导航”。
//
// 如果当前是 subapp root,并且目标已有精确 tab,优先跳已有 tab;
// 否则会优先吸收到当前 carrier,避免在同一个子应用里频繁开重复 runtime。
if (existing && absorbing?.kind === 'subapp-root') {
focusTab(existing.id, savedUrlOrDefault(existing));
return;
}
if (absorbing) {
navigateInsideTab(absorbing.id, buildPath(input));
return;
}
if (existing) {
focusTab(existing.id, savedUrlOrDefault(existing));
return;
}
addTabWithCapacityControl(input);
}

这一层同时解释了为什么“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 的字段说明
WorkstreamworkstreamId内部 view 变化不一定开新 runtime,而是作为这个 Workstream tab 的路径
SubApp rootsubAppType子应用根入口是稳定 tab
SubApp viewviewType + viewId具体业务视图可以独立打开,也可能被当前 subapp tab 吸收
TicketticketId + viewType工单对象适合作为独立 tab
Non-tab route不进入 tab listHome、notification、unknown route 不污染 tab lifecycle
url tab sync

图 A3:URL 和 tab 的双向同步。重点是:浏览器地址栏仍然是业务 URL,宿主在内部把它映射到 tab。 generated by gpt-image-2.

Browser URL -> Tab

这条链路处理刷新、复制链接、外部 deep link。

function onRouteChanged(location) {
// 浏览器地址栏变化后,先把业务 URL 解析成“它是否属于某个 tab”。
// appList 用来识别不同子应用的业务路由。
const resolved = resolveTabFromUrl(location.pathname, appList);
if (resolved.kind !== 'tab') {
// Home、redirect、standalone fallback 这类页面不是工作 tab。
// 它们走普通路由渲染,不创建 tab row,也不进入 hot runtime pool。
renderSingleOutlet();
return;
}
// 如果服务端 opened tabs 里已经有这个业务对象,只需要聚焦它。
// 这里不能新增,否则刷新一次就可能多一个重复 tab。
const matched = findOpenedTab(tabs, resolved.input);
if (matched) {
focusRuntime(matched.id);
return;
}
// direct URL recovery:
// 用户打开的是一个业务 URL,但 BFF 里还没有对应 tab。
// 宿主要补开 tab,否则页面会变成“没有 tab 行归属”的孤儿页面。
addTab(toAddWorkspaceTabRequest(resolved.input));
focusRuntimeWhenReady(resolved.input);
}

Tab -> Browser URL

这条链路处理用户点击 tab。

function activateTab(tab) {
// 每个 tab 都记住自己上一次停留的业务 URL。
// 例如用户在 Workstream tab 内切到了某个 view,下次点回这个 tab 应该回到那个 view,
// 而不是永远回到 Workstream 默认页。
const path = loadSavedTabUrl(tab.id) ?? buildDefaultBusinessUrl(tab);
// 从点击开始计时。后面会分别记录 frame visible、interactive、settled,
// 防止“页面显示了但还不能点”的时间被旧指标漏掉。
startTabSwitchMetric({ toId: tab.id, targetPath: path });
// 告诉沙箱 / iframe 的 history scope:
// 接下来这次 URL 写入属于目标 tab,而不是某个 hidden runtime 的越界写入。
prepareScopedNavigation({ targetTabId: tab.id, url: path });
navigate(path, {
// React Router 只看到一个全局 history。
// 这里把目标 tab id 写进 history.state,后续 popstate / hashchange / 沙箱 history
// 才能判断这次导航应该投递给哪个 runtime。
state: { workspaceTargetTabId: tab.id },
// tab 激活是用户输入的直接结果,必须尽快让 focused frame 和 URL 对齐。
flushSync: true,
});
}

Window -> Window

多个浏览器窗口不能共享 React state,所以同步的是“事实”,不是组件状态。

function onTabMutationSuccess() {
// 本窗口的缓存先失效。
queryClient.invalidateQueries(WORKSPACE_TABS_KEY);
// 其它窗口没有共享 React state,只能收到一个“事实变了”的通知后自己重拉。
broadcastChannel.postMessage({ type: 'TABS_INVALIDATED' });
}
function onOtherWindowInvalidated() {
queryClient.invalidateQueries(WORKSPACE_TABS_KEY);
}

难点

这层最大的难点是 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 里还会显示成可点击。

tab list 的后端动作也可能相对慢,因为 add / reorder / pin 可能会扇出到多个服务。前端需要 optimistic interaction,但不能把本地状态当成最终事实。

解决方案

持久状态由 BFF 和 React Query 共同处理:

模块负责什么
BFF tab controllerlist/add/remove/pin/unpin/reorder,合并 opened tabs 和 pinned tabs
React Query单一 tab list cache key;staleTime;focus refetch
optimistic mutation新增 tab 时先插临时 tab,服务端返回后替换。临时 tab 在真实 id 返回前禁止 pin / unpin / delete 等动作
BroadcastChannelmutation 后通知其它窗口失效并 refetch
persistent tab state

图 A3.5:Persistent tab state 的模块关系。React Query 让当前窗口先快起来,BFF 保存最终事实,BroadcastChannel 只通知其它窗口“事实变了”,其它窗口再自己从 BFF 拉最新 tab list。 generated by gpt-image-2.

伪代码:

function addWorkspaceTab(input) {
queryClient.setQueryData(WORKSPACE_TABS_KEY, tabs => [
...tabs,
makeTempLockedTab(input),
]);
post('/workspace/api/tab/add', input)
.then(serverTab => replaceTempTab(serverTab))
.finally(() => broadcastTabsInvalidated());
}

难点

这里的难点是 既要快,又要以服务端事实为准。Optimistic UI 只能改善交互延迟,不能绕开 BFF。最终 tab id、pin 状态、跨窗口一致性都必须回到服务端 tab list。

第四层:Runtime Cache

问题场景

用户打开很多 tab 后,有两个相互冲突的感受:切回最近几个 tab 应该很快,表格滚动位置、iframe 状态、内部 view 都最好还在;但如果所有 tab 都热运行,当前 tab 会变卡,后台 iframe 会继续跑任务,浏览器内存也会一路上涨。

因此 cache 不能只说“keep alive”。它有三种完全不同的职责。

runtime cache layers

图 A4:Runtime cache 的三层职责。Opened tabs 是持久化事实;Hot runtime pool 是有限热运行资源;Scoped view cache 是 Workstream runtime 内部的局部保活。Idle prewarm 是后台准备策略,不是无限后台加载。 generated by gpt-image-2.

层回答的问题生命周期
Opened Tabs这个 tab 是否存在BFF 持久化;最多 20 个 opened tabs;pin 不计入 cap
Hot Runtime Pool切回时能不能马上显示最多 5 个 hot frames;保留 DOM 或 iframe 沙箱;LRU demote
Cold Tabtab 存在但 runtime 不热保留 tab row 和 URL;聚焦时重建 runtime
Scoped View CacheWorkstream 内部 view 能不能快速回来按 workstream scopeKey 缓存;最多 30 个 inner views
Idle Prewarm用户点击前能不能先准备一部分首屏后执行;native 更早,沙箱更晚;切换中暂停

核心原理

热池只回答一个问题:哪些 runtime 现在值得保活。它不决定 tab 是否存在,也不修改服务端 tab list。

class WarmPool {
promote(tabId, location, runtimeKind) {
// 用户聚焦或预热命中时,把这个 tab 放进 hot set。
hot.set(tabId, { tabId, location, runtimeKind, lastFocusedAt: now() });
// iframe 沙箱更重,所以有单独上限;全局 hot frame 也有总上限。
// 淘汰只会卸载 runtime,不会删除 opened tab。
evictOldestSandboxIfOverCap();
evictOldestHotFrameIfOverGlobalCap();
notifySubscribers();
}
}

内容区根据当前 URL 选择渲染方式:

if (resolved.kind === 'nonTab') {
// 非 tab 页面走普通路由,不放进 HotTabStack。
return <Outlet />;
}
return (
<HotTabStack>
{hotTabs.map(tab => (
<HotTabFrame
tab={tab}
// 只有 focused tab 可见、可交互;其它 hot tab 保留 runtime,但不接收前台事件。
isFocused={tab.id === focusedTabId}
// focused tab 用当前浏览器 URL;后台 hot tab 用自己上次保存的 URL。
location={tab.id === focusedTabId ? currentLocation : tab.location}
/>
))}
</HotTabStack>
);

Idle prewarm 的策略也不是“能预热就预热”:

afterFirstScreenReady(() => {
requestIdleCallback(() => {
for (const candidate of selectIdlePrewarmTabs({ tabs, focusedTabId, hotTabs })) {
if (foregroundTabIsSettling()) break;
prewarmRuntime(candidate);
}
});
});
function selectIdlePrewarmTabs({ tabs, focusedTabId, hotTabs }) {
if (!focusedTabId) return [];
const hotIds = new Set(hotTabs.map(tab => tab.id));
const recentIds = loadRecentHotTabIds(agentId);
return sortRecentTabsBeforeOtherTabs(tabs, recentIds)
.filter(tab => tab.id !== focusedTabId)
.filter(tab => !hotIds.has(tab.id))
.filter(tab => tab.isLocked !== true)
.slice(0, 2);
}
// 1. idle queue 选出 candidate。
const candidate = {
id: tab.id,
location: tab.location,
runtimeKind: tab.runtimeKind, // native / subapp / sandbox
};
// 2. WarmPool promote 这个 candidate。
warmPool.promote(candidate);
// 3. React 通过 useSyncExternalStore 订阅 WarmPool。
const hotTabs = useWarmPool(warmPool);
// 4. WorkspaceContentHost 重新渲染 hot tab 列表。
hotTabs.map(tab => <HotTabFrame key={tab.id} tab={tab} />);
// 5. HotTabFrame 在用户点回前先把 runtime mount 好。

难点

这层难点是 性能收益和资源风险相互冲突。全部保活最简单,但会把后台 runtime 变成无上限。我们把 opened tabs 和 hot runtimes 分开,保留用户语义,同时给内存和 CPU 一个硬上限。

第五层:Isolation Boundary

问题场景

用户切到 tab B 后,tab A 里的子应用虽然看不见,但它的 iframe 仍然可能在后台运行。用户能看到的异常不是抽象的“隔离失败”,而是这些具体后果:

用户能看到的问题背后的原因
当前 tab 地址栏突然变成另一个 tab 的 URLhidden 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:沙箱接入与 tab 隔离

图 A5:沙箱接入与 tab 隔离。沙箱框架负责加载子应用;Workspace host 在沙箱暴露的生命周期和 sandbox window 外围加 tab 归属上下文。 generated by gpt-image-2.

沙箱接入:从目标倒推能力

这一层的目标不是“用了哪些沙箱 API”。目标是更具体的四个不变量:

目标不变量
复用沙箱加载能力子应用仍然由沙箱框架的 Sandbox 组件加载,不重造一套 runtime
每个 tab 有自己的挂载边界子应用 DOM 必须落到当前 tab 的容器里,不能落到全局容器
hidden runtime 不能越界hidden sandbox 不能改当前 tab 的 URL、弹层、focus event
子应用不感知 tab 细节子应用仍然按 window.parent、document.body、history、event bus 的旧方式写代码

为了满足这些目标,我们把“用户可见问题”和“背后原因”分开写:

用户能看到的问题背后原因 / 沙箱约束需要的边界
用户切到 tab B 后,地址栏变成 tab A 的 URL,tab A 的弹窗盖到 B,或 tab A 开始刷新抢资源沙箱框架默认解决“怎么加载子应用”,不知道这次 DOM / history / event 操作应该归属于哪个 Workspace tab宿主必须在沙箱外围补一层 tab 归属上下文,把 DOM、history、event 都挂到目标 tab
切回某个 tab 时,看到的是另一个 tab 的内容或滚动状态sandbox 内容如果挂到全局容器,就没有 per-tab DOM rootgetContainer() 必须返回当前 HotTabFrame 的 root
首次进入子应用时偶发地址栏写错、或 patch 太早导致空白 / 异常sandbox window 只有 ready 后才能访问;太晚 patch 又可能漏掉首次 history 写入onSandboxReady() 后立刻注册 runtime frame
用户没有切回 tab A,但当前地址栏被 tab A 的内部跳转改掉沙箱自己维护 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
切换当前 tab 时,后台 runtime 抢 CPUhidden runtime 仍可能继续 timer、lifecycle prime、prewarm 或刷新任务用 foreground lease 和后台调度器;当前 tab 稳定前延后后台任务

所以我们最后用到的沙箱能力,其实是被这些边界倒推出来的:

沙箱能力满足哪个要求接入方式
Sandbox继续复用沙箱框架的加载、entry、basename 和生命周期宿主只包一层 tab owner,不替换沙箱 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接管沙箱真正使用的 history对 pushState / replaceState 做 tab target 校验,再决定是否同步 host history

接入顺序可以简化成这样:

function SandboxTabRuntime({ tabId, entry, initialUrl }) {
const root = getCurrentHotTabRoot(tabId);
let sandboxRef = null;
return (
<Sandbox
entry={entry}
url={initialUrl}
// DOM 必须挂到这个 tab 的 frame 里,不能挂到全局容器。
getContainer={() => root}
onSandboxReady={sandbox => {
sandboxRef = sandbox;
registerRuntimeFrame({
tabId,
window: sandbox.raw.win,
rawHistory: sandbox.raw.win.RAW_HISTORY,
});
installScopedHistory(tabId, sandbox.raw.win);
installScopedParentProxy(tabId, sandbox.raw.win);
installScopedEventBridge(tabId, sandbox.raw.win);
}}
/>
);
}

这个顺序很关键:getContainer() 解决“挂到哪里”,onSandboxReady() 解决“拿到哪个 window 可以 patch”,runtime frame 解决“history、parent、event 属于谁”。

History / Window scope

沙箱里有多层 history,只 patch 宿主 window.history 不够:

History surface归属风险
window.historyWorkspace host直接改可见地址栏
iframeWin.history沙箱内的 subapp默认不知道自己的 Workspace tab 是否 active
iframeWin.RAW_HISTORY沙箱 history plugin沙箱内部 push / replace 可能绕过 host history 检查
scopedHistoryWorkspace isolation layer判断这次写入是否属于 focused tab
window.parent.history子应用逃逸到宿主的入口如果不代理,可以直接写宿主 URL

核心逻辑不是“禁止所有 history 写入”,而是只允许目标 tab 写。

function scopedPushState(state, unused, url) {
const target = getNavigationTargetFromStateOrPreparedScope(state, url);
if (target.tabId !== currentRuntime.tabId) {
recordScopeDrop({
reason: 'history-write-to-wrong-tab',
from: currentRuntime.tabId,
to: target.tabId,
url,
});
return;
}
rawHistory.pushState({ ...state, workspaceTargetTabId: target.tabId }, unused, url);
}
// 沙箱内部的 raw history 才是 sandbox 真正使用的 surface。
frame.rawHistory.pushState = frame.patchedPushState;
frame.rawHistory.replaceState = frame.patchedReplaceState;
// 子应用仍然看到 window.history,但拿到的是 scopedHistory。
Object.defineProperty(frame.iframeWin, 'history', {
get() {
return frame.scopedHistory;
}
});

window.parent 也不是裸宿主窗口,而是 Proxy:

parentProxy.get('history') -> scopedHistory
parentProxy.get('document') -> scopedDocumentFacade
parentProxy.get('__workspaceMFEventBus__') -> scopedEventBus

这样沙箱子应用仍然按原接口访问 window.parent,但拿到的是按 tab 裁剪后的对象。

Event scope

URL 类事件只投给目标 tab:

function addEventListener(type, listener) {
if (type !== 'popstate' && type !== 'hashchange') {
return rawAddEventListener(type, listener);
}
rawAddEventListener(type, event => {
if (resolveTargetTabForEvent(event) === currentFrame.tabId) {
listener(event);
} else {
reportScopeDrop('event_drop');
}
});
}

MF event bus 也做生命周期事件过滤:

scopedEventBus.listen(listener) {
if (!listener.listensTo('TAB_FOCUSED', 'TAB_BLURRED')) {
return hostBus.listen(listener);
}
return hostBus.listen(payload => {
if (payload.data.tabId === currentFrame.tabId) {
listener(payload);
}
});
}

DOM / Overlay scope

这里的用户问题是:弹窗和下拉看起来是“当前 tab 的 UI”,但底层组件库经常把节点挂到全局 document.body。如果宿主不接管,hidden tab 的弹窗会盖到当前 tab,或者下拉框因为坐标系变了而漂移。

分类规则里最容易出 bug 的是 floating overlay 和 content overlay:

  • Select、Dropdown、Tooltip 这类 floating overlay 需要跟触发器定位;
  • Modal、Drawer、Toast、Notification 这类 content overlay 需要限制在 tab 内容区域;
  • 大面积 overlay 的几何启发式只应用于 position: fixed,避免 absolute 下拉层因为坐标系变化而漂移。

第一步:让 document API 在 tab 内解析。

当 sandbox 里的代码调用 document.body、document.querySelector(...) 或 document.getElementsByClassName(...) 时,宿主先解析当前 sandbox,再找到它注册过的 tab root,从这个 root 里返回结果。

docUse('body', ctx => {
return scopedRoot(ctx) ?? realDocument.body;
});
docUse('querySelector', ctx => {
const root = scopedRoot(ctx);
if (!root) return realDocument.querySelector(...ctx.args);
return root.querySelector(...ctx.args);
});

第二步:每个 tab 创建两层 overlay root。

HotTabFrame(tab A)
└── sandbox app content root
workspace-overlay-root
└── workspace-subapp-overlay-root[data-tab-id="tab A"]
└── workspace-subapp-content-overlay-root
Root用途
workspace-subapp-overlay-rootfloating overlay:dropdown、tooltip、popover、listbox
workspace-subapp-content-overlay-root类 modal 内容:dialog、drawer、toast、大面积阻塞弹层

这个拆分很关键。Modal 应该被限制在 tab 内容区,但 Dropdown 通常依赖触发器坐标。如果所有 overlay 都塞进同一个 content root,Dropdown 很容易漂移。

第三步:拦截 append,并把节点路由到正确层。

当组件库调用 document.body.appendChild(node),或者沙箱 runtime 往 scoped root 插入 body child 时,宿主先分类节点,再决定保留在哪里。

function routeRuntimeBodyNode(node, tabRoot) {
if (!isElement(node)) return;
const target = chooseOverlayTarget(node);
if (target) {
target.appendChild(node);
return;
}
rawAppendChild(tabRoot, node);
}
function chooseOverlayTarget(node) {
if (hasFloatingDescendant(node) || isPositionedOverlay(node)) {
return tabOverlayRoot;
}
if (hasDialogDescendant(node) || isLargeOverlay(node)) {
return tabContentOverlayRoot;
}
return null;
}

第四步:隐藏不属于当前 focused tab 的 overlay root。

WarmPool 会让 hidden tab 仍然保持 mounted,因此它们的 overlay root 也可能还在。focus 变化时,Workspace 只展示当前 tab 的 overlay owner。

useLayoutEffect(() => {
setFocusedWorkspaceOverlayTab(effectiveFocusedTabId);
}, [effectiveFocusedTabId]);
function updateSubappOverlayFocus(root) {
const tabId = root.getAttribute('data-workspace-overlay-tab-id');
const visible = tabId === focusedWorkspaceOverlayTabId;
root.style.display = visible ? '' : 'none';
root.style.visibility = visible ? 'visible' : 'hidden';
root.style.pointerEvents = 'none';
}

Foreground Leasing

tab 激活期间,前台任务应该优先于 hidden prewarm、lifecycle prime、polling 和其它后台任务。这里用一个短暂的 foreground lease 表达“当前 tab 正在稳定中”。

// 用户聚焦 tab 时触发。
beginWorkspaceForegroundTabTask({
tabId: targetTabId,
reason: 'tab_activation',
});
function beginWorkspaceForegroundTabTask({ tabId, reason }) {
foregroundLease = {
tabId,
reason,
expiresAt: Date.now() + WORKSPACE_FOREGROUND_TASK_LEASE_MS,
};
}
// prewarm / lifecycle prime 等后台任务执行前先检查:
if (shouldDeferWorkspaceBackgroundTask({ tabId: candidate.id })) {
scheduleNext(1000);
return;
}
function shouldDeferWorkspaceBackgroundTask({ tabId }) {
const lease = activeForegroundLease();
if (!lease) return false;
if (lease.tabId === null) return true;
return tabId !== lease.tabId;
}

难点

这层难点是 用户看不到的 runtime 仍然会产生用户看得见的副作用。如果不在沙箱 window 和 document API 边界加 owner,hidden tab 就不是“隐藏”,而是“后台仍然能改当前页面”。

第六层:Rendered Runtimes

问题场景

用户看到的是一个当前 tab,但异常会很明显:切到 B 后 A 的 iframe 还挡着,点击落到 A,URL 写到 A,或者指标说切换完成但页面不能操作。背后原因才是宿主同时管理 native Workstream、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、iframe 沙箱、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 收敛成同一套宿主语义:

步骤设置什么用在哪里
沙箱 runtime focusfocusedRuntimeTabIdscoped history 判断 sandbox history 写入是否允许同步到宿主 URL
Overlay focusfocusedWorkspaceOverlayTabId通过 data-workspace-overlay-tab-id 显示或隐藏 tab-owned overlay root
Warm runtimeWarmPool entry 和 LRU timestampWorkspaceContentHost 渲染或复用对应 HotTabFrame
Lifecycle event带 tabId 的 TAB_BLURRED / TAB_FOCUSEDsubapp、MF component、SDK listener 按 tab id 过滤生命周期事件
Switch metricnextTabId 的 visible timestamptab switch 指标区分 shell activation 和真正 frame visible
function HotTabFrame({ tab, isFocused, location }) {
return (
<section
data-hot-tab-id={tab.id}
style={{
visibility: isFocused ? 'visible' : 'hidden',
pointerEvents: isFocused ? 'auto' : 'none',
}}
>
{/* focused tab 用当前 URL;hidden hot tab 用保存的 location。 */}
<Routes location={location}>
<WorkstreamTabRoutes />
</Routes>
</section>
);
}

聚焦 tab 时,宿主同步五类 owner:

function commitFocusedTab(nextTabId) {
// 1. 沙箱 runtime 的 URL owner。
setFocusedSandboxRuntimeTab(nextTabId);
// 2. overlay owner。
setFocusedWorkspaceOverlayTab(nextTabId);
// 3. runtime owner。
warmPool.promote(nextTabId, currentLocation, runtimeKind);
// 4. lifecycle owner。
publishTabLifecycleTransition(prevTabId, nextTabId);
// 5. metric owner。
notifyTabSwitchFrameVisible(nextTabId);
}

这样视觉 owner、沙箱 runtime owner、overlay owner、event owner、metric owner 是同一个 tab。

难点

这层难点是 用户看见的 focused tab 必须和运行时 owner 一致。如果视觉上切到了 A,但沙箱 scope 还认为 B 是 focused,history、overlay、event 都会错。

这套设计的关键取舍

取舍为什么不选更简单方案最终选择
URL/tabs/:id 实现简单,但链接只对当前用户的 tab 会话有意义,别人拿到后无法恢复业务对象保留业务 URL,宿主内部解析到 tab
Cache全部 keep-alive 切换快,但资源无上限opened tabs 和 hot runtime 分离,热池有 cap
沙箱每次切换 reload 最干净,但状态丢失、切换慢保留沙箱 runtime,并在 host 边界加 scope
Interface让各子应用直接理解 tab 最省宿主代码,但耦合扩散子应用只发 intent,宿主统一决策
Observability只看旧 duration 数字简单,但会漏 post-visible 卡顿FMP、switch v3、long task、scope drop 分线观测

最后有价值的不是那排 tab,而是所有权模型。在一个单页工作台里同时保留多个业务 runtime 时,宿主必须说清楚:谁能写 URL,谁拥有 DOM 和 overlay,谁能接收事件,谁能占前台资源。规则清楚以后,tab system 才像浏览器;规则不清楚,它就是一堆互相踩脚的隐藏页面。