Skip to main content

Electron contextBridge 是什么?

· 11 min read

contextBridge 解决的不是"怎么把 API 传给页面",而是"怎么让页面用 Node 能力但看不到 Node 环境"。

  1. 本质:在 isolated world 创建一道有边界的桥,preload 能跑 Node,页面只能摸到白名单 API
  2. 机制:preload 跑在 isolated world,主世界(页面)拿不到 preload 的闭包变量,只能拿到白名单出口
  3. 能力exposeInMainWorld 暴露受限 API,序列化约束守住边界(Function、Symbol、Error 不能裸传)
  4. 类型系统:preload 内 typeof api + 全局 Window['electronAPI'] 声明,编译期对齐
  5. 反模式:暴露整个 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.secretundefined

这个隔离不是 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 的工作模型

关键事实:

  1. DOM 共享——documentwindow 是同一个对象,但全局变量不共享
  2. API 只能"过桥"——contextBridge 在跨世界时调用结构化克隆(Structured Clone) 序列化参数和返回值
  3. 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) 直接炸。

六、Function 跨世界调用

Function 不能序列化,但 contextBridge 提供了代理机制:你把回调函数暴露出去,contextBridge 会创建一个跨世界的代理对象,渲染进程调用代理时:

  1. 代理把参数序列化(Structured Clone)
  2. 发到 isolated world 那边的真函数
  3. 真函数执行完,把返回值序列化回来
// preload.ts
contextBridge.exposeInMainWorld('api', {
// 这里的 cb 在 main world,但调用时上下文跳到 isolated world
subscribe: (cb: (data: string) => void) => {
const handler = (_e: unknown, data: string) => cb(data);
ipcRenderer.on('event', handler);
return () => ipcRenderer.removeListener('event', handler);
},
});

// 页面里
const off = window.api.subscribe((data) => {
console.log('收到:', data);
});

// 取消订阅
off();

两个要点:

  1. 返回的 off 函数也是代理——跨世界调用 ipcRenderer.removeListener
  2. 参数和返回值都要可序列化——回调里返回复杂对象(比如类实例、Buffer)会失败

错误用法:

// ❌ 回调里返回 Map / Set / 类实例
window.api.subscribe((data) => {
const map = new Map([['k', 'v']]);
return map; // 跨世界调用时被序列化,原型链丢失
});

// ❌ 回调里抛 Error 对象
window.api.subscribe((data) => {
throw new Error('x'); // 渲染进程拿不到 Error 类型
});

七、类型系统对接

类型同步的最大坑是 preload 改了 API 但 Window 声明忘同步,运行时能跑,但 TS 编译期报错或拼错字段。

最佳实践是 preload 内导出 typeof api,Window 声明复用:

// preload.ts
const api = { /* ... */ };
contextBridge.exposeInMainWorld('electronAPI', api);
export type ElectronAPI = typeof api; // ← 自动追踪 api 的形状
// global.d.ts
import type { ElectronAPI } from './preload';
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
export {};

这样改 api 的字段,ElectronAPI 自动跟,Window['electronAPI'] 也跟,一处改、全处生效

主进程端如果用 zod 之类的 schema 校验,可以把 schema 类型也导出来,preload 里复用:

// shared/schemas.ts
import { z } from 'zod';
export const ReadFileReq = z.object({ path: z.string() });
export type ReadFileReq = z.infer<typeof ReadFileReq>;
// preload.ts
import type { ReadFileReq } from '../shared/schemas';
const api = {
readFile: (req: ReadFileReq) => ipcRenderer.invoke('fs:read', req),
};

参数和返回值都用 zod 在 main 端再校验一次,类型和运行时双保险。

八、常见反模式

反模式 1:暴露整个 ipcRenderer

// ❌ 等于把 IPC 总线交给渲染进程
contextBridge.exposeInMainWorld('ipc', ipcRenderer);

任何 XSS 都能 window.ipc.invoke('any-channel')——白名单失效,等于 nodeIntegration: true

反模式 2:暴露 processrequire

// ❌ 直接给 Node 权限
contextBridge.exposeInMainWorld('node', { process, require });

process.versionsprocess.env.NODE_ENV 是常见的"调试方便"理由,但任何暴露都意味着 XSS 能读环境变量、执行任意路径 require。

反模式 3:contextIsolation: false

new BrowserWindow({
webPreferences: {
contextIsolation: false, // ❌
},
});

整个 isolated world 机制失效——preload 和页面共享全局作用域,XSS 能直接读 preload 的闭包变量、覆盖 window.electronAPIObject.prototype 污染攻击重出江湖。

反模式 4:在 preload 里跑业务逻辑

// ❌ preload 不该做业务决策
contextBridge.exposeInMainWorld('api', {
readConfig: () => {
const config = JSON.parse(fs.readFileSync('config.json', 'utf-8'));
return decrypt(config.secret); // 密钥逻辑跑在 preload?
},
});

preload 只负责透传,业务逻辑、密钥、权限校验都在 main 端。

九、调试技巧

1. 确认 preload 真的加载了

DevTools Console 里:

console.log(window.electronAPI); // 应该是 { readFile, writeFile, ... }
console.log(Object.keys(window.electronAPI)); // 列出所有方法

undefined → preload 没加载或路径错;方法是空对象 → preload 加载了但 contextBridge 没调。

2. 捕获 preload 的错误

preload 脚本的错误默认不会显示在 DevTools Console,要 main 端捕获:

// main.ts
win.webContents.on('preload-error', (_e, preloadPath, error) => {
console.error(`[preload error] ${preloadPath}:`, error);
});

3. 看 isolated world 的全局变量

DevTools 默认连的是 main world。要看 isolated world(preload)的变量:

// 在 main world 里看不到 preload 的变量,但能通过 contextBridge 暴露的 API 调它
// 要直接看 isolated world 的全局变量,用 Chrome DevTools 的 "Contexts" 下拉框切换

4. 类型不同步的快速定位

window.electronAPI.readfile(小写)运行时 undefined、TS 编译期不报错 → global.d.tsWindow 接口忘了声明,或者 preload 的 api 对象拼错了字段名。typeof api 而不是手写 interface,避免漂移。

十、总结

contextBridge 的核心是隔离世界 + 序列化约束这两件事,理解了这个,整个 Electron 安全模型就串起来了:

  1. 隔离世界——preload 跑在 isolated world,页面看不到 preload 的闭包变量,只能摸白名单出口
  2. 序列化约束——跨世界传值走 Structured Clone,Function 被代理、Error/类实例会丢失信息
  3. 类型同步——typeof api 导出 + Window 声明一处改全处生效
  4. 白名单出口——绝不暴露 ipcRenderer / process / require,只暴露业务 API
  5. 配套配置——contextIsolation: true + nodeIntegration: false + sandbox: true,缺一不可

contextBridge 不是装饰品,是 Electron 区别于"在浏览器里跑 Node"的关键设计——它让你既享受 Node 的能力,又不让渲染进程因为 XSS 而丢整个系统。

References

  1. Electron 官方文档 - contextBridge —— Electron 官方, 2026
  2. Electron Security Checklist —— Electron 官方, 2026
  3. Process Sandboxing —— Electron 官方, 2026
  4. HTML Living Standard - Structured Clone —— WHATWG, 2026