在 Electron 应用里,preload 脚本是连接主进程和渲染进程的安全桥梁。它的职责很纯粹:在渲染进程的全局作用域中,预先注入一组经过严格筛选的 API,让页面能够安全地调用那些原本只属于主进程的系统能力。
你不必把它想得过于复杂。本质上,preload 就是一个在页面加载之前运行的 Node.js 环境脚本,它有权限使用 require 加载 Electron 和 Node.js 的模块,但它不会直接把整个 require 能力暴露给渲染进程。这一点是它的核心设计目标:能力可用,权限可控。
8.2.1 contextBridge 暴露安全 API
从 Electron 12 开始,官方推荐启用 contextIsolation: true(这也是默认值),这意味着渲染进程的 JavaScript 环境与 preload 脚本的 Node.js 环境是完全隔离的。你想让渲染进程使用什么功能,就必须通过 contextBridge.exposeInMainWorld 显式地“挂载”到 window 对象上。
这是一种白名单机制,你在 preload 里写了什么,渲染进程就只能用到什么,杜绝了恶意脚本直接拿到 require('fs') 的可能。
下面是一个日常开发中最常见的 pattern:暴露一个安全的 electronAPI 对象,里面只提供具体的方法,而不暴露任何模块引用。
// preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
// 打开文件选择对话框
openFileDialog: () => ipcRenderer.invoke('dialog:openFile'),
// 获取应用版本号
getAppVersion: () => ipcRenderer.invoke('app:getVersion'),
// 监听主进程发来的消息(比如更新进度)
onUpdateProgress: (callback) => {
ipcRenderer.on('update:progress', (_event, value) => callback(value));
},
// 移除监听
removeUpdateProgressListener: () => {
ipcRenderer.removeAllListeners('update:progress');
}
});
在渲染进程的代码里,你就可以像调用普通函数一样使用它:
// renderer.js (在页面里)
document.getElementById('btn').addEventListener('click', async () => {
const filePath = await window.electronAPI.openFileDialog();
console.log('选择的文件:', filePath);
});
window.electronAPI.onUpdateProgress((progress) => {
console.log(`下载进度: ${progress}%`);
});
注意,在整个渲染进程中,你始终不需要(也不应该)写 require('electron'),所有系统能力都通过 window.electronAPI 的有限接口来获得。
8.2.2 主进程配合 invoke/handle 模式
上面的例子用了 ipcRenderer.invoke,它需要主进程用 ipcMain.handle 来响应。这是一种返回 Promise 的双向通信方式,非常适合“请求-响应”风格的调用,代码清晰且易于维护。
主进程侧对应代码:
// main.js
const { ipcMain, dialog, app } = require('electron');
ipcMain.handle('dialog:openFile', async () => {
const result = await dialog.showOpenDialog({ properties: ['openFile'] });
return result.filePaths[0] || null;
});
ipcMain.handle('app:getVersion', () => {
return app.getVersion();
});
如果需要从主进程主动推送消息给渲染进程(比如下载进度、系统通知),则保留传统的 webContents.send 模式,在 preload 中暴露一个基于回调的监听方法。
// main.js 中向渲染进程发送消息
mainWindow.webContents.send('update:progress', 45);
preload 里通过 ipcRenderer.on 接收,再传给回调,这部分在上面的 preload 示例中已经体现。
8.2.3 常见错误与注意事项
即使 preload 的设计已经很安全,日常开发中还是容易出现两个典型错误。
错误一:把整个 ipcRenderer 暴露出去
// ❌ 极度危险的做法
contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer: ipcRenderer
});
这样做等于给了渲染进程随意发送任何 IPC 消息的能力,并且可以监听所有频道,一旦页面存在 XSS 漏洞,攻击者就能够调用你主进程中的所有 ipcMain.handle 和 ipcMain.on 处理函数,后果难以控制。正确的做法永远是只暴露具体的方法,每个方法内部写死 IPC 频道和参数结构。
错误二:在 preload 中直接操作 DOM
preload 脚本可以访问 DOM,但你不应该在里面操作 DOM,因为这容易造成渲染进程代码和 preload 间的耦合混乱。preload 的唯一职责就是注入 API,页面逻辑全部交给渲染进程的 JS 脚本处理。
// ❌ 不推荐
window.addEventListener('DOMContentLoaded', () => {
document.getElementById('status').textContent = 'Preload 完成';
});
这种行为应该放到渲染进程自己的 renderer.js 中,preload 保持纯净。
错误三:误用同步 IPC 导致窗口卡死
老版本的 Electron 常常使用 ipcRenderer.sendSync,它是同步阻塞的,会冻结整个渲染进程直到主进程返回。在 contextIsolation 环境下,同步 IPC 更是一个风险源。现在最佳实践是全部使用异步的 invoke/handle 模式,它会返回 Promise,既安全又不阻塞 UI。
8.2.4 实战:一个安全的“本地文件读取”例子
让我们串联起一个完整的小场景:页面上有一个按钮,点击后弹出系统原生的文件选择框,选中一个文本文件,主进程读取内容后返回给页面显示。这个需求很常见,正好能展示 preload 的正确用法。
preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('fileAPI', {
// 读取文本文件,返回内容字符串
readTextFile: () => ipcRenderer.invoke('file:readText')
});
主进程 main.js 片段
const { ipcMain, dialog } = require('electron');
const fs = require('fs');
ipcMain.handle('file:readText', async () => {
const { filePaths } = await dialog.showOpenDialog({
filters: [{ name: 'Text Files', extensions: ['txt', 'md'] }]
});
if (filePaths.length === 0) return null;
const content = fs.readFileSync(filePaths[0], 'utf-8');
return content;
});
渲染进程 renderer.js
document.getElementById('readBtn').addEventListener('click', async () => {
try {
const text = await window.fileAPI.readTextFile();
if (text !== null) {
document.getElementById('content').textContent = text;
}
} catch (err) {
console.error('读取文件失败:', err);
}
});
整个流程中,渲染进程完全不知道 fs 模块的存在,甚至不知道自己在和系统文件对话框交互,它只是调用了 window.fileAPI.readTextFile() 并得到了一个字符串。主进程对每次调用做了完整的权限控制(比如这里限制只能选择文本文件),安全性自始至终处于可控状态。
preload 脚本的用法并不复杂,但它是构建安全 Electron 应用的基石。记住两条金律:永远使用 contextBridge 暴露白名单 API,永远使用异步 IPC。这样写出来的代码不仅安全可靠,而且接口清晰,新人接手时也能一眼看懂主进程和渲染进程之间的通信契约。