Electron contextBridge 是什么?
contextBridge 解决的不是"怎么把 API 传给页面",而是"怎么让页面用 Node 能力但看不到 Node 环境"。
- 本质:在 isolated world 创建一道有边界的桥,preload 能跑 Node,页面只能摸到白名单 API
- 机制:preload 跑在 isolated world,主世界(页面)拿不到 preload 的闭包变量,只能拿到白名单出口
- 能力:
exposeInMainWorld暴露受限 API,序列化约束守住边界(Function、Symbol、Error 不能裸传) - 类型系统:preload 内
typeof api+ 全局Window['electronAPI']声明,编译期对齐 - 反模式:暴露整个
ipcRenderer、暴露process、关掉contextIsolation
contextBridge 是 Electron 安全模型的核心组件,但它的工作机制在多数教程里被一笔带过——大家只记住了 contextBridge.exposeInMainWorld('api', {...}) 这行代码,没搞懂为什么这行代码比"把 ipcRenderer 整个塞进 window"安全。
这篇文章把 contextBridge 一次讲透:它背后是 isolated world 的边界设计,序列化约束为什么存在,Function 跨世界调用是怎么实现的,以及类型系统怎么和它对齐。
一、问题的根源:preload 到底跑在哪个世界?
Electron 启动一个窗口时,至少会创建两个 V8 实例:
- 主世界(main world):跑你的页面代码(React/Vue/原生 JS)
- isolated world:跑 preload 脚本
两个世界共享同一个 DOM,但全局作用域完全隔离。preload 里写的 const secret = 'xxx'、加载的 require('fs')、闭包里的私有变量——页面 JS 全部看不到,window.secret 是 undefined。
这个隔离不是 JavaScript 沙箱,是 Chromium 级别的 C++ 进程隔离,跟 DevTools Console 跟页面代码的关系一样:
// 页面代码(main world)
console.log(window.secret); // undefined,访问不到 preload 的变量
// 但 DOM 是同一个
document.body.style.color = 'red'; // preload 里改也生效,反过来也一样
contextBridge 就是在两个世界之间架的一道有边界的桥。它让你能:
- 把 preload 里的 Node API 白名单化暴露给页面
- 暴露的对象只能包含可序列化值,Function 会被包装成跨世界代理
- 页面看不到 preload 的内部实现,只能调暴露出来的方法
二、contextBridge 的工作模型
关键事实:
- DOM 共享——
document、window是同一个对象,但全局变量不共享 - API 只能"过桥"——contextBridge 在跨世界时调用结构化克隆(Structured Clone) 序列化参数和返回值
- Function 被代理包装——页面传过去的回调函数不会真的"传过去",而是创建一个跨世界代理对象,调用时序列化和反序列化来回
三、API 形态
contextBridge 只暴露两个方法,命名都直白:
exposeInMainWorld(apiKey, api)
把 API 挂到 window[apiKey] 上,让 main world(页面代码)能访问:
// preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
readFile: (path: string) => ipcRenderer.invoke('fs:read', path),
writeFile: (path: string, content: string) =>
ipcRenderer.invoke('fs:write', path, content),
});
// 页面里用
const text = await window.electronAPI.readFile('/tmp/a.txt');
exposeInIsolatedWorld(isolationId, apiKey, api)
把 API 暴露到自定义隔离世界,主要给 <webview> 嵌入场景用——嵌入的 webview 默认有自己的 isolated world,需要往那里挂 API 时用它。
绝大多数桌面应用场景只会用到 exposeInMainWorld。
四、完整实战代码
这是最小可运行的三件套:preload + main + 类型声明。
preload.ts
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
// 白名单:只暴露 这些 API 给页面
const api = {
openFile: () => ipcRenderer.invoke('dialog:openFile'),
readFile: (path: string) => ipcRenderer.invoke('fs:read', path),
writeFile: (path: string, content: string) =>
ipcRenderer.invoke('fs:write', path, content),
// 订阅主进程推送(返回 unsubscribe 函数)
onFileChanged: (cb: (path: string) => void) => {
const handler = (_e: IpcRendererEvent, p: string) => cb(p);
ipcRenderer.on('file:changed', handler);
return () => ipcRenderer.removeListener('file:changed', handler);
},
};
contextBridge.exposeInMainWorld('electronAPI', api);
// 导出类型给全局声明用
export type ElectronAPI = typeof api;
src/global.d.ts
import type { ElectronAPI } from './preload';
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
export {};
main.ts
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
import { promises as fs } from 'fs';
import path from 'path';
ipcMain.handle('dialog:openFile', async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
});
return result.filePaths[0] ?? null;
});
ipcMain.handle('fs:read', async (_e, p: string) => {
// 必须校验 sender:只允许读指定目录
if (!p.startsWith('/allowed/')) throw new Error('Forbidden path');
return await fs.readFile(p, 'utf-8');
});
ipcMain.handle('fs:write', async (_e, p: string, content: string) => {
if (!p.startsWith('/allowed/')) throw new Error('Forbidden path');
await fs.writeFile(p, content);
return { ok: true };
});
app.whenReady().then(() => {
const win = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
win.loadFile('index.html');
});
async function onOpen() {
const path = await window.electronAPI.openFile();
if (!path) return;
const text = await window.electronAPI.readFile(path);
console.log(text);
}
类型系统把整个链路串起来:preload 里改了 api 的形状,Window['electronAPI'] 编译期报错,VSCode 自动补全跟得上。
五、序列化约束:哪些能传、哪些不能
contextBridge 在跨世界传递数据时使用 Structured Clone——这是浏览器自带的深拷贝算法,不支持函数、Symbol、Error、原型链。
| 数据类型 | 能传? | 备注 |
|---|---|---|
string / number / boolean / null | ✅ | 原生支持 |
Date / RegExp / Map / Set | ✅ | 原生支持 |
Array / Object(plain) | ✅ | 原生支持 |
ArrayBuffer / TypedArray | ✅ | 二进制数据 |
Function / Symbol | ❌ | 必须包装成跨世界代理 |
Error | ⚠️ | 能传但只剩 message / name / stack,自定义属性会丢 |
| 类实例 | ⚠️ | 原型链丢失,只剩 own properties |
| DOM 节点 | ❌ | 跨世界不持有引用 |
Error 这条最坑——主进程抛 new MyError('x', { code: 42 }),渲染进程拿到的是 { message: 'x', name: 'Error' },code 字段没了。自定义错误类型不能用 instanceof 判断,必须走 plain object + code 字段:
// main 端
class AppError extends Error {
constructor(public code: string, msg: string) {
super(msg);
}
}
ipcMain.handle('fs:read', async () => {
throw new AppError('E_PERM', 'Permission denied');
});
// renderer 端 ❌ 拿不到 instanceof 信息
try {
await window.api.readFile('/x');
} catch (e) {
if (e instanceof AppError) { /* 永远 false */ }
}
// ✅ 改成 plain object
ipcMain.handle('fs:read', async () => {
const e: Error & { code: string } = Object.assign(
new Error('Permission denied'),
{ code: 'E_PERM' },
);
throw e;
});
类实例那条更隐蔽——一个 Map<number, User> 经过跨世界变成 Object,你 users.get(1) 直接炸。