30 · Stage iframe 接入策略
📌 本文目标:定义 4 个 L4 Stage(XiLink/XiTune/XiForge/XiTest)以 iframe 形式接入 Vue Shell 的完整策略,包括静态资源放置、URL 映射、生命周期、路由协同、资源回收。
1. 为什么坚持 iframe(不重写为 Vue 组件)
| 选项 | 优势 | 劣势 | 决策 |
|---|---|---|---|
| A · iframe 沿用 ✅ | 与 demo 1:1 字节兼容;Stage 与 Vue Shell 解耦;Stage 可独立迭代;postMessage 已是稳定契约 | 无法直接共享 Pinia store;初次加载有 iframe 开销(~50ms) | 采用 |
| B · 重写为 Vue 组件 | 可共享 store;零通信开销 | 需重写 4 个 Stage 全部业务(~5000 行);与 demo 不再同步 | 拒绝 |
| C · 微前端(qiankun/wujie) | 业务隔离 + 共享数据 | 引入额外框架复杂度;与 vanilla Stage 不匹配 | 拒绝 |
采用 A 的核心理由: 1. demo 与 Vue 项目并行迭代——Stage 业务(XiLink 流图、XiTune 调音、XiForge 模块、XiTest 测试)继续在 vanilla 端打磨,无需重复劳动 2. postMessage 9 类协议已是稳定契约——Vue Shell 只是把"散落的 addEventListener"集中到 composable,业务接口不变 3. 未来可渐进式重写——某个 Stage 业务稳定后可单独抽出来重写为 Vue 组件,其他 Stage 不受影响
2. 静态资源放置策略
2.1 推荐结构
public/
└── stages/ ← Vite 静态资源目录(构建时直接 copy)
├── stage-xilink.html
├── stage-xitune.html
├── stage-xiforge.html
├── stage-xitest.html
├── xistudio-codex.css ← Stage 也需要法典 CSS
└── stage-shared/ ← 4 Stage 共享资源
├── peq-engine.js
├── icons.svg
└── fonts/
2.2 URL 映射常量
// src/constants/stage-src.ts
export const STAGE_SRC = {
xilink: '/stages/stage-xilink.html',
xitune: '/stages/stage-xitune.html',
xiforge: '/stages/stage-xiforge.html',
xitest: '/stages/stage-xitest.html',
} as const
export type StageKey = keyof typeof STAGE_SRC
2.3 Vite 配置(vite.config.ts)
export default defineConfig({
publicDir: 'public', // Stage HTML 直接 copy 到 dist/
build: {
rollupOptions: {
// Stage 不参与 Vue 主 bundle
external: ['/stages/*'],
},
},
})
2.4 同步 demo 资源(CI 脚本)
# scripts/sync-stages-from-demo.sh
SRC=docs/02-products/P1-xistudio/layout-demo
DST=src-vue/public/stages
cp $SRC/stages/stage-*.html $DST/
cp $SRC/xistudio-codex.css $DST/
⚠️ 建议在 CI 中跑,确保 demo 一改 Vue 项目同步生效。
3. iframe 生命周期
3.1 状态机
┌─────────────┐ Pill click ┌──────────────┐
│ IDLE │ ────────────────▶ │ LOADING │
└─────────────┘ └──────────────┘
│ iframe.onload
▼
┌──────────────┐
│ FRAME_LOADED │
└──────────────┘
│ Stage 发 'stage-ready'
▼
┌──────────────┐
│ READY │ ← 可发 toolbar/inspector 等消息
└──────────────┘
│ 用户切 Stage
▼
┌──────────────┐
│ SWITCHING │
└──────────────┘
│ 清空槽位 + 重设 src
▼
LOADING ...
3.2 Pinia 状态
// stores/stage.ts
export const useStageStore = defineStore('stage', {
state: () => ({
current: 'xilink' as StageKey,
subtab: null as string | null,
state: 'idle' as 'idle' | 'loading' | 'frame-loaded' | 'ready' | 'switching',
version: null as string | null,
}),
actions: {
switchTo(key: StageKey) {
if (this.current === key) return
this.state = 'switching'
this.current = key
this.state = 'loading'
// iframe :src 响应式变化会自动重载
},
markReady(stage: StageKey, version: string) {
if (stage === this.current) {
this.state = 'ready'
this.version = version
}
},
},
})
3.3 Stage 切换流程(详细)
1. 用户点击 Pill(如 xitune)
└─ StageBar @click → stage.switchTo('xitune')
2. stage.current 变为 'xitune'
└─ StageFrame :src 响应式变化 → iframe 重新 load
3. Vue 监听 stage.current 变化
└─ watch(() => stage.current, () => {
stageBar.clearButtons()
layout.clearStageStatusHtml()
layout.clearStageHintHtml()
layout.clearInspectorHtml()
layout.clearModulePropertiesHtml()
})
4. iframe 加载完毕 → onload 触发
└─ stage.state = 'frame-loaded'
5. Stage 内部 JS 启动,发 postMessage 'stage-ready'
└─ useStageBridge handler → stage.markReady()
└─ stage.state = 'ready'
6. Stage 按需重新发:toolbar-inject / stage-status / stage-hint / inspector-inject 等
└─ Shell 各槽位重新填充
3.4 关键约束
- iframe DOM 不复用:每次 Stage 切换都是 iframe
src变化 → 浏览器重建文档(不需要手动removeChild) - Vue 不缓存 Stage iframe:当前架构不做 keep-alive 形式的 iframe 保留(如需 Stage 间 hot-switch,未来再加)
- 加载期 UI:
stage.state === 'loading'时可在 StageFrame 上显示 spinner 蒙层
4. 路由协同
4.1 Vue Router 与 Stage 的关系
URL: /xilink/main ← Pill: XiLink + SubTab: 主图
└ /:stage/:subtab?
实现:
- /:stage 决定 iframe src(通过 stage.current)
- /:subtab? 通过 useStageBridge.send('subtab', { subtab }) 通知 iframe 切子视图
4.2 路由表
// router/index.ts
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/xilink/main' },
{
path: '/:stage(xilink|xitune|xiforge|xitest)/:subtab?',
name: 'stage',
component: () => import('@/views/StageView.vue'),
meta: {
license: (route) => `${route.params.stage}-pro`,
},
},
{ path: '/:pathMatch(.*)*', redirect: '/xilink/main' },
]
4.3 路由守卫
router.beforeEach((to, from, next) => {
const lic = useLicenseStore()
const stage = useStageStore()
// license 校验
const requiredFn = to.meta.license as ((r: RouteLocationNormalized) => string) | undefined
if (requiredFn) {
const required = requiredFn(to)
if (!lic.has(required)) {
// 不阻塞路由,但通过 toast 提示 + Stage 内部进入只读模式
useToast().show(`License "${required}" 未授权,进入只读模式`)
}
}
// 同步 Pinia store
stage.switchTo(to.params.stage as StageKey)
if (to.params.subtab) stage.setSubtab(to.params.subtab as string)
next()
})
4.4 Pill 点击与路由
// StageBar.vue
function onPillClick(key: StageKey) {
if (!license.canEnter(key)) {
useToast().show(`License 未授权,无法进入 ${key.toUpperCase()}`)
return
}
router.push({ name: 'stage', params: { stage: key } })
}
4.5 SubTab 切换
// SubTabs.vue
const bridge = inject(StageBridgeKey)! // 从父级 StageFrame 注入
function onSubtabClick(key: string) {
router.push({ name: 'stage', params: { stage: stage.current, subtab: key } })
// 同步通知 iframe 内部
bridge.send('subtab', { subtab: key })
}
5. iframe 与 Vue 之间的资源共享
5.1 codex.css 共享策略
iframe 内部的 Stage HTML 必须 <link> 到法典 CSS:
⚠️ 不要让 Vue Shell 注入 codex.css 到 iframe(跨 origin 不可行;同 origin 也复杂),iframe 内部独立 link 即可。
5.2 主题同步
Vue Shell 切换主题时通过 postMessage 通知 iframe:
// 在 Theme 切换时
watch(() => theme.current, (newTheme) => {
bridge.send('theme', { theme: newTheme })
})
iframe 内部接收:
// stage-xilink.html
window.addEventListener('message', (e) => {
if (e.data.type === 'theme') {
document.body.dataset.theme = e.data.theme
}
})
📝 协议扩展提示:
theme消息属于 Shell→Stage 反向消息的扩展,需要在 40-postmessage-protocol.md 中显式登记 ADR。
5.3 字体 / 图标共享
放在 public/stages/stage-shared/ 下,iframe 与 Vue 都通过相对路径引用,不重复加载(浏览器 HTTP 缓存)。
6. iframe 与 postMessage 安全
6.1 来源校验
function onMessage(e: MessageEvent) {
// 只接受来自 stageFrame iframe 的消息
if (e.source !== frameRef.value?.contentWindow) {
console.warn('[useStageBridge] reject foreign message', e.origin)
return
}
const msg = e.data as StageMessage
handlers.get(msg.type)?.(msg)
}
6.2 同 origin 部署
- 强制同 origin:iframe HTML 与 Vue 项目部署在同一域名下
- 不开 CORS / postMessage targetOrigin '*' 仅在内部网络
- 生产环境:build 时把
public/stages/一起 dist 出去
6.3 sandbox attr 暂不启用
<!-- ❌ 暂不启用 sandbox(会限制 postMessage 与 localStorage 访问) -->
<iframe :src="..." sandbox="allow-scripts allow-same-origin"></iframe>
<!-- ✅ 当前用法 -->
<iframe :src="..."></iframe>
⚠️ 未来如果要加载用户上传的 Stage 脚本(高风险),需要重审 sandbox 策略。
7. 渐进式重写路径(未来)
如果某个 Stage 业务稳定后想重写为 Vue 组件:
// router/index.ts 改为 component 加载
{
path: '/xitune/:subtab?',
component: () => import('@/views/stages/XiTuneStage.vue'), // ← Vue 组件
}
XiTuneStage.vue 不再用 iframe,直接用 Vue 组件实现。其他 3 个 Stage 仍走 iframe,不受影响。
重写顺序建议(业务复杂度从低到高): 1. XiTest(KPI 看板,纯渲染) 2. XiForge(模块库 + 详情,结构化数据) 3. XiTune(曲线 + A/B,需 Web Audio) 4. XiLink(流图编辑,需 SVG/Canvas 重写)
8. 验收测试
| 场景 | 验收点 |
|---|---|
| Pill 切换 4 个 Stage | iframe src 变化 → 槽位清空 → Stage 重注入 → UI 正确 |
| 刷新页面 | iframe 重载 → Stage 重新发 stage-ready → 槽位重新填充 |
| 切换主题 | Vue Shell + iframe 内 Stage 同时切换主题(视觉一致) |
| 跨域伪造 message | 来源校验拒绝(控制台 warn) |
| 网络慢 iframe 慢加载 | 显示 loading 蒙层 + 槽位为空 |
| 切 Stage 时上一个 Stage 残留 | 槽位(toolbar/inspector/properties/status/hint)必须先清空 |
9. 错误处理
| 错误 | 处理 |
|---|---|
| iframe 404 | StageFrame 显示错误页("Stage 资源加载失败") |
| iframe 加载超时 (>10s) | toast + 自动重试 1 次,仍失败显示错误 |
Stage 长时间不发 stage-ready(>5s) |
视为加载失败,同上 |
| postMessage 数据格式不符 | console.error + 忽略消息 |
| iframe contentWindow 为 null | bridge.send 静默 noop |
v1.0 · 2026-05-17 · D4 前端改造手册 · Stage iframe 策略 · 配套 v1.2.2-impl §12.2