返回博客

Electron iframe 里 window.electronAPI 是 undefined——contextBridge 不注入子帧

我们做了一个 Electron 桌面应用,用「多标签 iframe」的方式实现浏览器式多标签页——宿主页面把同一个 Next.js SPA 嵌进多个 <iframe>,每个 iframe 是一个独立标签。

某天发现,iframe 里的 RPA 编辑器一直显示「Web 端不支持此功能」的降级提示,但明明是从 Electron 打开的。打开 DevTools 看主帧:window.electronAPI 正常;切到 iframe 的 context:window.electronAPIundefined

现象

  • Electron 主帧里 window.electronAPI 一切正常,IPC 调用可以用
  • 同一个 WebContents 里的子 iframe(同源):window.electronAPI === undefined
  • 功能门控代码 isDesktop = Boolean(window.electronAPI) 在 iframe 里始终返回 false
  • iframe 里的功能全部降级到「Web 模式」,丢失所有桌面特权能力

根因

contextBridge.exposeInMainWorld 只向主帧(main frame)的 main world 注入,不会自动传播到子帧。

每个 iframe 有自己独立的 main world,即便它和父帧同源、同 WebContents,window.electronAPI 对它来说就是空气。Electron 也不提供「per-frame preload」的原生 API,webPreferences.preload 是 per-WebContents 粒度的。

坑:contextBridge 的注入边界是主帧,不是 WebContents

很多开发者以为「同源 iframe 和父帧共享 window 全局」——但在 Electron contextIsolation: true(默认值)模式下,contextBridge 只给主帧的 main world 打了一个洞,子帧各有自己的 main world,这个洞对它们不可见。用 nodeIntegrationInSubFrames: true 能「修」,但这是把 Node 能力开放给所有 iframe 内容,等于重大安全漏洞。

正解

同源 iframe 可以通过 window.parent 访问父帧的属性——这是浏览器同源策略本就允许的。父帧的 contextBridge proxy 引用可以直接赋给子帧的 window,函数调用依然走父帧的 ipcRenderer,IPC 通道没有任何变化。

最小修复:模块加载时镜像

在包裹 bridge 的集中模块顶部(如 lib/desktop.ts)加一段同步副作用:

// contextBridge.exposeInMainWorld 只注入主帧的 main world。
// 同源 iframe 需要在模块加载时从 window.parent 镜像,否则 bridge 永远是 undefined。
if (
  typeof window !== 'undefined' &&
  !window.electronAPI &&
  window.parent &&
  window.parent !== window
) {
  try {
    const fromParent = (window.parent as Window & { electronAPI?: typeof window.electronAPI }).electronAPI
    if (fromParent) {
      (window as Window & { electronAPI?: typeof window.electronAPI }).electronAPI = fromParent
    }
  } catch {
    // 跨域父帧——same-origin policy 会抛异常,直接忽略
  }
}

为什么必须是模块级(而不是 useEffect):React useEffect 在首次渲染之后才跑,isDesktop() 的判断发生在渲染期,会先看到 undefined,触发降级渲染,下一帧才修正——这会造成一次 UI 闪烁,并可能有副作用。模块级副作用在任何 consumer import 前就执行完毕。

正解:在 lib/desktop.ts 顶部同步从 window.parent 镜像

一次同步属性读取,window.parent.electronAPI 就是父帧 contextBridge proxy 的引用。把它赋给子帧的 window.electronAPI,所有后续 IPC 调用照常工作,不需要重新建立通道。

必须跟上的二次修复:隔离「每进程启动一次」的副作用

bridge 镜像成功后,原本因为 isDesktop() === false 而跳过的代码,现在在每个 iframe 里都会运行。典型的危险代码:

useEffect(() => {
  if (!isDesktop()) return  // 以前 iframe 里这里直接 return,现在不会了
  void registerDevice()     // POST /api/install/bind,会触发 N 次
  startHeartbeat()          // 会建立 N 个 IPC 监听器
  bridge.events.on('log', ingestLog)  // 每条日志被消费 N 次
}, [])

所有「每个应用实例启动一次」的副作用,都要加 iframe 守卫:

useEffect(() => {
  // 仅在主帧执行,防止每个 iframe 标签各自触发一次
  if (typeof window !== 'undefined' && window.parent !== window) return
  registerDevice()
  startHeartbeat()
}, [])

需要审计的典型场景:设备绑定/注册接口、事件监听器注册、心跳轮询、localStorage 「启动一次」写入。

验证方法

打开 Electron 应用,开启至少 2 个 iframe 标签,在 DevTools 切到 iframe context,执行 window.electronAPI,应返回对象而非 undefined。同时检查 Network 面板,「设备绑定」类接口在整个 App 生命周期只应该出现 1 次 POST,不是 N 次。

一句话外卖

contextBridge 的边界是「主帧的 main world」,不是整个 WebContents——同源 iframe 镜像一下 window.parent.bridge 即可,但别忘了同时给所有 boot-once 副作用加 window.parent !== window 守卫。