从编辑器到运行时:跨窗口通信架构详解

在复杂的 Web 编辑器应用中,"预览"功能往往涉及主窗口与子窗口的协同工作。本文将深度剖析从点击工具栏"预览"按钮,到运行时窗口加载完毕并接收场景数据的完整链路,解读 Vue3 + TypeScript 环境下多窗口通信的方法。

一、需求场景与架构挑战

我们的目标是实现这样一个工作流:

  1. 用户在编辑器(Editor)中配置三维场景

  2. 点击顶部工具栏的"运行预览"按钮

  3. 系统智能判断:复用已打开的预览窗口,或新开窗口

  4. 预览窗口(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 时序控制的艺术

正确的初始化顺序至关重要:

  1. Editor 侧 :监听 message → 判断窗口状态 → 决定刷新或新开 → (窗口加载中...)→ 接收 init → 发送数据

  2. 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
  ↓
场景加载完成

调试建议

  1. 控制台监控 :在 setupInitListeneronEditorMessage 中添加 console.log 确认消息收发

  2. 网络面板 :观察 cc_runtime.html 的加载时机,确认是否在数据发送前完成

  3. 断点设置 :在 event.source !== window.opener 处断点,验证消息来源安全性

六、结语

这套架构展现了松散耦合可靠传输的平衡:

  • TopToolbar 只关心"发出指令",不关心窗口细节

  • EditorApp 担任"窗口管理员"和"通信中转站"

  • Runtime 保持独立运行能力,通过标准协议接收数据

这种模式不仅适用于 3D 编辑器预览,也可推广到任何需要"主-从"窗口协同的复杂 Web 应用(如代码编辑器的实时预览、设计工具的分离预览窗口等)。理解其中的时序控制、内存管理和安全校验,对构建健壮的多窗口应用至关重要。

相关推荐
lsjweiyi1 年前
h5适配iOS——window.open失效
ios·h5·支付宝支付·window.open