contextBridge 暴露 API
本教程共 45 篇 · 第 12 篇 · 更新于 2026-08-03
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 沙箱环境下是允许的,它拿到的是渲染端模块集合。这里只取了 contextBridge 和 ipcRenderer 两个,没有暴露多余能力。
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 })
}
}
})
设计原则:一个功能一个函数,函数名表达意图(如 openFile、savePrefs)。不要暴露 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 的渲染端模块和 events、timers、url 三个 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 设计好,渲染进程用起来既安全又顺手。