跳转至

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,未来再加)
  • 加载期 UIstage.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:

<!-- public/stages/stage-xilink.html -->
<link rel="stylesheet" href="./xistudio-codex.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