首页 / Electron 入门教程 / contextBridge 暴露 API

Electron 入门教程

contextBridge 暴露 API

本教程共 45 篇 · 第 12 篇 · 更新于 2026-08-03

ElectroncontextBridgepreload安全暴露API 设计

12. contextBridge 暴露 API

上一章建立了 IPC 的心智模型。现在讲具体怎么把能力「安全」地交给渲染进程——主角就是 contextBridge。它是隔离世界里,preload 通往网页的唯一合规桥梁。

本节目标

  • 掌握 contextBridge.exposeInMainWorld 的基本用法。
  • 学会设计暴露出来的 API 形状(函数为主、对象可嵌套)。
  • 理解为什么不能把整个 ipcRenderer 或 Node 直接交出去。
  • 知道过桥的数据会被复制且冻结。
  • 了解 exposeInIsolatedWorld 等进阶入口。

12-1 exposeInMainWorld 基本用法

contextBridge 是渲染进程模块,通常在 preload 里使用。它的核心方法是 exposeInMainWorld(apiKey, api):第一个参数是挂到 window 上的键名,第二个参数是你要暴露的 API 对象。

暴露之后,网页里的业务代码就能通过 window[apiKey] 访问它。下面这个最小例子,把一次 IPC 调用包装成 window.electron.doThing()

// preload.js(Preload 脚本,隔离世界)
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electron', {
  doThing: () => ipcRenderer.send('do-a-thing')
})

// 渲染进程(主世界)里这样调用
// window.electron.doThing()

注意:require('electron') 在 preload 沙箱环境下是允许的,它拿到的是渲染端模块集合。这里只取了 contextBridgeipcRenderer 两个,没有暴露多余能力。

12-2 API 的形状怎么设计

api 参数的取值有严格限制:它可以是函数、字符串、数字、数组、布尔,或者「键为字符串、值为上述类型或嵌套对象」的普通对象。换句话说,你暴露的是一个干净的、可序列化的结构。

推荐以「函数」为主。函数会被代理到另一侧执行,既能封装逻辑,又不会把内部状态直接泄漏。下面是一个较复杂的例子,展示嵌套对象与异步函数。

// preload.js(Preload 脚本)
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electron', {
  doThing: () => ipcRenderer.send('do-a-thing'),
  anAsyncFunction: async () => 123,
  data: {
    myFlags: ['a', 'b', 'c'],
    bootTime: 1234
  },
  nestedAPI: {
    evenDeeper: {
      fn: () => ({ returnData: 123 })
    }
  }
})

设计原则:一个功能一个函数,函数名表达意图(如 openFilesavePrefs)。不要暴露 ipcRenderer 本身,让用户能发任意通道消息。

12-3 为什么不能暴露整个 ipcRenderer

这是安全红线。如果你这样写:

// ❌ 不安全:直接把整个 ipcRenderer 交出去
contextBridge.exposeInMainWorld('myAPI', {
  send: ipcRenderer.send
})

网页里的任何脚本(包括可能被注入的恶意脚本)都能 window.myAPI.send('任意通道', 任意数据)。它等于拿到了向主进程发任意指令的万能钥匙。这种写法官方明确点名为「不安全」。

正确做法是逐接口封装,每个函数只对应一个明确动作,必要时还能在 preload 里做参数校验:

// ✅ 安全:一个函数只做一件事
contextBridge.exposeInMainWorld('myAPI', {
  loadPreferences: () => ipcRenderer.invoke('load-prefs')
})

这样即使网页被攻破,攻击者最多只能触发你预先定义好的那几个动作,破坏面被锁死在最小范围。

提示:如果你干脆把 ipcRenderer 整个模块当参数丢给 exposeInMainWorld,网页那边只会拿到一个空对象——Electron 已经明确禁止整体过桥。所以「偷懒暴露」这条路本身就走不通,老老实实按动作封装即可。

12-4 过桥的数据会被复制且冻结

一个重要特性:通过 contextBridge 传过去的函数会被代理,而其它值(字符串、数字、对象等)则是「复制」并且「冻结(frozen)」的。

这意味着:preload 这边改了某个暴露出去的对象,网页那边不会跟着变;反过来网页改了,preload 也不会变。两边拿到的是各自的副本,互不影响。

// preload.js(Preload 脚本)
const { contextBridge } = require('electron')

const config = { theme: 'dark' }

contextBridge.exposeInMainWorld('api', {
  getConfig: () => config,        // 返回的是副本
  setTheme: (t) => { config.theme = t } // 想改状态,要靠函数
})

所以「共享可变状态」不能靠直接暴露对象实现。要改状态,就暴露一个会改 preload 内部变量的函数,或者通过 IPC 让主进程来统一管理(第 15 章细讲)。

12-5 也能暴露受限的 Node 能力

contextBridge 不止能桥接 IPC,也能把部分 Node API 安全地交给渲染进程。前提是你要非常清楚风险:很多 Node API 能访问本机资源,暴露给不可信的远程内容尤其危险。

下面这个官方示例,把 crypto 的 SHA-256 计算暴露成 window.nodeCrypto.sha256sum。它只暴露一个纯计算函数,不暴露文件或网络能力,风险可控。

// preload.js(Preload 脚本)
const { contextBridge } = require('electron')
const crypto = require('node:crypto')

contextBridge.exposeInMainWorld('nodeCrypto', {
  sha256sum (data) {
    const hash = crypto.createHash('sha256')
    hash.update(data)
    return hash.digest('hex')
  }
})

提示:这个例子有个前提。沙箱开启时,preload 的 require 只是个裁剪版 polyfill,能引入的仅有 electron 的渲染端模块和 eventstimersurl 三个 Node 模块,node:crypto 并不在其中。也就是说,上面这段代码只有在该窗口设了 sandbox: false 时才跑得起来。

所以更推荐的做法是:把哈希计算这类活儿交给主进程,渲染侧通过 ipcRenderer.invoke 拿结果。这样既不必关沙箱,主进程还能顺便校验参数。

12-6 进阶:exposeInIsolatedWorld

除了 exposeInMainWorld,还有 exposeInIsolatedWorld(worldId, apiKey, api)。它把 API 注入到指定 id 的隔离世界,而不是默认主世界。0 是默认世界,999 是 Electron 上下文隔离用的世界,自定义建议用 1000 以上。

// preload.js(Preload 脚本)
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInIsolatedWorld(
  1004,
  'electron',
  {
    doThing: () => ipcRenderer.send('do-a-thing')
  }
)
// 在 id 为 1004 的隔离世界里访问:window.electron.doThing()

对大多数应用,exposeInMainWorld 已足够。多世界隔离属于较进阶的用法,常用于需要多个互不干扰的脚本环境时。

12-7 给暴露的 API 加类型(TypeScript)

如果你的项目用 TypeScript,渲染进程的 window 默认不知道 electronAPI 的存在,编辑器会报类型错误。解决方法是写一个声明文件,给 window 做全局增强。

// interface.d.ts
export interface IElectronAPI {
  doThing: () => void
  loadPreferences: () => Promise<string>
}

declare global {
  interface Window {
    electronAPI: IElectronAPI
  }
}

这样在渲染进程的 .ts 文件里写 window.electronAPI.loadPreferences(),编译器就能正确推导返回类型,自动补全也会生效。注意声明文件要和 preload 实际暴露的结构保持一致,否则类型与运行时不符,反而会造成误导。

即使不用 TypeScript,这个思路也值得借鉴:把 preload 暴露的接口当成一份契约,集中记录「页面能用哪些函数、参数与返回值是什么」。契约清晰,渲染进程和主进程的开发才能各写各的而不出错。

12-8 一个实用禁忌:别直接暴露 process

有些开发者图省事,会想把整个 process 对象暴露给页面,好让渲染进程读环境变量、版本号。这是危险做法:process 包含大量系统信息与能力,暴露出去等于把攻击者需要的情报都摆到台面上。

正确做法是按需挑选。只要版本号,就从 process.versions 取那几个字段包成函数返回。只要某个环境变量,就显式读它再返回一个字符串。给出的信息越少,后面要担心的事情就越少。

// preload.js(Preload 脚本)—— 只暴露需要的字段
const { contextBridge } = require('electron')

contextBridge.exposeInMainWorld('envAPI', {
  platform: () => process.platform,
  versions: () => ({
    node: process.versions.node,
    electron: process.versions.electron
  })
})

记住一个原则:渲染进程拿到的每一份能力,都是潜在攻击面。宁可给函数也不给对象,宁可给副本也不给引用。守住这条线,contextBridge 才是安全桥,而不是后门。

常见误区

  • 误区一:直接暴露 ipcRenderer 图省事。这是高危写法,应逐接口封装。
  • 误区二:以为暴露的对象两边会同步变化。过桥的值是复制且冻结的,改状态要靠函数或 IPC。
  • 误区三:暴露 Node 的 fs 让页面自己读写文件。应走主进程代理并做权限校验。

小结

contextBridge 是隔离世界里通往网页的唯一合规桥梁。用 exposeInMainWorld 把能力暴露出去时,尽量以函数为主,形状保持干净。

有两件事要刻在脑子里:整个 ipcRenderer 或 Node 模块不能交出去,过桥的数据是复制且冻结的副本。把这层 API 设计好,渲染进程用起来既安全又顺手。