跳到主要内容7 天掌握 Electron 跨平台桌面应用开发实战 | 极客日志JavaScriptNode.js大前端
7 天掌握 Electron 跨平台桌面应用开发实战
Electron 技术栈详解跨平台桌面应用开发全流程,涵盖架构原理、项目搭建、IPC 通信机制及安全性配置。通过实战案例演示文件读写、全局快捷键及窗口管理,并提供打包体积优化、内存泄漏排查等进阶技巧。适合希望拓展前端技能至桌面端的技术人员参考。
GopherDev1 浏览 7 天掌握 Electron 跨平台桌面应用开发实战
背景与动机
当前端开发面临同质化竞争时,拓展技能边界往往成为破局关键。很多岗位开始要求全栈能力,甚至包括桌面端。当年我们入坑前端时,常说"一次编写,到处运行",结果现在 H5 卷不动了卷小程序,小程序卷不动了又开始卷桌面端。
其实,Electron 上手比你想象得简单十倍,但简历上写出来能显著提升技术广度。别人简历上都是"开发了 XX 管理系统",你写的是"独立开发了跨平台桌面客户端应用,支持 Windows/macOS 双平台"——这差距,就像穿拖鞋去面试和穿皮鞋的区别。
更关键的是,老板们总觉得"客户端"比"网页"高级。花三天时间用 Electron 套个壳,双击图标就能打开,体验确实比浏览器输入 localhost:3000 好得多。你现在的 HTML、CSS、JavaScript 功底完全够用,不用现学 C# 或啃 C++,用熟悉的技术栈就能打包出 .exe 文件。
不过,"跨平台"容易把人忽悠瘸了。Windows 上的路径分隔符和 macOS 不一样,系统通知的 API 调用方式也有差异,甚至有些功能在某个平台上干脆就不支持。所以咱们得先扒开 Electron 的底裤,看看这货到底是个什么东西。
Electron 架构解析
核心原理
你要是去 GitHub 上看 Electron 源码,或者跟后端同事解释你在做什么,最简单粗暴的说法就是:"就是个套了壳的 Chrome 浏览器,里面塞了个 Node.js。"
Electron 的核心架构其实就两部分:Chromium 负责渲染界面,Node.js 负责操作系统级别的操作。Chromium 这部分好理解,就是你写的那堆 HTML、CSS、JavaScript 跑的地方。但 Node.js 在里面干嘛呢?它让你能在"网页"里直接调用 fs 模块读写文件,能用 child_process 启动系统命令,能访问数据库。
这里有个特别有意思的设计,也是新手最容易懵圈的地方:Electron 把这两个东西分成了"主进程"(Main Process)和"渲染进程"(Renderer Process)。
- 主进程:跑 Node.js,负责创建窗口、管理应用生命周期、搞系统级别的操作。
- 渲染进程:每个窗口里的 Chromium 实例,负责展示界面、响应用户交互。
这俩进程怎么通信呢?官方给了一套 IPC(进程间通信)机制。简单来说,渲染进程想读文件?行,但它不能直接读,得发消息给主进程:"哥,帮我读一下 C 盘这个文件。"主进程读完了再发回去:"给,内容在这儿。"听起来挺绕的,但这设计其实是为了安全。
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const fs = require('fs');
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
: {
: path.(__dirname, ),
: ,
: ,
:
}
});
mainWindow.();
mainWindow.(, {
mainWindow = ;
});
}
app.().(createWindow);
app.(, {
(process. !== ) {
app.();
}
});
app.(, {
(.(). === ) {
();
}
});
ipcMain.(, (event, filePath) => {
{
content = fs..(filePath, );
{ : , content };
} (error) {
{ : , : error. };
}
});
webPreferences
preload
join
'preload.js'
contextIsolation
true
enableRemoteModule
false
nodeIntegration
false
loadFile
'index.html'
on
'closed'
() =>
null
whenReady
then
on
'window-all-closed'
() =>
if
platform
'darwin'
quit
on
'activate'
() =>
if
BrowserWindow
getAllWindows
length
0
createWindow
handle
'read-file'
async
try
const
await
promises
readFile
'utf-8'
return
success
true
catch
return
success
false
error
message
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
versions: {
node: process.versions.node,
electron: process.versions.electron,
chrome: process.versions.chrome
}
});
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>我的第一个 Electron 应用</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
.container { max-width: 800px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
textarea { width: 100%; height: 300px; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-family: 'Consolas', monospace; resize: vertical; }
button { background: #0066cc; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; margin-right: 10px; margin-top: 10px; }
button:hover { background: #0052a3; }
</style>
</head>
<body>
<div class="container">
<h1>🚀 Electron 文件编辑器</h1>
<p>当前平台:<span id="platform"></span> | Electron 版本:<span id="version"></span></p>
<textarea id="editor" placeholder="在这里输入内容..."></textarea>
<div>
<button onclick="openFile()">📂 打开文件</button>
<button onclick="saveFile()">💾 保存</button>
</div>
</div>
<script>
document.getElementById('platform').textContent = window.electronAPI.versions.node;
const editor = document.getElementById('editor');
async function openFile() {
const result = await window.electronAPI.readFile('test.txt');
if (result.success) {
editor.value = result.content;
}
}
</script>
</body>
</html>
其他备选方案
虽然我现在在教 Electron,但咱得客观。这玩意儿不是唯一的选项。
- Tauri:这两年火得一塌糊涂,用 Rust 写的,前端还是用你熟悉的 HTML/CSS/JS,但后端不是 Node.js 了,是 Rust。它的最大卖点是打包体积小,一般也就几 MB,比 Electron 那动辄上百 MB 的体型优雅多了。但缺点是 Rust 学习曲线陡,生态也不如 Node.js 丰富。
- NW.js:算是 Electron 的前辈了,当年叫 node-webkit。它的架构和 Electron 不太一样,Node.js 和 Chromium 运行在同一个上下文里,代码写起来更直接,不用搞什么 IPC 通信。但这也带来了安全隐患,而且社区活跃度现在不如 Electron。
大厂为什么爱用 Electron?
你可能要问了,既然 Electron 有这么多缺点,为啥 VS Code、Discord、Slack、Figma 这些大牌都用它?答案很简单:开发效率。对于这些公司来说,招一个会 JavaScript 的前端工程师太容易了,招一个会 C++ 还会 Qt 的桌面端开发?那难度和成本都高得多。
VS Code 团队曾经分享过,他们用 Electron 能快速迭代,每周都能发版本,这在原生开发里是很难想象的。而且现代电脑的配置越来越高,用户其实没那么在意那几百 MB 的体积和几百 MB 的内存占用——只要你的软件好用。
但你要注意,这些大厂用 Electron,不代表你也要无脑选。他们有一整个团队来优化性能、处理兼容性、维护更新机制。你一个人搞项目,要是做个简单的计算器也用 Electron,用户下载个 200MB 的安装包,打开一看就是个加减乘除,不骂娘才怪。
项目搭建与核心流程
脚手架一把梭
我知道很多教程喜欢从 npm init 开始,一步步教你配 webpack、配 babel。都 2024 年了,谁还这么干啊?直接上脚手架,五分钟跑起来 Hello World,不香吗?
官方推荐的 electron-forge 就挺好用,或者社区里流行的 electron-vite。我个人推荐 electron-forge,因为它封装好了打包、签名、自动更新这些脏活累活。
npm install -g @electron-forge/cli
electron-forge init my-app
cd my-app
npm start
就这么三行命令,一个完整的 Electron 项目就搭好了。目录结构长这样:
my-app/
├── src/
│ ├── index.html
│ ├── index.js
│ ├── preload.js
│ └── renderer.js
├── forge.config.js
├── package.json
└── assets/
main.js 里的细节
前面我已经给了一个基础的 main.js 示例,但实际项目里,你要考虑的事情还多着呢。比如窗口大小怎么记?用户上次把窗口拖成啥样,下次打开应该保持吧?还有系统托盘,很多应用点了关闭其实是最小化到托盘,而不是真的退出。
const { app, BrowserWindow, Tray, Menu, ipcMain, dialog } = require('electron');
const path = require('path');
const Store = require('electron-store');
const store = new Store();
let mainWindow;
let tray;
function createWindow() {
const windowState = store.get('windowState', { width: 1200, height: 800, x: undefined, y: undefined, maximized: false });
mainWindow = new BrowserWindow({
width: windowState.width,
height: windowState.height,
x: windowState.x,
y: windowState.y,
minWidth: 800,
minHeight: 600,
titleBarStyle: 'hiddenInset',
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
enableRemoteModule: false,
nodeIntegration: false,
allowEval: false,
webSecurity: true
}
});
mainWindow.loadFile('src/index.html');
mainWindow.once('ready-to-show', () => {
mainWindow.show();
if (windowState.maximized) {
mainWindow.maximize();
}
});
mainWindow.on('close', (event) => {
if (store.get('minimizeToTray', true) && !app.isQuiting) {
event.preventDefault();
mainWindow.hide();
return;
}
const bounds = mainWindow.getBounds();
store.set('windowState', {
width: bounds.width,
height: bounds.height,
x: bounds.x,
y: bounds.y,
maximized: mainWindow.isMaximized()
});
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
function createTray() {
const iconPath = process.platform === 'win32' ? path.join(__dirname, 'assets', 'tray.ico') : path.join(__dirname, 'assets', 'trayTemplate.png');
tray = new Tray(iconPath);
const contextMenu = Menu.buildFromTemplate([
{ label: '显示应用', click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } } },
{ label: '退出', click: () => { app.isQuiting = true; app.quit(); } }
]);
tray.setToolTip('我的 Electron 应用');
tray.setContextMenu(contextMenu);
tray.on('click', () => {
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
mainWindow.focus();
}
}
});
}
app.whenReady().then(() => {
createWindow();
createTray();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
preload 脚本的重要性
很多人写 Electron,preload.js 里就写两行代码,把 IPC 暴露出去就完事了。这其实挺浪费的,preload 脚本是个绝佳的"安全层"和"适配层"。
你想啊,渲染进程里的代码理论上是可以被用户篡改的。如果你在 preload 里做好参数校验、权限控制,就能避免很多安全问题。而且,如果以后你要迁移到 Tauri 或者其他框架,只需要改 preload 里的实现,渲染进程的代码可以完全不动。
const { contextBridge, ipcRenderer, shell } = require('electron');
const ALLOWED_PATHS = [
app.getPath('userData'),
app.getPath('documents')
];
function isPathAllowed(filePath) {
const normalizedPath = path.normalize(filePath);
return ALLOWED_PATHS.some(allowedPath => normalizedPath.startsWith(allowedPath));
}
contextBridge.exposeInMainWorld('electronAPI', {
file: {
read: async (filePath) => {
if (!isPathAllowed(filePath)) throw new Error('Access denied');
return ipcRenderer.invoke('read-file', filePath);
},
write: async (filePath, content) => {
if (!isPathAllowed(filePath)) throw new Error('Access denied');
if (Buffer.byteLength(content, 'utf-8') > 10 * 1024 * 1024) throw new Error('File too large');
return ipcRenderer.invoke('write-file', filePath, content);
},
openDialog: () => ipcRenderer.invoke('show-open-dialog'),
saveDialog: (content, defaultPath) => ipcRenderer.invoke('show-save-dialog', content, defaultPath)
},
system: {
platform: process.platform,
versions: process.versions,
getPath: (name) => ipcRenderer.invoke('get-path', name)
},
window: {
minimize: () => ipcRenderer.send('window-minimize'),
maximize: () => ipcRenderer.send('window-maximize'),
close: () => ipcRenderer.send('window-close'),
setMinimizeToTray: (value) => { ipcRenderer.send('set-minimize-to-tray', value); }
},
openExternal: (url) => {
if (!/^https?:\/\//.test(url)) { console.error('Invalid URL protocol'); return; }
shell.openExternal(url);
}
});
常见陷阱与优化方案
打包体积优化
这是 Electron 被吐槽最多的点。你写了个简单的记事本应用,代码可能就几十 KB,结果打包出来一个 exe,200 多 MB。为啥这么大?因为 Electron 把 Chromium 和 Node.js 的运行时都打包进去了。
怎么优化?首先,用 electron-builder 或者 electron-forge 的时候,配置一下 files 字段,别把 node_modules 里所有东西都塞进去。很多开发依赖(比如 eslint、webpack、babel)生产环境根本用不到。
module.exports = {
packagerConfig: {
ignore: [
/^\/src\/assets\/raw\//,
/^\/tests?\//,
/^\/\.git\//,
/^\/\.vscode\//,
/README\.md/,
/\.map$/
],
compression: 'maximum',
prune: true
},
makers: [
{
name: '@electron-forge/maker-squirrel',
config: {
name: 'my_electron_app',
setupIcon: 'assets/icon.ico'
}
}
]
};
其次,考虑用 electron-updater 做增量更新,这样用户不用每次都下载完整包。如果你的应用真的特别简单,可以看看 Tauri,那个体积确实小。
内存占用监控
Electron 应用的内存占用确实比原生应用高。Chromium 本身就是个内存大户,再加上 Node.js,开个应用占用几百 MB 内存是常态。如果你再不注意代码质量,内存泄漏起来,几个 GB 都能给你吃掉。
怎么监控?主进程和渲染进程都可以用 Node.js 的 process.memoryUsage() 来查看内存使用情况。渲染进程还可以在 Chrome 开发者工具的 Memory 面板里做 Heap Snapshot 分析。
setInterval(() => {
const usage = process.memoryUsage();
console.log('主进程内存使用:', {
rss: `${(usage.rss / 1024 / 1024).toFixed(2)} MB`,
heapTotal: `${(usage.heapTotal / 1024 / 1024).toFixed(2)} MB`,
heapUsed: `${(usage.heapUsed / 1024 / 1024).toFixed(2)} MB`,
external: `${(usage.external / 1024 / 1024).toFixed(2)} MB`
});
}, 30000);
自动更新配置
Electron 的自动更新功能,官方文档写得云里雾里,实际配置起来一堆坑。Windows 上要用 Squirrel.Windows,macOS 上要用 Squirrel.Mac,Linux 更麻烦,还得自己配服务器。
- 一个静态文件服务器,存放更新包(可以用 GitHub Releases、S3、或者自己的服务器)
- 签名证书(macOS 和 Windows 都需要,不然更新会失败)
- 在 main.js 里集成
electron-updater
const { autoUpdater } = require('electron-updater');
const log = require('electron-log');
autoUpdater.logger = log;
autoUpdater.logger.transports.file.level = 'info';
function checkForUpdates() {
if (process.env.NODE_ENV === 'development') return;
autoUpdater.checkForUpdatesAndNotify();
}
autoUpdater.on('checking-for-update', () => { console.log('正在检查更新...'); });
autoUpdater.on('update-available', (info) => { console.log('发现新版本:', info.version); });
autoUpdater.on('update-not-available', () => { console.log('当前已是最新版本'); });
autoUpdater.on('error', (err) => { console.error('更新出错:', err); });
autoUpdater.on('download-progress', (progressObj) => { let logMessage = `下载速度:${progressObj.bytesPerSecond}`; logMessage += ` - 已下载 ${progressObj.percent}%`; console.log(logMessage); });
autoUpdater.on('update-downloaded', (info) => { console.log('更新下载完成'); });
app.whenReady().then(() => {
createWindow();
setTimeout(checkForUpdates, 5000);
});
安全性加固
Electron 应用因为能直接访问系统 API,如果被攻击者利用,危害比普通网页大得多。几个必须做的安全措施:
- 开启上下文隔离(contextIsolation):前面代码里一直强调这个,一定要设为
true,不然渲染进程能直接访问 Node.js API,XSS 攻击直接变 RCE。
- 禁用 Node 集成(nodeIntegration):设为
false,通过 preload 脚本按需暴露 API。
- 内容安全策略(CSP):在 HTML 里设置 CSP 头,限制能加载的资源。
- 校验所有输入:特别是文件路径、URL 这些,防止目录遍历和协议劫持。
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.example.com;">
实战案例:本地记事本
理论说得差不多了,咱们来实战一个项目。做个极简的本地记事本,功能不多:能打字、能保存到本地文件、能全局快捷键快速隐藏。
界面就用 Tailwind CSS 随便画画,主打一个性冷淡风。核心功能是调用本地文件系统,数据存在用户电脑上,不用担心隐私泄露。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; font-src 'self' https://fonts.gstatic.com;">
<title>本地记事本</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
textarea:focus { outline: none; }
.drag-over { background-color: #f0f9ff !important; border: 2px dashed #3b82f6 !important; }
</style>
</head>
<body class="bg-gray-50 h-screen flex flex-col overflow-hidden">
<div class="h-10 bg-white border-b border-gray-200 flex items-center px-4 select-none drag-region">
<div class="flex items-center space-x-2">
<div class="w-3 h-3 rounded-full bg-red-400"></div>
<div class="w-3 h-3 rounded-full bg-yellow-400"></div>
<div class="w-3 h-3 rounded-full bg-green-400"></div>
</div>
<div class="flex-1 text-center text-sm text-gray-600 font-medium" id="title"> 本地记事本 - 未命名 </div>
<div class="text-xs text-gray-400" id="save-status"> 已就绪 </div>
</div>
<div class="bg-white border-b border-gray-200 px-4 py-2 flex items-center space-x-2">
<button onclick="newFile()" class="px-3 py-1.5 text-sm bg-gray-100 hover:bg-gray-200 rounded-md transition-colors flex items-center space-x-1"><span>📝</span><span>新建</span></button>
<button onclick="openFile()" class="px-3 py-1.5 text-sm bg-gray-100 hover:bg-gray-200 rounded-md transition-colors flex items-center space-x-1"><span>📂</span><span>打开</span></button>
<button onclick="saveFile()" class="px-3 py-1.5 text-sm bg-blue-500 hover:bg-blue-600 text-white rounded-md transition-colors flex items-center space-x-1"><span>💾</span><span>保存</span></button>
<div class="flex-1"></div>
<span class="text-xs text-gray-400" id="word-count">0 字</span>
</div>
<div class="flex-1 relative" id="drop-zone">
<textarea id="editor" class="w-full h-full p-6 resize-none bg-white text-gray-800 leading-relaxed text-base" placeholder="在这里开始记录..."></textarea>
</div>
<div class="bg-gray-100 border-t border-gray-200 px-4 py-1.5 flex items-center justify-between text-xs text-gray-500">
<div class="flex items-center space-x-4">
<span id="file-path">未选择文件</span>
<span id="encoding">UTF-8</span>
</div>
<div class="flex items-center space-x-4">
<span id="cursor-pos">行 1, 列 1</span>
<span>Electron + ❤️</span>
</div>
</div>
<script>
const editor = document.getElementById('editor');
const title = document.getElementById('title');
const saveStatus = document.getElementById('save-status');
const wordCount = document.getElementById('word-count');
const filePathDisplay = document.getElementById('file-path');
const cursorPos = document.getElementById('cursor-pos');
const dropZone = document.getElementById('drop-zone');
let currentFilePath = null;
let isModified = false;
let saveTimeout = null;
editor.addEventListener('input', () => {
updateWordCount();
markAsModified();
if (currentFilePath && saveTimeout) {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => { autoSave(); }, 2000);
}
});
editor.addEventListener(, updateCursorPos);
editor.(, updateCursorPos);
() {
text = editor.;
cnCount = (text.() || []).;
enCount = (text.() || []).;
total = cnCount + enCount + (text. - cnCount - (text.() || []).);
wordCount. = ;
}
() {
pos = editor.;
textUpToCursor = editor..(, pos);
lines = textUpToCursor.();
currentLine = lines.;
currentCol = lines[lines. - ]. + ;
cursorPos. = ;
}
() {
(!isModified) {
isModified = ;
();
saveStatus. = ;
saveStatus. = ;
}
}
() {
fileName = currentFilePath ? currentFilePath.().() : ;
title. = ;
}
() {
{
result = ...();
(result.) ;
(result.) {
editor. = result.;
currentFilePath = result.;
isModified = ;
();
();
filePathDisplay. = currentFilePath;
saveStatus. = ;
saveStatus. = ;
} {
( + result.);
}
} (error) {
.(, error);
();
}
}
() {
{
content = editor.;
result = ...(content, currentFilePath);
(result.) ;
(result.) {
currentFilePath = result.;
isModified = ;
();
filePathDisplay. = currentFilePath;
saveStatus. = ;
saveStatus. = ;
} {
( + result.);
}
} (error) {
.(, error);
();
}
}
() {
(!currentFilePath || !isModified) ;
{
result = ...(editor., currentFilePath);
(result.) {
isModified = ;
();
saveStatus. = + ().();
saveStatus. = ;
}
} (error) {
.(, error);
}
}
.(, {
isMac = navigator..().() >= ;
ctrlKey = isMac ? e. : e.;
(ctrlKey) {
(e..()) {
: e.(); (); ;
: e.(); (); ;
: e.(); (); ;
: e.(); ...(); ;
}
}
});
() {
(isModified) {
(!()) ;
}
editor. = ;
currentFilePath = ;
isModified = ;
();
();
filePathDisplay. = ;
saveStatus. = ;
saveStatus. = ;
}
</script>
</body>
</html>
const { app, BrowserWindow, globalShortcut, ipcMain } = require('electron');
function createWindow() {
const registerShortcuts = () => {
globalShortcut.register('CommandOrControl+Shift+H', () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
mainWindow.webContents.send('command', 'hide-window');
} else {
mainWindow.show();
mainWindow.focus();
}
});
globalShortcut.register('CommandOrControl+Shift+N', () => {
mainWindow.webContents.send('command', 'new-file');
});
};
mainWindow.once('ready-to-show', () => {
mainWindow.show();
registerShortcuts();
});
}
app.on('will-quit', () => {
globalShortcut.unregisterAll();
});
ipcMain.on('hide-window', () => {
if (mainWindow) {
mainWindow.hide();
}
});
这个记事本虽然功能简单,但涵盖了 Electron 开发的核心要素:窗口管理、IPC 通信、文件系统访问、全局快捷键、拖拽交互。你可以在此基础上加更多功能,比如 Markdown 预览、多标签页、云同步等等。
最后打包成安装包,用 npm run make(如果你用 electron-forge 的话),然后在 out 目录里找到你的.exe 或者.dmg 文件,发给朋友炫耀吧。虽然这玩意儿就是个套壳的网页,但双击图标打开的那一刻,逼格确实比打开浏览器输入 localhost:3000 高多了。
调试与性能排查
主进程崩溃排查
主进程崩了最麻烦,因为开发者工具是跟着渲染进程的,主进程报错你根本看不见。这时候日志系统就很重要了。推荐用 electron-log 这个库,它能把日志写到本地文件里,崩溃了去日志文件里找线索。
const log = require('electron-log');
log.transports.file.resolvePath = () => {
return path.join(app.getPath('userData'), 'logs/main.log');
};
Object.assign(console, log.functions);
process.on('uncaughtException', (error) => {
log.error('未捕获的异常:', error);
dialog.showErrorBox('应用出错', error.message);
});
process.on('unhandledRejection', (reason, promise) => {
log.error('未处理的 Promise rejection:', reason);
});
渲染进程白屏处理
有时候窗口打开了,但里面是白的,开发者工具也打不开。这通常是渲染进程崩溃了或者加载失败了。可能的原因:HTML 路径写错了、preload 脚本报错了、CSP 拦截了资源。
调试方法:在 main.js 里监听 render-process-gone 事件,能看到崩溃原因。
mainWindow.webContents.on('render-process-gone', (event, details) => {
console.error('渲染进程崩溃:', details.reason);
log.error('渲染进程崩溃:', details);
dialog.showErrorBox('页面崩溃', '应用页面发生错误,即将重启');
app.relaunch();
app.quit();
});
内存泄漏排查
如果你发现应用越跑越慢,内存占用越来越高,那可能是内存泄漏了。Chrome 开发者工具的 Memory 面板是神器,可以拍 Heap Snapshot 对比分析。
在 Electron 里,你可以这样打开开发者工具:
mainWindow.webContents.openDevTools();
mainWindow.webContents.openDevTools({ mode: 'detach' });
mainWindow.webContents.devToolsWebContents?.executeJavaScript(` UI.inspectorView.showPanel('heap_profiler'); `);
版本对齐
Electron 社区里那些奇奇怪怪的报错,十有八九是版本问题。Electron 版本更新很快,API 经常有变动。比如 remote 模块在 Electron 12 之后默认禁用,14 之后彻底移除;contextIsolation 默认值也改过。
- 锁定
package.json 里的 Electron 版本,别随便升级
- 查文档的时候看清楚版本号,别拿着 v28 的文档去调 v20 的 API
- 原生模块(比如 sqlite3、sharp)需要针对 Electron 版本重新编译,用
electron-rebuild 工具
npm install --save-dev electron-rebuild
npx electron-rebuild
npx electron-rebuild -v 28.0.0
进阶技巧
逻辑分离
渲染进程里跑太多计算密集型任务,界面会卡成 PPT。比如你要处理个大 JSON 文件、生成个 PDF、压缩图片,这些都应该扔给主进程或者 Worker 线程。
const { Worker } = require('worker_threads');
ipcMain.handle('heavy-task', async (event, data) => {
return new Promise((resolve, reject) => {
const worker = new Worker('./src/worker.js');
worker.postMessage(data);
worker.on('message', (result) => {
resolve(result);
worker.terminate();
});
worker.on('error', reject);
});
});
上下文隔离
前面说了无数遍了,contextIsolation: true 是保命设置。别为了图方便关了它,那样做等于把 Node.js API 直接暴露在网页里,XSS 攻击直接变系统级攻击。
多窗口架构
如果你的应用需要多个窗口(比如主窗口 + 设置窗口 + 关于窗口),别傻乎乎地每个窗口都新建一个 BrowserWindow 然后加载不同 HTML。可以做个窗口管理器,统一管理窗口状态、通信、生命周期。
class WindowManager {
constructor() {
this.windows = new Map();
}
create(name, options) {
if (this.windows.has(name)) {
const win = this.windows.get(name);
win.focus();
return win;
}
const win = new BrowserWindow(options);
this.windows.set(name, win);
win.on('closed', () => {
this.windows.delete(name);
});
return win;
}
get(name) {
return this.windows.get(name);
}
broadcast(channel, ...args) {
this.windows.forEach(win => {
if (!win.isDestroyed()) {
win.webContents.send(channel, ...args);
}
});
}
}
module.exports = new WindowManager();
资源压缩
打包的时候把图片压缩一下,CSS 和 JS 用 webpack 或者 vite 压缩,别让安装包胖得像个球。Electron-builder 支持 asar 打包,能把资源文件打包成归档格式,减小体积还能防篡改。
packagerConfig: {
asar: true,
asarUnpack: ['node_modules/sharp/**', 'assets/large-files/**']
}
总结与建议
说了这么多 Electron 的好,也得泼点冷水。这玩意儿确实不是银弹,有些场景下用它就是给自己找不痛快。
如果你只是写个简单的内部工具,比如给运营同学用的数据录入工具、给测试同学用的接口调试工具,Electron 确实爽翻天。开发快、跨平台、界面好看,还能直接调用本地资源,比用 Python 写个 Tkinter 界面强多了。
但如果你要做的是性能敏感型应用,比如视频剪辑软件、大型游戏、专业音频处理,趁早换赛道。Electron 的性能瓶颈在 Chromium 和 Node.js 的架构上,不是优化能解决的。给十年前的老电脑用?别想了,Electron 应用最低配置要求就不低。
还有,如果你的应用需要深度系统集成,比如杀毒软件、系统优化工具、驱动程序,Electron 也搞不定。它毕竟是个套壳浏览器,系统底层的东西碰不了。
技术选型这事儿,最忌讳的就是"为了用而用"。我见过太多人,明明就是个简单的表单页面,非要用 Electron 打包成客户端,结果用户下载个 200MB 的安装包,打开一看就是个网页,骂声一片。也见过人明明要做个高性能的图像处理工具,非要用 Electron 写,结果处理个大图卡成狗。
所以啊,7 天学会 Electron 是真的,用它搞个项目放简历上加分也是真的,但别神话它。工具就是工具,合适的场景用合适的工具,这才是老手的做法。
记住,看十遍不如写一遍,遇到报错先查版本,内存爆了记得打快照,打包体积太大就精简依赖。祝你早日搞出自己的桌面应用,工资翻倍那天记得回来请我喝咖啡——虽然咱们这是文字交流,咖啡就心领了吧。
'keyup'
addEventListener
'click'
function
updateWordCount
const
value
const
match
/[\u4e00-\u9fa5]/g
length
const
match
/[a-zA-Z]+/g
length
const
length
match
/[a-zA-Z]/g
length
textContent
`${total} 字`
function
updateCursorPos
const
selectionStart
const
value
substring
0
const
split
'\n'
const
length
const
length
1
length
1
textContent
`行 ${currentLine}, 列 ${currentCol}`
function
markAsModified
if
true
updateTitle
textContent
'未保存'
className
'text-xs text-orange-500'
function
updateTitle
const
split
/[\\/]/
pop
'未命名'
textContent
`本地记事本 - ${fileName}${isModified ? ' *' : ''}`
async
function
openFile
try
const
await
window
electronAPI
file
openDialog
if
canceled
return
if
success
value
content
path
false
updateTitle
updateWordCount
textContent
textContent
'已保存'
className
'text-xs text-green-600'
else
alert
'打开文件失败:'
error
catch
console
error
'打开文件出错:'
alert
'打开文件时发生错误'
async
function
saveFile
try
const
value
const
await
window
electronAPI
file
saveDialog
if
canceled
return
if
success
path
false
updateTitle
textContent
textContent
'已保存'
className
'text-xs text-green-600'
else
alert
'保存失败:'
error
catch
console
error
'保存文件出错:'
alert
'保存文件时发生错误'
async
function
autoSave
if
return
try
const
await
window
electronAPI
file
saveDialog
value
if
success
false
updateTitle
textContent
'自动保存于 '
new
Date
toLocaleTimeString
className
'text-xs text-blue-500'
catch
console
error
'自动保存失败:'
document
addEventListener
'keydown'
(e) =>
const
platform
toUpperCase
indexOf
'MAC'
0
const
metaKey
ctrlKey
if
switch
key
toLowerCase
case
'n'
preventDefault
newFile
break
case
'o'
preventDefault
openFile
break
case
's'
preventDefault
saveFile
break
case
'h'
preventDefault
window
electronAPI
window
hide
break
function
newFile
if
if
confirm
'当前文件未保存,确定要新建吗?'
return
value
''
null
false
updateTitle
updateWordCount
textContent
'未选择文件'
textContent
'已就绪'
className
'text-xs text-gray-400'
相关免费在线工具
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online