40 · postMessage 协议 TS 类型定义
📌 本文目标:把架构文档 v1.2.2-impl §12.3 的 9 类 postMessage 协议落地为 TypeScript 严格类型,前端智能体可直接复制到
src/types/stage-bridge.ts。Stage HTML 端虽然是 vanilla JS,但字段约定必须与本文一致(任何变更需新建 ADR)。
1. 协议总览
| 方向 | 类型 | Stage→Shell 8 类 | Shell→Stage 2 类 |
|---|---|---|---|
| 1 | stage-ready | ✅ | — |
| 2 | toolbar-inject | ✅ | — |
| 3 | stage-status | ✅ | — |
| 4 | stage-hint | ✅ | — |
| 5 | select-node | ✅ | — |
| 6 | tuning-dialog | ✅ | — |
| 7 | show-toast | ✅ | — |
| 8 | inspector-inject | ✅ | — |
| 9 | module-properties-inject | ✅ | — |
| 10 | subtab | — | ✅ |
| 11 | toolbar-click | — | ✅ |
注:v1.2.2-impl §12.3 列为"9 类"是因为把 8 类 Stage→Shell + 2 类 Shell→Stage 合并表述(去重 toolbar 一对:
toolbar-inject+toolbar-click算一对联动),实际类型定义是 11 个。
2. 完整 TS 类型定义
// src/types/stage-bridge.ts
/**
* Stage 标识
*/
export type StageKey = 'xilink' | 'xitune' | 'xiforge' | 'xitest'
// =============================================================
// Stage → Shell 消息(8 类)
// =============================================================
/** 1. Stage 启动就绪 */
export interface StageReadyMsg {
type: 'stage-ready'
stage: StageKey
version: string // Stage 实现版本号(如 '1.2.2-impl')
}
/** 2. 注入 StageBar 工具组 */
export interface ToolbarInjectMsg {
type: 'toolbar-inject'
stage: StageKey
buttons: ToolbarButton[] // 替换式注入(不是追加)
}
export interface ToolbarButton {
id: string // 唯一 id(按 id 反查回调)
icon: string // 图标名(如 'play' 'stop' 'save')
label: string // 显示文字
tip?: string // hover 提示
group?: string // 工具组名('文件'/'构建'/'编辑'/'视图')
disabled?: boolean
}
/** 3. 注入 StatusBar Stage 附加状态槽 */
export interface StageStatusMsg {
type: 'stage-status'
stage: StageKey
html: string // 直接 v-html 渲染(Stage 责任:避免注入恶意脚本)
}
/** 4. 注入 BottomBar 右侧快捷键提示槽 */
export interface StageHintMsg {
type: 'stage-hint'
stage: StageKey
html: string
}
/** 5. 节点选中事件(Shell 据此更新右 Drawer 标题)*/
export interface SelectNodeMsg {
type: 'select-node'
stage: StageKey
node: string // 节点 id
label: string // Drawer 标题(如 "PEQ.gain.lowShelf")
}
/** 6. 拉起跨 Stage Tuning Dialog 浮窗 */
export interface TuningDialogMsg {
type: 'tuning-dialog'
stage: StageKey
module: string // 模块名(PEQ / Mixer / GEQ / Limiter ...)
label: string // 浮窗标题
}
/** 7. Stage 请求 Shell 显示 toast */
export interface ShowToastMsg {
type: 'show-toast'
stage: StageKey
message: string
level?: 'info' | 'warn' | 'error' | 'success'
}
/** 8. 注入右 Drawer Inspector 子内容(v3.4 边界修正)*/
export interface InspectorInjectMsg {
type: 'inspector-inject'
html: string // Inspector 内容 HTML
module: string // 当前模块名(用于 Drawer 标题副标题)
}
/** 9. 注入 Bottom 属性 tab 子内容(v3.4 边界修正)*/
export interface ModulePropertiesInjectMsg {
type: 'module-properties-inject'
html: string // 属性 tab 内容 HTML
module: string
}
/** Stage→Shell 消息联合类型 */
export type StageMessage =
| StageReadyMsg
| ToolbarInjectMsg
| StageStatusMsg
| StageHintMsg
| SelectNodeMsg
| TuningDialogMsg
| ShowToastMsg
| InspectorInjectMsg
| ModulePropertiesInjectMsg
// =============================================================
// Shell → Stage 消息(2 类)
// =============================================================
/** 10. Shell 通知 Stage 切 SubTab */
export interface SubtabMsg {
type: 'subtab'
subtab: string // SubTab key(按 Stage 不同含义不同)
}
/** 11. Shell 通知 Stage 顶栏工具按钮被点击 */
export interface ToolbarClickMsg {
type: 'toolbar-click'
stage: StageKey
id: string // ToolbarButton.id
}
/** Shell→Stage 消息联合类型 */
export type ShellMessage = SubtabMsg | ToolbarClickMsg
// =============================================================
// 协议版本(用于将来扩展)
// =============================================================
export const PROTOCOL_VERSION = '1.0.0'
/** 类型判别助手 */
export function isStageMessage(msg: any): msg is StageMessage {
return msg && typeof msg.type === 'string' && [
'stage-ready', 'toolbar-inject', 'stage-status', 'stage-hint',
'select-node', 'tuning-dialog', 'show-toast',
'inspector-inject', 'module-properties-inject',
].includes(msg.type)
}
export function isShellMessage(msg: any): msg is ShellMessage {
return msg && typeof msg.type === 'string' && ['subtab', 'toolbar-click'].includes(msg.type)
}
3. useStageBridge composable(完整实现)
// src/composables/useStageBridge.ts
import { onMounted, onUnmounted, type Ref } from 'vue'
import type {
StageMessage,
ShellMessage,
StageKey,
} from '@/types/stage-bridge'
import { isStageMessage } from '@/types/stage-bridge'
type Handler<T extends StageMessage['type']> = (
msg: Extract<StageMessage, { type: T }>
) => void
export function useStageBridge(stageFrameRef: Ref<HTMLIFrameElement | null>) {
const handlers = new Map<string, Handler<any>>()
/** 注册某类消息的处理器(同 type 多次注册会覆盖)*/
function on<T extends StageMessage['type']>(type: T, handler: Handler<T>) {
handlers.set(type, handler)
return () => handlers.delete(type) // 反注册函数
}
/** 向 iframe 发 Shell→Stage 消息 */
function send<T extends ShellMessage['type']>(
type: T,
data: Omit<Extract<ShellMessage, { type: T }>, 'type'>
) {
const win = stageFrameRef.value?.contentWindow
if (!win) {
console.warn('[useStageBridge] iframe contentWindow not ready, skip send', type)
return
}
win.postMessage({ type, ...data }, '*')
}
/** 监听 iframe → Vue 的消息 */
function onMessage(e: MessageEvent) {
// 来源校验:只接受来自 stageFrame 的消息
if (e.source !== stageFrameRef.value?.contentWindow) return
const msg = e.data
if (!isStageMessage(msg)) {
console.warn('[useStageBridge] reject malformed message', msg)
return
}
handlers.get(msg.type)?.(msg as any)
}
onMounted(() => window.addEventListener('message', onMessage))
onUnmounted(() => window.removeEventListener('message', onMessage))
return { on, send }
}
4. Stage 端调用示例(vanilla JS)
// public/stages/stage-xilink.html 内的 <script>
;(() => {
const STAGE = 'xilink'
// 启动就绪
window.parent.postMessage({
type: 'stage-ready',
stage: STAGE,
version: '1.2.2-impl',
}, '*')
// 注入工具组
window.parent.postMessage({
type: 'toolbar-inject',
stage: STAGE,
buttons: [
{ id: 'file.new', icon: 'plus', label: '新建', tip: '新建链路' },
{ id: 'file.open', icon: 'folder', label: '打开' },
{ id: 'build.compile', icon: 'play', label: '编译', group: '构建' },
{ id: 'build.deploy', icon: 'upload', label: '烧录', group: '构建' },
],
}, '*')
// 节点选中
function onNodeClick(node) {
window.parent.postMessage({
type: 'select-node',
stage: STAGE,
node: node.id,
label: `${node.type}: ${node.name}`,
}, '*')
// 同时注入 Inspector 内容(v3.4)
window.parent.postMessage({
type: 'inspector-inject',
module: node.type,
html: `<div class="inspector-content"><h3>${node.name}</h3>...</div>`,
}, '*')
}
// 双击节点 → 拉起 Tuning 浮窗
function onNodeDblClick(node) {
window.parent.postMessage({
type: 'tuning-dialog',
stage: STAGE,
module: node.type,
label: `${node.type} 调参 - ${node.name}`,
}, '*')
}
// 监听 Shell → Stage 消息
window.addEventListener('message', (e) => {
if (e.data.type === 'subtab') {
switchInternalSubtab(e.data.subtab)
} else if (e.data.type === 'toolbar-click' && e.data.stage === STAGE) {
handleToolbarClick(e.data.id)
}
})
})()
5. Stage → Shell 协议字段速查表
| type | stage 字段 | 必带其他字段 | Shell 处理后果 |
|---|---|---|---|
| stage-ready | ✅ | version | 标记 ready,可发后续消息 |
| toolbar-inject | ✅ | buttons[] | 替换 StageBar 右半区工具组 |
| stage-status | ✅ | html | 注入 StatusBar #stageStatusSlot |
| stage-hint | ✅ | html | 注入 BottomBar #stageHintSlot |
| select-node | ✅ | node, label | 更新右 Drawer 标题为 label |
| tuning-dialog | ✅ | module, label | 拉起 TuningDialog 浮窗 |
| show-toast | ✅ | message, level? | 显示 toast |
| inspector-inject | — | html, module | 注入右 Drawer Inspector |
| module-properties-inject | — | html, module | 注入 Bottom 属性 tab |
📝
inspector-inject/module-properties-inject不带 stage 字段是历史决策(v3.4 直接以 module 为标识)。不要添加 stage 字段(避免 break Stage 端 vanilla 代码)。
6. 协议变更流程(ADR 强制)
任何对 9 类协议的变更(新增/删除/字段调整)都必须:
Step 1 · 新建 ADR
在 docs/02-products/P1-xistudio/adr/ 下新建 ADR-XX-protocol-change-{name}.md:
# ADR-06 · protocol-change · theme 消息(Shell→Stage)
## 背景
Vue Shell 主题切换需通知 iframe 内 Stage 同步切换。
## 变更
新增 Shell→Stage 消息类型 `theme`:
- `{ type: 'theme', theme: ThemeKey }`
## 影响
- 4 个 Stage HTML 需添加 message handler
- TS 类型 ShellMessage 联合类型新增 ThemeMsg
## 时间
2026-MM-DD
Step 2 · 更新本文件 §2 TS 类型
Step 3 · 更新架构 v1.2-ide-architecture.md §12.3
Step 4 · 更新 4 个 Stage HTML(同步代码)
Step 5 · 更新 useStageBridge handler 注册
Step 6 · 更新 §5 速查表
⚠️ 跳过任何一步都会导致协议契约破坏。CI 中应加 schema 校验脚本兜底。
7. 错误处理
| 错误场景 | useStageBridge 行为 |
|---|---|
| iframe contentWindow 为 null | send 静默 noop + console.warn |
收到 e.data 不带 type 字段 |
isStageMessage 返回 false → 忽略 |
| 收到未知 type | handlers.get 返回 undefined → 忽略 |
| 来源不是 stageFrame iframe | 直接 return(不打 warn 避免噪音) |
| 收到的 stage 字段与当前 stageStore.current 不符 | 当前不做处理(信任来源),未来可加严格模式拒绝 |
8. 测试用例(Vitest)
// tests/stage-bridge.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { ref } from 'vue'
import { mount } from '@vue/test-utils'
import { useStageBridge } from '@/composables/useStageBridge'
describe('useStageBridge', () => {
it('注册的 handler 在收到对应 type 消息时被调用', async () => {
const frame = document.createElement('iframe')
document.body.appendChild(frame)
const ref$ = ref(frame)
const handler = vi.fn()
// 模拟组件 setup
const wrapper = mount({
setup() {
const bridge = useStageBridge(ref$)
bridge.on('stage-ready', handler)
return {}
},
template: '<div />',
})
// 模拟 iframe 发消息
const msg = { type: 'stage-ready', stage: 'xilink', version: '1.0.0' }
Object.defineProperty(window, 'event', { value: { source: frame.contentWindow } })
window.dispatchEvent(new MessageEvent('message', { data: msg, source: frame.contentWindow as any }))
expect(handler).toHaveBeenCalledWith(msg)
wrapper.unmount()
})
it('来源校验拒绝外部 iframe 消息', async () => {
// ... 类似测试
})
it('isStageMessage 类型判别正确', () => {
expect(isStageMessage({ type: 'stage-ready', stage: 'xilink', version: '1' })).toBe(true)
expect(isStageMessage({ type: 'unknown' })).toBe(false)
expect(isStageMessage(null)).toBe(false)
})
})
9. 给 Stage HTML 端的兼容提示
虽然 Stage 是 vanilla JS(无 TS),但为了与 Vue Shell 类型契约一致,建议:
- 复制 §2 类型注释到 stage-*.html 顶部(作为参考)
- field 名严格对齐:
stageversionbuttonshtmlmodulelabelnodemessage等 - 向后兼容:Stage 端可以不发某些可选字段,但必带字段绝不能省略
- JSDoc 注解(可选):在 vanilla JS 中用 JSDoc 加类型提示
/**
* @param {{ id: string, icon: string, label: string }[]} buttons
*/
function injectToolbar(buttons) {
window.parent.postMessage({ type: 'toolbar-inject', stage: 'xilink', buttons }, '*')
}
v1.0 · 2026-05-17 · D4 前端改造手册 · postMessage 协议 TS 类型 · 配套 v1.2.2-impl §12.3