1. 从零开始:为什么要用Electron做桌面程序
先说结论:如果你手里已经有一个Web项目,或者你是个前端开发者,想快速做一个Windows、macOS都能跑的桌面应用,Electron几乎是门槛最低的那条路。它的核心思路特别直白——用Node.js做后端逻辑,用Chromium渲染界面,最终打包出一个独立的桌面程序。也就是说,你会的HTML、CSS、JavaScript,到这里直接全都能用上。
这个demo项目我前后折腾了两周左右,踩了不少坑,也摸清了一些规律。这篇文章不是官方文档的翻译,是我自己从环境搭建、主进程与渲染进程分工、IPC通信、打包分发一路走下来的完整记录,包含所有能直接抄作业的代码和配置。
适合谁来读?两类人:一是想快速给团队做个内部工具、但不想单独学C#或者Qt的前端同学;二是已经听说过Electron、但还没真正把第一个demo跑起来的初学者。下面所有内容都以实际可运行为准,我用的版本是Node.js 18 LTS和Electron 28,建议你也用LTS版本,避免奇奇怪怪的兼容性问题。
2. 核心概念拆解:主进程、渲染进程和预加载脚本
2.1 为什么Electron应用必须分进程
Electron应用启动后,实际上会创建两类进程:主进程(Main Process)和渲染进程(Renderer Process)。主进程是应用的入口,负责创建窗口、调用系统原生能力、管理应用生命周期;渲染进程负责页面展示,本质上就是一个Chromium浏览器页面。这两者之间有明确的权限边界,渲染进程默认是拿不到Node.js API的,这是Electron安全模型的核心。
我一开始没搞懂这个设计,写代码的时候直接在渲染进程里用require('fs')去读文件,结果报错。后来才明白,Electron这样做是为了防止页面里的恶意脚本直接操作你的操作系统。理解了这个,后面做IPC通信的时候思路就清晰多了。
预加载脚本(Preload Script)是沟通两端的桥梁,它运行在一个特殊的环境里,既能用有限的Node.js API,又能通过contextBridge把安全的API暴露给渲染进程。记住一个原则:渲染进程永远不直接接触Node.js的核心模块,所有敏感操作都在主进程里做。
2.2 进程间通信(IPC)的三种模式
IPC(Inter-Process Communication)是Electron开发里最绕不开的话题。我总结下来其实就三种场景:
第一种,渲染进程向主进程发消息,主进程接收并处理,比如点击按钮后让主进程读取一个文件。用ipcRenderer.send配合ipcMain.on实现。
第二种,渲染进程向主进程发消息,而且需要拿到返回值,比如问主进程“当前系统是什么平台”。这时候要用ipcRenderer.invoke配合ipcMain.handle,这个模式支持Promise,用起来最顺手。
第三种,主进程主动向渲染进程推送信息,比如后台下载任务进度变了,主进程用webContents.send发消息,渲染进程用ipcRenderer.on监听。
写demo的时候,我建议你只用第二种invoke/handle模式,因为代码可读性好、不容易乱,等基础打牢了再研究其他两种也不迟。
3. 环境搭建与第一个窗口:5分钟跑起来
3.1 环境准备工作
你需要先装好Node.js。下载LTS版本,安装完后在终端里确认版本号能正常输出。然后创建一个项目目录,初始化package.json,装Electron依赖。国内网络环境建议设置镜像源,否则Electron的二进制文件下载会让你等到怀疑人生。
mkdir electron-demo && cd electron-demo npm init -y npm config set electron_mirror https://npmmirror.com/mirrors/electron/ npm install --save-dev electron@28注意:Electron和Node.js的版本没有严格的对应关系,但Electron内置的Node版本通常落后于最新版,写代码时避免使用太新的Node语法特性。
装完之后,在package.json里加上启动脚本。然后创建三个文件:
main.js:主进程入口preload.js:预加载脚本index.html:渲染进程页面
这是最标准的Electron目录结构,任何教程里的demo几乎都是这三件套。
3.2 最小可运行代码与常见启动失败
主进程的代码是整个应用的骨架。先写一个最简单的版本,创建一个窗口,加载一个本地HTML文件:
// main.js const { app, BrowserWindow } = require('electron'); const path = require('path'); function createWindow() { const win = new BrowserWindow({ width: 1024, height: 768, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false } }); win.loadFile('index.html'); } app.whenReady().then(() => { createWindow(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });这里有两个参数特别注意:
contextIsolation: true:将渲染进程与Node.js环境隔离,安全必须开启nodeIntegration: false:禁止渲染进程直接使用Node.js功能
我第一次跑demo的时候,双击启动脚本没反应,终端也不报错。后来发现是app.whenReady()没有正确执行,原因是Electron的缓存出了问题,执行了npx electron --version之后才恢复正常。如果你也遇到类似问题,先检查终端有没有异常输出,再考虑是不是缓存问题。
4. 预加载脚本与安全通信:正确的数据交换姿势
4.1 用contextBridge暴露API
在Electron 28的时代,安全红线就是三件事:contextIsolation必须为true,nodeIntegration必须为false,预加载脚本用contextBridge.exposeInMainWorld暴露API。下面这段预加载脚本是我demo里实际用到的写法:
// preload.js const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('demoAPI', { getPlatform: () => ipcRenderer.invoke('get-platform'), readFile: (filePath) => ipcRenderer.invoke('read-file', filePath), writeFile: (filePath, content) => ipcRenderer.invoke('write-file', filePath, content) });渲染进程里就可以通过window.demoAPI.getPlatform()直接调用。注意,这里暴露的是函数,不要直接把整个ipcRenderer对象暴露出去,否则渲染进程就拥有了完整的通信能力,安全边界又等于没设。
4.2 主进程响应与参数校验
主进程侧用ipcMain.handle监听对应的频道。这里有一个容易忽略的点:主进程在处理渲染进程传来的数据时,一定要做参数校验。渲染进程理论上是可以被篡改的,如果它给你传了一个超长字符串或者一个畸形路径,你的主进程要有能力拒绝而不是直接报错。
// main.js 中补充 const { ipcMain } = require('electron'); const fs = require('fs/promises'); ipcMain.handle('get-platform', () => process.platform); ipcMain.handle('read-file', async (event, filePath) => { if (typeof filePath !== 'string' || !filePath.startsWith(app.getPath('userData'))) { throw new Error('非法路径'); } return await fs.readFile(filePath, 'utf-8'); });这种写法有两点好处:一是数据流单向清晰,渲染进程发起请求,主进程处理并返回结果;二是出错时能很方便地在主进程统一打日志,排查问题不用到处翻。
5. 完整实操:一个文件管理器的demo实现
5.1 功能设计与HTML页面搭建
光跑起来一个空窗口没什么成就感,我做的这个demo是一个极简文件管理器,功能是:显示当前平台的名称,列出用户数据目录下的文件列表,点击文件后读取内容显示在页面上,还能把编辑后的内容写回去。
为什么选这个功能?因为它正好覆盖了Electron开发的三个核心场景:调用系统能力(平台信息)、读取文件系统、UI与后端交互。做一遍这个,比看十遍文档都管用。
index.html的页面骨架如下:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Electron Demo</title> <style> body { font-family: system-ui, sans-serif; margin: 24px; } .card { border: 1px solid #e0e0e0; border-radius: 8px; padding: 16px; margin-bottom: 16px; } pre { background: #f5f5f5; padding: 12px; border-radius: 6px; white-space: pre-wrap; } button { padding: 8px 16px; border: none; background: #4a67ff; color: #fff; border-radius: 6px; cursor: pointer; } button:hover { background: #3a54e0; } </style> </head> <body> <div class="card"> <h2>当前平台</h2> <p id="platform">加载中...</p> </div> <div class="card"> <h2>文件列表</h2> <ul id="fileList"></ul> </div> <div class="card"> <h2>文件内容</h2> <textarea id="editor" rows="8" style="width:100%; font-family: monospace;"></textarea> <br><br> <button id="saveBtn">保存内容</button> </div> <script src="./renderer.js"></script> </body> </html>5.2 渲染进程业务逻辑
渲染进程的脚本单独放在renderer.js里,这是推荐的写法,不要把大段script直接内联到HTML里,代码一多就乱。
// renderer.js const platformEl = document.getElementById('platform'); const fileListEl = document.getElementById('fileList'); const editorEl = document.getElementById('editor'); const saveBtn = document.getElementById('saveBtn'); let currentFile = null; async function init() { const platform = await window.demoAPI.getPlatform(); platformEl.textContent = platform; const files = await window.demoAPI.listFiles(); fileListEl.innerHTML = ''; files.forEach((file) => { const li = document.createElement('li'); li.textContent = `${file.name} (${file.size} bytes)`; li.style.cursor = 'pointer'; li.addEventListener('click', async () => { const content = await window.demoAPI.readFile(file.path); editorEl.value = content; currentFile = file.path; }); fileListEl.appendChild(li); }); } saveBtn.addEventListener('click', async () => { if (!currentFile) { alert('请先选择一个文件'); return; } await window.demoAPI.writeFile(currentFile, editorEl.value); alert('保存成功'); }); init();对应的主进程里需要再加一个list-files的处理:
ipcMain.handle('list-files', async () => { const dir = app.getPath('userData'); const entries = await fs.readdir(dir, { withFileTypes: true }); return entries.map((entry) => ({ name: entry.name, size: entry.isFile() ? (await fs.stat(path.join(dir, entry.name))).size : 0, path: path.join(dir, entry.name) })); });这一套跑起来,一个能交互的桌面应用就成型了。
6. 打包分发:让demo变成真正的软件
6.1 electron-builder的配置与使用
开发完demo之后,要把它分享给别人用,不能指望对方也装Node.js环境,必须打包成可执行文件。打包工具有好几个,我用了electron-builder,原因是配置相对简单,支持多平台输出。
先安装:
npm install --save-dev electron-builder然后在package.json里加一段build配置:
{ "scripts": { "start": "electron .", "dist": "electron-builder --win --mac" }, "build": { "appId": "com.example.demo", "productName": "ElectronDemo", "files": ["main.js", "preload.js", "renderer.js", "index.html"], "win": { "target": ["nsis"] }, "mac": { "target": ["dmg"] } } }跑npm run dist之后,dist目录下就会生成安装包。Windows下是exe安装文件,macOS下是dmg镜像。
6.2 打包体积优化思路
Electron打包出来的体积默认很大,一个空应用都要150MB以上,因为里面塞了一整个Chromium。能优化的方案有几种:用electron-builder的asar模式把应用代码压缩打包,这默认是开启的;尽可能减少files列表里的冗余文件;如果应用不涉及屏幕录制或摄像头,可以考虑关掉一些用不到的Chromium模块,但配置比较麻烦,不建议新手折腾。
体积这件事,我的态度是:小工具就别太纠结,150MB对于现在的硬盘来说不算什么,换来的是开发效率极大提升。如果哪天真需要极致瘦身,再考虑用Tauri之类的替代方案,那是另一个话题了。
7. 常见问题排查与避坑记录
7.1 主进程与渲染进程的数据传递异常
遇到过最典型的问题就是渲染进程调用window.demoAPI时报undefined。多半是两种原因:
- 预加载脚本路径写错了,
BrowserWindow里的preload路径要指向真实的文件 contextIsolation配置为false导致contextBridge暴露的API被覆盖或失效
排查方法直接在渲染进程的开发者工具控制台里输入window.demoAPI,看一下有没有这个对象。没有的话,回到主进程检查路径,还是不行就把contextIsolation先设为true重启验证。
7.2 窗口白屏与加载失败
白屏问题通常是页面加载路径不对。loadFile接收的是相对路径,最好用path.join(__dirname, 'index.html')这种绝对拼接方式,避免工作目录不同导致的路径错乱。另外,如果页面里有外部CDN资源,加载失败也会导致看起来白屏,尽量把资源本地化。
7.3 应用无法退出与进程残留
有时候关掉窗口后,进程还停留在后台,尤其是在Windows上。常见原因是主进程注册了某些全局监听或定时器没有清理。在window-all-closed事件里执行app.quit()是基本操作,但如果还有其它窗口引用没释放,照样会残留。排查时打开Windows的任务管理器,找到对应的进程强制结束,然后逐步注释代码找到占用句柄的地方。
8. 经验总结:demo之外的进阶方向
做完这个demo,Electron的基本套路就掌握得差不多了。剩下的进阶方向,按推荐顺序排一下:
第一,深入理解主进程的能力边界,学一学怎么用net模块发请求、怎么用dialog调用系统原生弹窗、怎么用Tray做系统托盘。这些都是桌面应用高频用到的原生能力。
第二,研究自动更新机制。electron-builder自带了electron-updater,配置一个更新服务器,应用启动时自动检测新版本。这个对实际分发特别重要,否则每次改BUG都要让用户重新下载安装包,体验很差。
第三,如果有跨平台统一存储的需求,可以引入SQLite或者用electron-store做本地持久化。electron-store底层就是JSON文件,上手极其简单,适合做配置存储。
我在实际使用中发现,Electron最大的价值不是让前端转行做桌面开发,而是让团队里已有的Web技术积累能直接复用。做一个内部工具,从立项到拿出第一版demo,可能只需要一两天时间。很多时候,工具的价值不在于技术多高深,而在于解决了一个“手动做太烦、懒得做又总出错”的痛点。Electron恰好把这条路铺平了,剩下的就看你自己的场景了。