Skip to content安全模型:
概述
Electron 是一个使用 JavaScript、HTML 和 CSS 构建桌面应用的框架。它将 Chromium 和 Node.js 嵌入同一个二进制文件中,使开发者能用一套 Web 技术栈编译出 Windows、macOS、Linux 上独立运行的桌面程序[1]。框架本身不要求你学习平台原生的 GUI 工具链,但暴露的操作系统能力——文件系统、系统托盘、菜单、通知、快捷键等——几乎与原生相当,这些能力通过 Node.js 模块或 Electron 主进程 API 提供。
简而言之,Electron 将一个浏览器和一个 Node.js 运行时打包进了同一个可执行文件。应用启动后,Chromium 负责渲染界面,Node.js 负责操作本地资源。这两条路径并不直接互通:它们运行在不同的进程中,通过消息通道协作。后续内容将拆解这套运行时组合和多进程模型,目的是在动手创建窗口之前,先理解 Electron 的运行方式。
背景:跨平台桌面开发的难点
在没有类似框架之前,为不同桌面平台开发 GUI 程序通常意味着维护多套代码:
- Windows 上用 C# + WPF 或 C++ + Win32;
- macOS 上用 Swift/Objective-C + AppKit;
- Linux 上用 GTK 或 Qt(语言可能是 C/C++/Python)。
每个平台都有独立的 API 体系、事件循环模型和分发方式。团队若想将一个工具同时提供给三个平台,要么招聘懂不同技术栈的开发者,要么在跨平台 GUI 库(如 Qt)上做大量抽象,最终仍然要面对打包、签名、系统集成等平台特定配置。
Electron 的思路是将平台适配的工作交给 Chromium 和 Node.js。Chromium 负责将 HTML/CSS/JavaScript 渲染为正常的窗口界面,Node.js 负责通过标准化的文件系统、网络、进程管理等 API 与操作系统交互。代码不直接调用 Win32 或 Cocoa,而是在 Electron 提供的 JavaScript API 层上操作。因此,一套 Web 前端代码加上一套 Node.js 逻辑就可以在三端运行,开发实践与前端开发高度一致。
这一方案会带来包体积和运行时开销的问题,后续会提到,但在开发效率和跨平台一致性上的收益是明确的。
运行环境:Chromium 与 Node.js
Electron 的核心二进制包含两个完整的运行时:
- Chromium:负责窗口管理、HTML/CSS 解析与渲染、JavaScript(V8)执行、DevTools 等。它使 Electron 应用能像浏览器一样加载网页内容——可以是本地 HTML 文件,也可以是远程 URL。
- Node.js:提供
fs、net、child_process等模块,直接访问操作系统资源。它运行在独立于渲染进程的主进程中(也可以进入渲染进程,但有严格的安全限制)。
这两个运行时并非简单“一起编译”,而是通过 Electron 的 C++ 胶水层集成。Chromium 和 Node.js 内各有一个事件循环(libuv),Electron 启动主进程时会同时启动这两个循环,并将 Node.js 的能力注入特定进程上下文。在渲染进程中,是否允许直接使用 Node.js 由配置项 nodeIntegration 控制,该配置的默认值在近几个大版本中已设为 false,以降低安全风险。
最终的结果是,你可以写出这样的代码结构:
- 主进程脚本:Node.js 环境,能
require('electron')和require('fs'); - 渲染进程脚本:浏览器环境,可操作 DOM,但不能直接
requireNode.js 模块,除非显式开启或通过 preload 桥接。
这种混合环境是 Electron 应用一切行为的基础。
进程模型
Electron 应用启动后,会立即分出两类进程:主进程与渲染进程。
主进程
主进程是应用入口,每个 Electron 应用有且只有一个。package.json 中的 main 字段指向的脚本就运行在此进程内。主进程负责:
- 管理应用生命周期(
app.ready、app.window-all-closed等事件); - 创建和控制
BrowserWindow实例,每调用一次new BrowserWindow(),背后就会启动一个渲染进程; - 调用系统原生 API:对话框(
dialog)、菜单(Menu)、托盘(Tray)、快捷键(globalShortcut)、文件拖拽等; - 处理与渲染进程的 IPC 消息。
主进程没有 DOM 环境,不能直接操作页面元素。它可以无限制地使用 Node.js 全部模块,因此所有需要系统权限的操作都应放在主进程中。
渲染进程
每当 BrowserWindow 加载页面,Electron 就会启动一个新的渲染进程。该进程内运行的是 Chromium 的渲染引擎,拥有完整的 DOM、CSSOM、JavaScript 引擎和 Web API(fetch、localStorage、WebRTC 等)。渲染进程负责:
- 加载 HTML/CSS/JavaScript 并渲染 UI;
- 响应用户交互;
- 通过 IPC 向主进程请求系统能力。
渲染进程之间彼此隔离。一个页面的崩溃或死循环通常不会直接拖垮其他窗口,这与 Chrome 浏览器标签页的进程隔离逻辑相同。若某个渲染进程崩溃,主进程可以监听到 webContents 的 crashed 事件并决定是否重载。
进程间通信(IPC)
主进程和渲染进程运行在不同的进程空间,无法直接共享变量或调用对方函数。Electron 提供了一套基于事件的消息通道:ipcMain 和 ipcRenderer。
- 主进程侧:通过
ipcMain.on(channel, listener)监听来自渲染进程的消息,通过event.reply或webContents.send向渲染进程发送数据。 - 渲染进程侧:通过
ipcRenderer.on(channel, listener)接收主进程发来的消息,通过ipcRenderer.send(channel, ...args)向主进程发消息。
这套通信机制是异步的,底层使用 Chromium 的 IPC 基础设施(与 Chrome 扩展的通信模型类似)。对于简单的请求-响应场景,也可以使用 ipcRenderer.invoke 与 ipcMain.handle 返回 Promise,背后仍建立在相同的消息通道上。
下面是一个最简的示意,不涉及安全配置:
js
// 主进程 main.js
const { app, BrowserWindow, ipcMain } = require('electron')
app.whenReady().then(() => {
const win = new BrowserWindow({
// 注意:实际开发必须配置 preload,这里仅为演示 IPC
})
win.loadFile('index.html')
ipcMain.on('get-os-info', (event) => {
event.reply('os-info', process.platform)
})
})js
// 渲染进程 renderer.js(若不开启 nodeIntegration,需将此逻辑放入 preload)
const { ipcRenderer } = require('electron')
ipcRenderer.send('get-os-info')
ipcRenderer.on('os-info', (event, platform) => {
console.log('操作系统平台:', platform)
})在这个过程里,渲染进程请求“获取系统信息”,但自身没有权限直接读取 process.platform。它必须通过 IPC 让主进程去读,再将结果传回。这个“请求-转发-回复”路径是 Electron 中最常见的协作模式。
安全模型:contextIsolation 与 preload
如果在渲染进程中直接开放 Node.js 能力(nodeIntegration: true),页面中的任何脚本——包括从外部 CDN 加载的第三方代码、XSS 注入的恶意脚本——都能直接使用 fs、child_process 等模块。这意味着一个反射型 XSS 可能直接演变为远程命令执行。Electron 早期版本曾出现此类问题,后续安全配置的默认值逐渐收紧。
现在的推荐配置是:
nodeIntegration: falsecontextIsolation: true
开启 contextIsolation 后,preload 脚本运行的 JavaScript 环境与页面脚本的环境完全分开:
- preload 可以访问一部分 Node.js 能力和 Electron 的
contextBridge、ipcRenderer等模块; - 页面自身的
window上拿不到任何 Node.js 或 Electron 对象,只能拿到 preload 经由contextBridge显式暴露的接口。
preload 脚本
preload 是在页面加载之前执行的脚本,通过 webPreferences.preload 配置指定路径。它运行在一个相对特权环境,可以安全地引入 ipcRenderer 并构建白名单 API,再通过 contextBridge 暴露给页面。
contextBridge 与 contextIsolation
contextBridge.exposeInMainWorld(apiKey, apiObject) 会在页面的 window 对象上挂载指定属性,同时确保暴露出的对象是一个安全的、隔离的副本,不会将 preload 的作用域泄漏给页面。
一个典型的 preload 写法:
js
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
getPlatform: () => ipcRenderer.invoke('get-platform'),
onMenuAction: (callback) => {
ipcRenderer.on('menu-action', (event, action) => callback(action))
}
})在主进程中创建窗口时:
js
const win = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
})此时页面脚本只能通过 window.electronAPI 调用 getPlatform,看不到 ipcRenderer,更无法 require('child_process')。即便页面存在 XSS,攻击者也仅能调用 preload 暴露的有限方法,无法直接触及系统。
这套模型是当前 Electron 应用安全防护的核心。
与纯 Web / 纯 Node.js 应用的对比
Electron、纯 Web 应用、纯 Node.js 应用的差异主要体现在三个方面:
系统能力访问
浏览器中的 Web 应用受同源策略和沙盒限制,不能随意读写本地文件、访问系统剪贴板或创建原生菜单。Electron 渲染进程虽同为浏览器环境,但可通过 preload 暴露的 API 间接调用主进程的系统能力。纯 Node.js 拥有完整的系统访问权限,但它没有自带的图形界面。界面层
纯 Web 应用必须在用户安装的某个浏览器中打开,交互受限于浏览器标签页。Electron 自带 Chromium,可以自定义窗口样式、创建无边框窗口、使用系统托盘,这些是普通 Web 页面无法做到的。纯 Node.js 程序若不搭配 Electron 或 NW.js,就没有自带的 GUI 渲染引擎,通常只在终端运行。分发形式
Web 应用部署在服务器上,用户通过 URL 访问。Electron 应用打包为平台特定的可执行文件(.exe、.app、.AppImage等),用户需要下载安装。纯 Node.js 命令行工具一般通过 npm 分发或封装为可执行文件,没有图形界面。
Electron 将 Web 的 UI 能力和 Node.js 的本地能力串联起来,最终产物是一个可独立运行、带有 GUI 的桌面程序。
应用启动流程
一个典型的 Electron 应用从启动到关闭的路径如下:
启动主进程
操作系统启动可执行文件,加载main入口脚本。此时只有主进程在运行,没有窗口。app ready
主进程监听app.whenReady()事件(或app.on('ready'))。该事件表明 Electron 已完成初始化,可以安全创建BrowserWindow。创建窗口并启动渲染进程
主进程创建BrowserWindow实例,加载本地 HTML 文件或远程 URL。每个窗口背后启动一个独立的渲染进程,同时根据需要执行配置好的 preload 脚本。页面交互与 IPC
用户在窗口内操作,渲染进程处理 UI 逻辑。当需要访问系统能力时,渲染进程通过 preload 暴露的 API 发送 IPC 消息给主进程,主进程执行相应操作并返回结果。生命周期结束
用户关闭所有窗口(或触发app.quit()),主进程退出,整个应用结束。
限制
Electron 将 Chromium 和 Node.js 打包进应用,带来两个直接的成本:
- 安装包体积:一个空项目的打包体积通常在 100 MB 以上(Windows x64),因为必须附带完整的 Chromium 和 Node.js 运行时。对下载和磁盘占用敏感的场景需要评估这一点。
- 运行时内存:每个 Electron 应用实例接近运行一个完整的浏览器实例。渲染进程在加载复杂页面时,可能导致内存占用偏高。
启动速度、CPU 占用等表现也受具体实现影响,但根本上无法摆脱“自带浏览器内核”带来的资源开销。
