使用 Preload 脚本与 IPC 安全打通主进程与渲染进程:Electron 官方教程(三)实战解读
本篇文章围绕 Electron 官方教程第三部分「使用 Preload 脚本」展开,讲解 Preload 脚本的概念、沙箱环境下的可用 API、如何借助 contextBridge 向渲染进程安全地暴露特权能力,并通过 ipcMain.handle 与 ipcRenderer.invoke 搭建最小可用的进程间通信(IPC)链路。学完后你可以独立实现一个「主进程持有系统能力、渲染进程负责界面展示、二者通过受控的 preload 桥接层通信」的标准 Electron 应用架构。
学习目标
本教程部分对应仓库中的 docs/tutorial/tutorial-3-preload.md,它是官方教程的第三站,前面依次是 环境准备、创建第一个应用,后续还有 为应用添加功能、打包应用、发布与更新。
完成本部分你将掌握两件事:
- 理解 preload 脚本是什么、在什么时机运行、拥有哪些权限边界;
- 学会用它把「有特权的 API」安全地暴露给渲染进程,并基于 IPC 打通主进程与渲染进程的通信。
为什么需要 preload 脚本
Electron 的主进程是一个完整的 Node.js 环境,除了 Electron 自身模块,还能直接使用 Node.js 内置模块(fs、path、os 等)以及通过 npm 安装的任意第三方包,因此它对操作系统拥有完整的访问能力。
与之相对,渲染进程负责运行网页内容,出于安全原因默认不运行 Node.js,也接触不到主进程那一整套系统级 API。它只能执行标准的 Web(DOM、Canvas、fetch 等)能力。
这种设计带来的结果是:主进程与渲染进程职责不同、能力互不通用——渲染进程无法直接调用 Node.js API,主进程也无法直接操作网页的 DOM。要弥合两者,Electron 提供了一种特殊脚本,就是 preload:一个运行在渲染进程里、但能"看见"部分 Node.js 与 Electron API 的中间层,充当两个进程之间的桥梁。
用 preload 脚本增强渲染进程
一个 BrowserWindow 的 preload 脚本,运行在一个同时能访问 HTML DOM 和有限 Node.js/Electron API 的上下文里。它会在网页正式加载之前被注入(机制上类似 Chrome 扩展中的 content scripts),先于页面代码执行完毕。
因此,凡是需要特权访问(读文件、查版本、调系统能力等)却又要在页面上使用的功能,正确的做法是:在 preload 中通过 contextBridge API 定义全局对象,再交给渲染进程的页面脚本使用。
Electron 20 起默认沙箱化
需要特别留意的是,从 Electron 20 开始,preload 脚本默认处于沙箱(sandbox)状态,不再拥有完整的 Node.js 环境。从源码看,Electron 将沙箱与非沙箱两类 preload 入口分置于不同目录:lib/sandboxed_renderer/preload.ts 与 lib/preload_realm/init.ts,而沙箱模式下渲染进程能 require 到的 API 白名单由 lib/sandboxed_renderer/api/module-list.ts 定义并经由 lib/sandboxed_renderer/api/exports/electron.ts 装配导出。
实际效果就是:preload 里的 require 是一个经过 polyfill 的函数,只能访问下面这组受限 API:
| 可用 API | 详细说明 |
|---|---|
| Electron 模块 | 渲染进程模块(如 contextBridge、ipcRenderer,详见 electron 导出面) |
| Node.js 模块 | events、timers、url |
| Polyfilled 全局 | Buffer、process(process 文档)、clearImmediate、setImmediate |
沙箱机制、context isolation 与安全基线更完整的讨论可参考 进程沙箱说明 sandbox.md(原文档配套章节)(若仓库当前版本未携带该文件,请以 security.md 与 context-isolation.md 为准)。
实战:把进程版本号暴露给页面
作为演示,我们做一个 preload 脚本,把 Electron process.versions 中的 Chrome、Node、Electron 版本通过 contextBridge.exposeInMainWorld 暴露到渲染进程的 versions 全局变量中。
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('versions', {
node: () => process.versions.node,
chrome: () => process.versions.chrome,
electron: () => process.versions.electron
// 这里同样可以暴露普通变量,而不只是函数
})
然后,在 BrowserWindow 构造函数的 webPreferences.preload 选项中传入该脚本的绝对路径,把它挂到渲染进程上:
const { app, BrowserWindow } = require('electron')
const path = require('node:path')
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
win.loadFile('index.html')
}
app.whenReady().then(() => {
createWindow()
})
这段代码用到了两个 Node.js 概念:
__dirname:指向当前正在执行脚本所在目录的字符串(本例即项目根目录);path.join:把多个路径片段拼成一个完整路径,好处是跨平台统一(Windows 用\、macOS/Linux 用/,它都会正确处理)。
在页面里读取暴露的全局
此时渲染进程里已经有了 versions 这个全局对象,可以写 window.versions(或简写 versions)。新建 renderer.js,用 DOM API document.getElementById 把版本信息渲染到页面上:
const information = document.getElementById('info')
information.innerText = `This app is using Chrome (v${versions.chrome()}), Node.js (v${versions.node()}), and Electron (v${versions.electron()})`
然后修改 index.html:新增一个 id="info" 的空元素,并引入 renderer.js:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
>
<meta
http-equiv="X-Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
>
<title>Hello from Electron renderer!</title>
</head>
<body>
<h1>Hello from Electron renderer!</h1>
<p>👋</p>
<p id="info"></p>
</body>
<script src="./renderer.js"></script>
</html>
注意 index.html 里同时声明了 Content-Security-Policy 与 X-Content-Security-Policy,内容为 default-src 'self'; script-src 'self',即只允许加载同源资源与脚本——教程从「最小 Hello World」开始就刻意贯彻安全基线,这一点与官方 security.md 的推荐一致。
运行后应用会输出类似 "This app is using Chrome (v...), Node.js (v...), and Electron (v...)" 的版本信息:
教程配套的完整可运行代码位于仓库 docs/fiddles/tutorial-preload 目录,包含 main.js、preload.js、renderer.js 与 index.html 四个文件;其中 preload.js 使用 require('electron/renderer') 引入 contextBridge,这与教程正文的 require('electron') 写法在沙箱 preload 中等价,均指向渲染进程 API 导出面。
仓库的真实应用也遵循同一模式:Electron 自带默认应用入口 default_app/preload.ts 中先
contextBridge.exposeInMainWorld('electronDefaultApp', {...})暴露初始化入口,再在其中通过ipcRenderer.invoke('bootstrap')与主进程通信取回 Electron 可执行文件路径,进而渲染版本号——这正是本文所讲桥接层在实际代码中的范本。
进程间通信(IPC):ping/pong 示例
展示版本只需要 preload 单向读取 process.versions 即可,但多数功能需要「页面发起请求 → 主进程处理 → 结果回传」。Electron 为此提供 ipcMain(主进程侧)与 ipcRenderer(渲染进程侧)两个模块组成 IPC 通道。
推荐的请求-响应模式是:主进程用 ipcMain.handle 注册处理器,渲染进程用 ipcRenderer.invoke 调用它。下面给渲染进程增加一个返回字符串 'pong' 的全局函数 ping()。
第一步: 在 preload 里增加 invoke 调用:
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('versions', {
node: () => process.versions.node,
chrome: () => process.versions.chrome,
electron: () => process.versions.electron,
ping: () => ipcRenderer.invoke('ping')
// 同样可以暴露变量,而不只是函数
})
:::caution IPC 安全红线
注意我们把 ipcRenderer.invoke('ping') 包在了一个辅助函数里,而不是把 ipcRenderer 模块本身交给 context bridge。永远不要通过 preload 直接暴露整个 ipcRenderer 模块——那等于把「向主进程发送任意 IPC 消息」的能力拱手交给渲染进程里的任何代码,一旦页面被注入恶意脚本,它就成了威力巨大的攻击面。官方对这条约束的完整论述见 context-bridge 文档。
:::
第二步: 在主进程中注册 handle 监听器。必须放在加载 HTML 之前(即 whenReady 回调里、loadFile 之前),确保页面一发起 invoke 时处理器已经就绪:
const { app, BrowserWindow, ipcMain } = require('electron/main')
const path = require('node:path')
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
win.loadFile('index.html')
}
app.whenReady().then(() => {
ipcMain.handle('ping', () => 'pong')
createWindow()
})
第三步: 发送端与接收端就绪后,渲染进程即可通过刚定义的 'ping' 频道发起消息:
const func = async () => {
const response = await window.versions.ping()
console.log(response) // 打印出 'pong'
}
func()
由于 invoke 返回 Promise,调用侧必须 await。关于 ipcRenderer / ipcMain 更深入的用法(双向通信、send/on 事件模型、端口消息等),可继续阅读 IPC 完整指南。
从源码看这条通信链路如何落地
为了印证上面的流程确实运行在真实实现之上,可以回到本仓库源码追溯关键环节:
- 渲染侧 IPC 封装:
ipcRenderer.invoke的真正实现位于 lib/renderer/ipc-renderer-bindings.ts,它内部会把调用序列化并通过IpcRenderer的原生绑定发送到主进程; - 主进程消息分发:主进程侧
ipcMain.handle的注册与分发在 lib/browser/ipc-main-impl.ts 中维护,收到消息后按频道名查找对应 handler 执行并回传结果; - 内部消息定义:频道名常量集中定义于 lib/common/ipc-messages.ts,例如
default_app/preload.ts中调用的'bootstrap'频道即属于此类约定——真实应用与教程一样,通过约定的频道字符串完成跨进程协作。
从代码结构可以推断:ipcMain.handle 注册的是一张「频道 → 异步处理函数」的映射表,ipcRenderer.invoke 发出的请求会在主进程中查表执行并经由 Promise 语义把返回值送回调用方,因此整个链路天然支持异步与错误传播,这也是教程推荐 handle/invoke 作为首选通信模式的原因。
总结
preload 脚本是在网页加载进浏览器窗口之前运行的代码,它同时拥有 DOM API 与受限的 Node.js 环境,最常用的职责是通过 contextBridge 把特权 API 安全地暴露给渲染进程。
由于主进程与渲染进程职责差异巨大,Electron 应用通常还会借助 preload 脚本搭起 IPC 接口,让两类进程之间可以互相传递任意消息——本文实现的 versions 只读桥接与 ping/pong 往返,就是这套架构最精简的骨架,也应该是你后续所有 Electron 应用中「页面需要调用系统能力」时的标准模板。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
