Skip to content
概述
Electron 应用通常需要让渲染进程访问主进程的能力——文件系统、系统通知、数据库操作。但直接把 Node.js 环境开放给网页脚本,意味着页面上加载的任何第三方代码都能操作系统资源。上下文隔离(contextIsolation)就是解决这个信任边界的一套机制。
基本概念
上下文隔离的核心逻辑很简单:让 preload 脚本和网页脚本跑在两个互不可见的 JavaScript 上下文里。
从 Electron 12 起,这个机制默认启用。开启后,preload 脚本中的 window 和网页中的 window 是两个不同的对象。在 preload 里写:
js
window.hello = 'wave'网页中访问 window.hello,结果是 undefined。反过来,网页往 window 上挂的属性,preload 同样看不见。
这意味着 preload 虽然运行在渲染进程内部,但拥有独立的全局作用域。Chromium 内部也用同样的机制隔离自身的逻辑,preload 只是借用了这个特性。
工作原理
上下文隔离能够生效,依赖于两个配置的组合。
一是 contextIsolation 必须为 true(默认值)。设为 true 后,Chromium 会为 preload 创建独立的 V8 上下文,与网页主世界(main world)的上下文完全分开。全局变量、原型链、DOM 访问都互不干扰。
二是 nodeIntegration 必须为 false(从 Electron 5 起也是默认值)。如果 nodeIntegration 打开,渲染进程可以直接 require Node 模块,整个隔离就形同虚设——网页脚本只要能执行代码,就能绕过 preload 直接调用 require('child_process')。在默认配置下,这两个条件都满足,不需要额外处理。
配置 preload 脚本
在主进程创建 BrowserWindow 时指定 preload 脚本的路径:
js
const { BrowserWindow } = require('electron')
const path = require('path')
const win = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
// contextIsolation 默认为 true,可以省略不写
},
})preload 脚本会在网页加载前执行,早于网页自身的任何脚本运行。preload 属性要求传入绝对路径,用相对路径可能导致不同平台下的路径解析不一致。
如果因为特殊原因需要关闭上下文隔离(不推荐),必须显式设置 contextIsolation: false,同时确认 nodeIntegration 保持 false。仅有关闭 contextIsolation 这一项操作,并不能保证安全。
preload 的执行环境与能力边界
preload 脚本可以 require 一部分 Node 模块,但不能把它当作完整的 Node 环境来用。它的权限介于 Node 进程和纯浏览器环境之间。
可以做的事情:
require('electron')导入ipcRenderer等模块- 在
sandbox: false(默认值)时访问部分 Node 内置模块,如path、fs - 通过
contextBridge向网页暴露方法
不能做的事情:
- 直接操作 DOM。preload 的
window不是网页的window,没有document对象 - 与网页共享全局变量。隔离的上下文决定了这在设计上就不被允许
- 在
sandbox: true时使用fs等敏感模块,此时 preload 只能使用contextBridge显式暴露的少数内置模块
调试时经常出现的误判,就是在 preload 里写 console.log(document.title) 然后发现 document 是 undefined——这恰好说明隔离在生效。
contextBridge.exposeInMainWorld
contextBridge 的作用是在隔离的前提下,把 preload 上下文中指定的方法暴露到网页的主世界(main world)。
基本语法:
js
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('myAPI', {
loadPreferences: () => ipcRenderer.invoke('load-prefs'),
})网页中通过 window.myAPI.loadPreferences() 调用。'myAPI' 是暴露出去的键名,可以自定义,但要避免跟网页已有的全局变量重名。
contextBridge.exposeInMainWorld 是同步方法——调用后属性就会挂到网页的 window 上(实际的同步时机是 preload 执行阶段)。preload 的运行时机早于网页脚本,所以网页在 DOMContentLoaded 或更早阶段访问 window.myAPI 时,API 已经就绪。只有在 preload 脚本本身出现错误、未能执行到 exposeInMainWorld 这一行时,网页侧才会拿不到暴露的 API。
暴露内容的类型限制
通过 contextBridge 传来的内容,不是随便什么对象都可以。以下几条限制需要留意:
不能传递包含原型链或 Symbol 的对象。比如类的实例、包含
[Symbol.iterator]方法的对象。官方文档明确要求,暴露的 API 应该是“平面”对象,只包含可序列化的值和函数。函数以代理方式传递,但函数内部闭包捕获的 preload 侧变量仍然可以访问。这和结构化克隆不同——
contextBridge使用的是 “context-safe” 包装,网页侧调用函数时,实际执行仍发生在 preload 的上下文中,所以函数体内可以安全使用ipcRenderer等模块。这个能力仅限于通过exposeInMainWorld明确定义的函数,不能动态把模块对象整体传过去。不能传递构造函数。网页侧拿到的暴露对象没有
prototype,不能使用new。
下面这种写法就是典型的违规示例:
js
// 错误写法
contextBridge.exposeInMainWorld('electron', {
ipcRenderer: ipcRenderer,
})ipcRenderer 对象包含内部属性和方法,无法安全通过 bridge 传递,会导致序列化错误。就算技术上能传过去,也非常危险——下面会说明原因。
安全封装 IPC 调用
上例直接暴露 ipcRenderer 的后果是:网页上的任何脚本(包括第三方广告、被 XSS 注入的代码)都能向主进程发送任意 IPC 消息。如果主进程注册了敏感通道的监听,攻击面就被直接打开了。
正确的做法是针对每一个 IPC 通道封装具体方法,让网页只能调用预先定义好的函数,既不知道通道名,也碰不到 ipcRenderer 本身。
主进程提供通道:
js
// main.js
const { ipcMain } = require('electron')
ipcMain.handle('load-prefs', async () => {
return { theme: 'dark', lang: 'zh' }
})preload 封装调用:
js
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('myAPI', {
loadPreferences: () => ipcRenderer.invoke('load-prefs'),
})网页侧:
js
// renderer.js
const prefs = await window.myAPI.loadPreferences()网页只知道调用 loadPreferences(),对 'load-prefs' 通道名和 ipcRenderer 一无所知。这种模式把主进程提供的能力抽象成一个有限的 API 层,preload 脚本就是这个层的实现。新增通道时,也应该在 preload 里追加方法,而不是把 IPC 模块本身扔给网页。
如果场景需要渲染进程接收主进程推送的消息,也可以暴露 onUpdate 类型的方法,内部调用 ipcRenderer.on 注册回调。这种情况需要注意监听器卸载的生命周期,避免页面销毁后残留监听。
完整示例:暴露安全的 invoke 接口
下面是一个可运行的示例。主进程提供 get-version 通道,返回系统版本信息;preload 封装成网页可调用的 API。
main.js:
js
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
app.whenReady().then(() => {
const win = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
})
ipcMain.handle('get-version', () => {
return {
electron: process.versions.electron,
node: process.versions.node,
}
})
win.loadFile('index.html')
})preload.js:
js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('app', {
getVersion: () => ipcRenderer.invoke('get-version'),
})index.html:
html
<!DOCTYPE html>
<html>
<body>
<button id="btn">Get Version</button>
<pre id="output"></pre>
<script src="renderer.js"></script>
</body>
</html>renderer.js:
js
document.getElementById('btn').addEventListener('click', async () => {
const info = await window.app.getVersion()
document.getElementById('output').textContent = JSON.stringify(info, null, 2)
})点击按钮后,网页调用 window.app.getVersion() 发起请求,数据从主进程返回到页面,整个过程中通道名和 ipcRenderer 都没有暴露到网页上下文。
在渲染进程中使用暴露的接口(TypeScript)
TypeScript 项目中,直接访问 window.app 会触发类型错误,因为全局 Window 接口上没有声明这个属性。
需要补充类型声明:
ts
// interface.d.ts
export interface IAppAPI {
getVersion: () => Promise<{ electron: string; node: string }>
}
declare global {
interface Window {
app: IAppAPI
}
}renderer.ts 中即可在 window.app 上获得完整的类型提示。
调试指南
preload 未加载导致 API 未定义
页面中 window.myAPI 为 undefined,控制台报 Cannot read properties of undefined,是开发中最常遇到的一类问题。排查路径如下:
- 检查 preload 路径。主进程
preload属性要求绝对路径,确认__dirname拼出的路径是否正确、文件名是否写错。 - 确认 preload 脚本本身没有错误。preload 中的异常(模块找不到、语法错误等)会导致
exposeInMainWorld根本未执行。可以在 preload 开头加console.log('preload loaded'),打开 DevTools 查看输出。上下文隔离开启时,preload 的控制台和网页控制台是分开的,需要在 DevTools 里切换 JavaScript 上下文才能看到 preload 的日志。 - 检查
contextIsolation是否被关闭。如果被设为false,contextBridge反而无法正常工作(contextBridge依赖上下文隔离)。此时应重新启用隔离,并改用contextBridge暴露 API。 - 确认
nodeIntegration为false。nodeIntegration开启时可能因全局污染导致奇怪行为,同时也不安全。
如果上述配置都正确但 API 仍未定义,可以尝试将 contextBridge.exposeInMainWorld 调用放在 process.once('loaded') 事件中,不过大多数情况下不需要这样做。
contextBridge 序列化错误
控制台报 An object could not be cloned 或类似错误,是因为 contextBridge 在传递参数时使用了结构化克隆算法,部分对象无法被克隆。
常见的触发场景:
exposeInMainWorld中传入了ipcRenderer等复杂对象- 暴露的函数返回了包含 Symbol、原型链、函数以外不可克隆值的数据
- 主进程
ipcMain.handle返回了特定情况下不能序列化的对象(比如直接返回 Buffer 在某些版本会报错,应转成 base64 字符串或显式序列化)
排查时可以先简化暴露的 API 为一个不依赖 IPC 的纯函数,看错误是否消失,以此区分是 contextBridge 的序列化问题还是 IPC 参数传递问题。
安全配置清单
以下项目适用于每个 BrowserWindow 的创建配置:
- [ ]
contextIsolation未显式设为false(保持默认true) - [ ]
nodeIntegration未显式设为true(保持默认false) - [ ]
sandbox若设为true,确认 preload 不依赖被禁用的 Node 模块 - [ ] 只通过
contextBridge.exposeInMainWorld暴露 API,不向网页传递ipcRenderer或任何原始模块 - [ ] 每个暴露的方法对应一个具体的 IPC 通道,不对前端暴露通道名
- [ ] 所有 IPC 参数和返回值都是可序列化的纯对象、字符串、数字、布尔值、数组
- [ ] preload 脚本使用绝对路径,文件名拼写正确
- [ ] TypeScript 项目中补充了
Window接口的类型声明
这份清单覆盖了 preload 与 contextBridge 相关的常见风险点,但不是完整的安全审计清单。
