Electron preload 脚本
preload 脚本运行在 renderer 页面加载前,是 Electron 中建立最小权限桥接的安全边界;在默认 sandbox + contextIsolation 下应通过 contextBridge 暴露窄 API,而不是把 Node 或 ipcRenderer 整体交给页面。
[!info] related notes
- 所属 MOC: Electron MOC
- 进程模型: Electron 渲染进程, Electron 进程模型
- 隔离: Electron contextIsolation
- 桥接: Electron contextBridge
- IPC: Electron IPC 模式
- 安全: Electron 安全检查清单
Electron preload 脚本
一句话定义
preload 是 BrowserWindow 在页面脚本执行前加载的一段桥接代码。
它的核心职责不是“让 renderer 获得 Node”,而是:
在高权限 Electron/Main 能力与低权限页面之间暴露最小、稳定、可验证的 API。
当前默认安全模型
现代 Electron 的默认模型包含两个关键变化:
- Electron 12 起,
contextIsolation默认开启; - Electron 20 起,renderer sandbox 默认开启。
因此默认 preload:
不再拥有完整 Node.js 环境。
在 sandboxed renderer 中,preload 只有受限的 polyfilled Node/Electron API 子集,例如部分 electron 模块以及 events、timers、url 等。
所以旧心智模型:
preload
= full Node access
在现代默认配置下已经不准确。
contextIsolation
启用 contextIsolation 后:
preload
→ Isolated World
web page
→ Main World
两个 world 不共享同一个普通 window 对象。
因此:
// preload
window.myApi = ...
不能再作为推荐桥接方式。
应使用:
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('appApi', {
...
})
preload 应暴露什么
优先暴露 业务语义 API:
window.appApi = {
openFile: () => Promise<FileInfo>,
saveSettings: (input) => Promise<void>,
getVersion: () => Promise<string>,
}
而不是:
window.electron = {
ipcRenderer,
fs,
shell,
...
}
前者表达 capability contract。
后者把底层权限面直接暴露给页面。
IPC 的正确边界
典型调用链:
Renderer
→ window.appApi.openFile()
→ preload validates / maps
→ ipcRenderer.invoke('file:open')
→ main ipcMain.handle(...)
→ privileged operation
→ serializable result
→ Renderer
preload 可以:
- 限制可调用 channel;
- 校验参数;
- 映射返回值;
- 隐藏底层 IPC 细节;
- 把 Electron API 变成稳定应用 contract。
为什么不要暴露整个 ipcRenderer
Electron 当前明确禁止把整个 ipcRenderer 通过 contextBridge 直接传给 renderer。
从 Electron 29 起,这种整体传递也被 API 层进一步限制。
即使技术上能封装出通用:
send(channel, payload)
invoke(channel, payload)
如果页面可以任意指定 channel,仍然相当于扩大攻击面。
更安全的设计是:
one narrow method
→ one known capability
→ explicit validation
Sandbox 与 preload
sandboxed renderer 没有完整 Node.js 环境。
如果 preload 依赖:
- 任意 npm CommonJS module;
- filesystem;
- child_process;
- native Node module;
不能默认认为它会继续工作。
更稳的方向是:
renderer/preload
→ IPC
→ main process
→ privileged Node operation
而不是为了方便直接关闭 sandbox。
preload 与 main 的职责
preload
负责:
- capability exposure;
- renderer-facing contract;
- 参数边界;
- IPC adapter。
main
负责:
- filesystem;
- process;
- native integration;
- window/session;
- OS capability;
- privileged business action。
如果 preload 开始承担大量 domain logic 或 OS 操作,边界通常已经变厚。
preload 与普通前端初始化脚本
普通 renderer script:
- 负责 UI;
- 运行在页面环境;
- 不应拥有 Electron 高权限。
preload:
- 更早执行;
- 位于安全边界;
- 负责受控桥接;
- 不应该成为 UI bootstrap。
一个推荐结构
main/
ipc/
file.ts
settings.ts
preload/
index.ts
api-types.ts
renderer/
app.tsx
其中:
api-types
→ renderer 可见 contract
preload
→ contract adapter
main/ipc
→ privileged implementation
常见失败模式
万能 IPC
页面可以传任意 channel。
→ capability boundary 失效。
关闭 contextIsolation / sandbox 只为了省事
→ renderer compromise 的权限后果显著扩大。
preload 里写大量业务逻辑
→ main/preload/renderer 责任混乱。
返回复杂高权限对象
应该返回可序列化、最小必要的数据,而不是底层 Electron/Node object。
学习检查
- preload 的核心职责为什么不是“给 renderer Node 权限”?
- Electron 12 与 Electron 20 分别改变了什么默认安全边界?
- contextIsolation 下 preload 与页面为什么不能直接共享普通 window object?
- 为什么
openFile()比invoke(channel, payload)更适合作为 renderer API? - sandboxed preload 为什么不能假定拥有完整 Node.js?
- preload 与 main process 应怎样分工?
- 如果一个 renderer 需要读取本地配置文件,设计 renderer → preload → main 的最小 API。