在复杂的 Web 编辑器应用中,"预览"功能往往涉及主窗口与子窗口的协同工作。本文将深度剖析从点击工具栏"预览"按钮,到运行时窗口加载完毕并接收场景数据的完整链路,解读 Vue3 + TypeScript 环境下多窗口通信的方法。
一、需求场景与架构挑战
我们的目标是实现这样一个工作流:
-
用户在编辑器(Editor)中配置三维场景
-
点击顶部工具栏的"运行预览"按钮
-
系统智能判断:复用已打开的预览窗口,或新开窗口
-
预览窗口(Runtime)加载完毕后,自动接收 Editor 导出的运行数据
这涉及同源跨窗口通信 、窗口生命周期管理 、异步握手协议等多个技术难点。
二、整体通信链路概览
整个流程可分为三个阶段:内部事件触发 → 窗口管理 → 双向握手传输。

三、分层代码解析
3.1 触发层:TopToolbar.vue 的事件封装
工具栏组件不直接处理窗口逻辑,而是通过 EventBus 解耦:
TypeScript
// 按钮配置定义发射参数
{
cmd: 'runtimePreview',
icon: IconPlayForward,
tooltip: '运行预览',
emitArg: false // false表示复用窗口,true强制新开
}
// 点击处理:统一包装为 emitter 调用
const handleClick = (btn: any) => {
if (btn.emitArg !== undefined) {
emitter.emit(btn.cmd, btn.emitArg) // 携带参数发射
} else {
emitter.emit(btn.cmd)
}
}
设计亮点 :通过 emitArg 配置化区分"新开"与"复用"模式,保持组件纯粹性。
3.2 控制层:EditorApp.vue 的窗口管理策略
EditorApp 是实际的管理中枢,核心在于 handleRuntimePreview 函数:
窗口复用机制
TypeScript
let runtimeWindow: Window | null = null; // 缓存窗口引用
const handleRuntimePreview = (openNew?: boolean) => {
const runtimeUrl = new URL('cc_runtime.html', window.location.href).href;
// 场景1:复用窗口(智能刷新)
if (!openNew && runtimeWindow && !runtimeWindow.closed) {
setupInitListener(runtimeWindow); // 关键:先设置监听器
runtimeWindow.location.reload(); // 再刷新页面
return;
}
// 场景2:新开窗口
runtimeWindow = window.open(runtimeUrl);
if (!runtimeWindow) {
MessagePlugin.warning('请允许弹窗以打开运行页');
return;
}
setupInitListener(runtimeWindow);
};
关键细节 :必须在 reload() 之前设置 message 监听器,因为页面刷新是异步的,若先刷新后监听,可能错过 Runtime 初始化完成的消息。
双向握手协议实现
TypeScript
const setupInitListener = (win: Window) => {
const onMessage = (event: MessageEvent) => {
// 安全校验:确认消息来源
if (event.source !== win || event.data?.type !== 'init') return;
// 只监听一次,避免内存泄漏
window.removeEventListener('message', onMessage);
// 获取序列化后的场景数据
const editorSystem = EditorSystem.instance;
const jsonString = JSON.stringify(editorSystem.getDataRuntime());
// 发送执行指令,而非直接传递数据
win.postMessage({
type: 'callMethod',
methodName: 'setRuntimeByJson',
args: [jsonString] // 数组形式支持多参数
}, '*');
};
window.addEventListener('message', onMessage);
};
协议设计:不直接传输数据,而是发送"方法调用指令"(RPC 风格),这让 Runtime 端可以灵活处理多种不同的调用场景。
3.3 运行时层:Runtime.vue 的协议处理
Runtime 端需要完成:初始化 Babylon.js → 通知父窗口就绪 → 接收并加载数据。
生命周期钩子与全局暴露
TypeScript
const initRuntimeScene = () => {
// ... 初始化 Engine 和 Scene ...
// 关键:将实例方法挂载到 window,供父窗口通过 postMessage 调用
(window as any).loadRuntimeFile = runtimeSystem.setByFile.bind(runtimeSystem);
(window as any).setRuntimeByJson = runtimeSystem.setByJson.bind(runtimeSystem);
// 通知父窗口:我已就绪,可以发送数据了
if (window.opener && !(window.opener as any).closed) {
window.opener.postMessage({ type: 'init' }, '*');
}
}
消息路由中心
TypeScript
const onEditorMessage = (event: MessageEvent) => {
// 安全校验:只接受来自父窗口的消息
if (event.source !== window.opener) return;
const { type, methodName, args } = event.data || {};
if (type === 'callMethod' && methodName && Array.isArray(args)) {
const fn = (window as any)[methodName];
if (typeof fn === 'function') {
fn(...args); // 动态调用:setRuntimeByJson(jsonString)
}
}
};
onMounted(() => {
// ...
window.addEventListener('message', onEditorMessage);
});
四、关键机制深度解读
4.1 窗口状态检测
runtimeWindow.closed 是判断窗口是否被用户手动关闭的唯一可靠方式。注意:跨域情况下无法访问 closed 属性以外的任何属性。
4.2 时序控制的艺术
正确的初始化顺序至关重要:
-
Editor 侧 :监听
message→ 判断窗口状态 → 决定刷新或新开 → (窗口加载中...)→ 接收init→ 发送数据 -
Runtime 侧 :页面加载 → Vue 挂载 → Babylon 初始化 → 发送
init→ 监听message→ 接收调用指令 → 执行方法
若顺序错乱(如 Runtime 先发送 init,Editor 后设置监听器),数据将丢失。
4.3 方法暴露的安全考量
代码中使用 (window as any) 强制扩展 Window 接口,这在 TypeScript 中需要谨慎。生产环境建议:
TypeScript
// 定义严格的接口
interface RuntimeGlobal {
setRuntimeByJson: (data: string) => void;
[key: string]: any;
}
// 类型断言
(window as unknown as { mkOp: RuntimeGlobal }).mkOp = { ... };
4.4 EventBus 的内存管理
EditorApp 在 onUnmounted 中显式移除监听,防止组件热更新或路由切换时产生重复监听:
TypeScript
onUnmounted(() => {
emitter.off('runtimePreview', onRuntimePreview);
// ... 其他清理
});
五、流程总结与调试技巧
完整数据流复盘
用户点击
↓
TopToolbar 发射 'runtimePreview' 事件(携带 false 参数)
↓
EditorApp 接收 → handleRuntimePreview(false)
↓
检查 runtimeWindow 变量:
├─ 存在且未关闭 → reload() 刷新,并等待新的 init 消息
└─ 不存在或已关闭 → window.open() 新开
↓
setupInitListener 注册一次性消息监听器
↓
cc_runtime.html 加载,Runtime.vue 挂载
↓
initRuntimeScene 执行完毕,向 opener postMessage({type: 'init'})
↓
EditorApp 的 onMessage 触发,移除监听器
↓
序列化场景数据,postMessage 发送 callMethod 指令
↓
Runtime.vue onEditorMessage 接收,调用 setRuntimeByJson
↓
场景加载完成
调试建议
-
控制台监控 :在
setupInitListener和onEditorMessage中添加console.log确认消息收发 -
网络面板 :观察
cc_runtime.html的加载时机,确认是否在数据发送前完成 -
断点设置 :在
event.source !== window.opener处断点,验证消息来源安全性
六、结语
这套架构展现了松散耦合 与可靠传输的平衡:
-
TopToolbar 只关心"发出指令",不关心窗口细节
-
EditorApp 担任"窗口管理员"和"通信中转站"
-
Runtime 保持独立运行能力,通过标准协议接收数据
这种模式不仅适用于 3D 编辑器预览,也可推广到任何需要"主-从"窗口协同的复杂 Web 应用(如代码编辑器的实时预览、设计工具的分离预览窗口等)。理解其中的时序控制、内存管理和安全校验,对构建健壮的多窗口应用至关重要。