跳到主要内容
7 天用 Electron 开发跨平台桌面应用实战指南 | 极客日志
JavaScript Node.js 大前端
7 天用 Electron 开发跨平台桌面应用实战指南 Electron 桌面应用开发实战涵盖了架构理解、环境搭建、安全配置及性能优化等关键环节。通过对比主进程与渲染进程通信机制,结合脚手架工具快速启动项目。文中包含本地文本编辑器的完整实现示例,涉及文件系统操作、全局快捷键绑定及自动更新集成。此外还总结了内存泄漏排查、版本对齐及调试技巧,为前端开发者拓展跨平台能力提供实用参考。
lzdxwyh 发布于 2026/3/29 更新于 2026/4/25 1 浏览前端人别卷网页了!7 天用 Electron 搞定桌面应用
前言:为什么你的简历还缺个'桌面端'项目
现在前端行业竞争日益激烈,打开招聘网站,十个岗位九个要求'全栈',剩下的那个写着'精通 Vue/React,有小程序经验优先'——点进去一聊,往往还会问'会不会搞点桌面端的东西'。
当年咱们入坑前端的时候,不就是说好了'一次编写,到处运行'吗?结果现在倒好,H5 卷不动了卷小程序,小程序卷不动了又开始卷桌面端。前两天我一哥们儿面试,面试官原话是:'你们前端现在不都会 Electron 吗?我们后台管理系统想做个客户端版本。'
能碰,太能碰了。而且这玩意儿上手比你想象得简单十倍,但简历上写出来逼格能高十倍。你想啊,别人简历上都是'开发了 XX 管理系统',你写的是'独立开发了跨平台桌面客户端应用,支持 Windows/macOS 双平台'——这差距,就像你穿拖鞋去面试和穿皮鞋去面试的区别。
更关键的是,现在老板们总觉得'客户端'比'网页'高级。上周我们产品经理突然在群里@我:'能不能把咱们的后台搞成软件那种,双击图标就能打开,别老是用浏览器。'我当时就想回他:'您知道浏览器就是个软件吗?'但转念一想,算了,跟钱过不去干嘛。花三天时间用 Electron 套了个壳,老板看完演示直接说:'这个好,专业!'
所以啊,别觉得桌面端离你很远。你现在的 HTML、CSS、JavaScript 功底完全够用,甚至那些让你头秃的浏览器兼容性问题在这里都不存在了——因为 Electron 里面就是个 Chrome,版本还是你定的。不用现学 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 里就是家常便饭。
但这里有个特别有意思的设计,也是新手最容易懵圈的地方:Electron 把这两个东西分成了'主进程'(Main Process)和'渲染进程'(Renderer Process)。主进程就是那个跑 Node.js 的,它负责创建窗口、管理应用生命周期、搞系统级别的操作。渲染进程就是每个窗口里的 Chromium 实例,负责展示界面、响应用户交互。
这俩进程怎么通信呢?官方给了一套 IPC(进程间通信)机制。简单来说,渲染进程想读文件?行,但它不能直接读,得发消息给主进程:'哥,帮我读一下 C 盘这个文件。'主进程读完了再发回去:'给,内容在这儿。'听起来挺绕的,但这设计其实是为了安全——要是渲染进程能随便访问系统 API,那用户打开个第三方页面不就完蛋了?
const { app, BrowserWindow , ipcMain } = require ('electron' );
const path = require ('path' );
const fs = require ('fs' );
let mainWindow;
function createWindow ( ) {
mainWindow = ({
: ,
: ,
: {
: path. (__dirname, ),
: ,
: ,
:
}
});
mainWindow. ( );
mainWindow. ( , {
mainWindow = ;
});
}
app. (). (createWindow);
app. ( , {
(process. !== ) {
app. ();
}
});
app. ( , {
( . (). === ) {
();
}
});
ipcMain. ( , (event, filePath) => {
{
content = fs. . (filePath, );
{ : , content };
} (error) {
{ : , : error. };
}
});
ipcMain. ( , (event, filePath, content) => {
{
(fs. (filePath)) {
backupPath = ;
fs. . (filePath, backupPath);
}
fs. . (filePath, content, );
{ : };
} (error) {
{ : , : error. };
}
});
new
BrowserWindow
width
1200
height
800
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
handle
'write-file'
async
try
if
existsSync
const
`${filePath} .backup.${Date .now()} `
await
promises
copyFile
await
promises
writeFile
'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),
writeFile : (filePath, content ) => ipcRenderer.invoke ('write-file' , filePath, content),
platform : process.platform ,
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 ;
}
.status {
margin-top : 10px ;
padding : 10px ;
border-radius : 4px ;
display : none;
}
.status .success {
background : #d4edda ;
color : #155724 ;
display : block;
}
.status .error {
background : #f8d7da ;
color : #721c24 ;
display : block;
}
</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 id ="status" class ="status" > </div >
</div >
<script >
document .getElementById ('platform' ).textContent = window .electronAPI .platform ;
document .getElementById ('version' ).textContent = window .electronAPI .versions .electron ;
const editor = document .getElementById ('editor' );
const statusDiv = document .getElementById ('status' );
let currentFilePath = null ;
function showStatus (message, isError = false ) {
statusDiv.textContent = message;
statusDiv.className = 'status ' + (isError ? 'error' : 'success' );
setTimeout (() => {
statusDiv.className = 'status' ;
}, 3000 );
}
async function openFile ( ) {
const path = 'test.txt' ;
result = . . (path);
(result. ) {
editor. = result. ;
currentFilePath = path;
( );
} {
( + result. , );
}
}
( ) {
path = currentFilePath || ;
content = editor. ;
result = . . (path, content);
(result. ) {
currentFilePath = path;
( );
} {
( + result. , );
}
}
</script >
</body >
</html >
除了 Electron,这几个备胎也得认识 虽然我现在在教 Electron,但咱得客观。这玩意儿不是唯一的选项,甚至有些场景下它不是最好的选择。作为技术人员,最怕的就是手里只有一把锤子,看什么都像钉子。
Tauri 这两年火得一塌糊涂,用 Rust 写的,前端还是用你熟悉的 HTML/CSS/JS,但后端不是 Node.js 了,是 Rust。它的最大卖点是打包体积小,一般也就几 MB,比 Electron 那动辄上百 MB 的体型优雅多了。而且内存占用也低,毕竟不用带个完整的 Chromium。但缺点是 Rust 学习曲线陡,生态也不如 Node.js 丰富。如果你要做个简单的工具类应用,对体积敏感,Tauri 确实值得看看。
NW.js 算是 Electron 的前辈了,当年叫 node-webkit。它的架构和 Electron 不太一样,Node.js 和 Chromium 运行在同一个上下文里,代码写起来更直接,不用搞什么 IPC 通信。但这也带来了安全隐患,而且社区活跃度现在不如 Electron,大厂背书也少。
还有 Flutter Desktop 、React Native Windows 这些,但那些已经不算'前端技术栈'了,得学 Dart 或者原生开发那一套,咱们今天就不展开说了。
为啥大厂都爱用 Electron? 你可能要问了,既然 Electron 有这么多缺点(后面我会详细吐槽),为啥 VS Code、Discord、Slack、Figma 这些大牌都用它?
答案很简单:开发效率 。对于这些公司来说,招一个会 JavaScript 的前端工程师太容易了,招一个会 C++ 还会 Qt 的桌面端开发?那难度和成本都高得多。而且 Electron 的跨平台能力是真的香,一套代码打三个包(Windows、macOS、Linux),虽然细节上还是要做适配,但总比写三份代码强。
VS Code 团队曾经分享过,他们用 Electron 能快速迭代,每周都能发版本,这在原生开发里是很难想象的。而且现代电脑的配置越来越高,用户其实没那么在意那几百 MB 的体积和几百 MB 的内存占用——只要你的软件好用。Discord 和 Slack 也是这个道理,它们的核心竞争力在于网络通信和用户体验,而不是极致的性能优化。
但你要注意,这些大厂用 Electron,不代表你也要无脑选。他们有一整个团队来优化性能、处理兼容性、维护更新机制。你一个人搞项目,要是做个简单的计算器也用 Electron,用户下载个 200MB 的安装包,打开一看就是个加减乘除,不骂娘才怪。
手把手教你把网页'塞'进桌面图标里
脚手架一把梭,别傻傻敲命令 我知道很多教程喜欢从 npm init 开始,一步步教你配 webpack、配 babel、配这配那。我呸,都 2024 年了,谁还这么干啊?直接上脚手架,五分钟跑起来 Hello World,不香吗?
官方推荐的 electron-forge 就挺好用,或者社区里流行的 electron-vite(如果你喜欢用 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/
forge.config.js 里可以配置打包选项,比如应用图标、安装包格式、签名证书这些。开发阶段基本不用动它,默认配置就能跑。
main.js 里的那些破事儿 前面我已经给了一个基础的 main.js 示例,但实际项目里,你要考虑的事情还多着呢。比如窗口大小怎么记?用户上次把窗口拖成啥样了,下次打开应该保持吧?还有系统托盘,很多应用点了关闭其实是最小化到托盘,而不是真的退出。
const { app, BrowserWindow , Tray , Menu , ipcMain, dialog } = require ('electron' );
const path = require ('path' );
const fs = require ('fs' );
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 ();
}
});
ipcMain.handle ('show-open-dialog' , async () => {
const result = await dialog.showOpenDialog (mainWindow, {
properties : ['openFile' ],
filters : [
{ name : '文本文件' , extensions : ['txt' , 'md' ] },
{ name : '所有文件' , extensions : ['*' ] }
]
});
if (!result.canceled && result.filePaths .length > 0 ) {
try {
const content = await fs.promises .readFile (result.filePaths [0 ], 'utf-8' );
return { success : true , content, path : result.filePaths [0 ] };
} catch (error) {
return { success : false , error : error.message };
}
}
return { canceled : true };
});
ipcMain.handle ('show-save-dialog' , async (event, content, defaultPath) => {
const result = await dialog.showSaveDialog (mainWindow, {
defaultPath : defaultPath || 'untitled.txt' ,
filters : [
{ name : '文本文件' , extensions : ['txt' ] },
{ name : 'Markdown' , extensions : ['md' ] }
]
});
if (!result.canceled ) {
try {
await fs.promises .writeFile (result.filePath , content, 'utf-8' );
return { success : true , path : result.filePath };
} catch (error) {
return { success : false , error : error.message };
}
}
return { canceled : true };
});
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: Path not allowed' );
}
return ipcRenderer.invoke ('read-file' , filePath);
},
write : async (filePath, content) => {
if (!isPathAllowed (filePath)) {
throw new Error ('Access denied: Path not allowed' );
}
if (Buffer .byteLength (content, 'utf-8' ) > 10 * 1024 * 1024 ) {
throw new Error ('File too large (max 10MB)' );
}
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);
},
onUpdateAvailable : (callback ) => {
ipcRenderer.on ('update-available' , callback);
},
removeAllListeners : (channel ) => {
ipcRenderer.removeAllListeners (channel);
}
});
常见陷阱与优化方案
打包体积:塞了整个 Chrome 进去? 这是 Electron 被吐槽最多的点。你写了个简单的记事本应用,代码可能就几十 KB,结果打包出来一个 exe,200 多 MB。用户下载的时候还以为你塞了什么高清大片进去。
为啥这么大?因为 Electron 把 Chromium 和 Node.js 的运行时都打包进去了。Chromium 本身就几十 MB,Node.js 也有几十 MB,再加上各种依赖,体积就上去了。
怎么优化?首先,用 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' ,
skipUpdateIcon : true
}
},
{
name : '@electron-forge/maker-dmg' ,
config : {
icon : 'assets/icon.icns' ,
background : 'assets/dmg-background.png' ,
windowSize : {
width : 600 ,
height : 400
}
}
}
]
};
其次,考虑用 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 );
setInterval (() => {
const usage = performance.memory ;
console .log ('渲染进程内存:' , {
usedJSHeapSize : `${(usage.usedJSHeapSize / 1024 / 1024 ).toFixed(2 )} MB` ,
totalJSHeapSize : `${(usage.totalJSHeapSize / 1024 / 1024 ).toFixed(2 )} MB` ,
jsHeapSizeLimit : `${(usage.jsHeapSizeLimit / 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 );
if (mainWindow) {
mainWindow.webContents .send ('update-available' , info);
}
});
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} %` ;
logMessage += ` (${progressObj.transferred} /${progressObj.total} )` ;
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 ="在这里开始你的创作大业... 快捷键: Ctrl/Cmd + N 新建 Ctrl/Cmd + O 打开 Ctrl/Cmd + S 保存 Ctrl/Cmd + H 老板键(隐藏窗口)" > </textarea >
<div id ="drag-overlay" class ="absolute inset-0 bg-blue-50 bg-opacity-90 border-4 border-blue-400 border-dashed hidden flex items-center justify-center" >
<div class ="text-center" >
<div class ="text-4xl mb-2" > 📄</div >
<div class ="text-lg text-blue-600 font-medium" > 松开以打开文件</div >
</div >
</div >
</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' );
const dragOverlay = document .getElementById ('drag-overlay' );
let currentFilePath = null ;
let isModified = false ;
let saveTimeout = null ;
window .addEventListener ('DOMContentLoaded' , async () => {
const lastFile = localStorage .getItem ('lastOpenedFile' );
if (lastFile) {
. ( , lastFile);
}
});
editor. ( , {
();
();
(currentFilePath && saveTimeout) {
(saveTimeout);
saveTimeout = ( {
();
}, );
}
});
editor. ( , 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. = ;
}
( ) {
(isModified) {
(! ( )) {
;
}
}
editor. = ;
currentFilePath = ;
isModified = ;
();
();
filePathDisplay. = ;
saveStatus. = ;
saveStatus. = ;
. ( );
}
( ) {
{
result = . . . ();
(result. ) ;
(result. ) {
editor. = result. ;
currentFilePath = result. ;
isModified = ;
();
();
filePathDisplay. = currentFilePath;
saveStatus. = ;
saveStatus. = ;
. ( , currentFilePath);
} {
( + result. );
}
} (error) {
. ( , error);
( );
}
}
( ) {
{
content = editor. ;
result = . . . (content, currentFilePath);
(result. ) ;
(result. ) {
currentFilePath = result. ;
isModified = ;
();
filePathDisplay. = currentFilePath;
saveStatus. = ;
saveStatus. = ;
. ( , currentFilePath);
originalText = saveStatus. ;
saveStatus. = ;
( {
saveStatus. = originalText;
}, );
} {
( + 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. ();
. . . ();
;
}
}
});
dropZone. ( , {
e. ();
dropZone. . ( );
dragOverlay. . ( );
});
dropZone. ( , {
e. ();
dropZone. . ( );
dragOverlay. . ( );
});
dropZone. ( , (e) => {
e. ();
dropZone. . ( );
dragOverlay. . ( );
files = e. . ;
(files. > ) {
file = files[ ];
(!file. . ( ) && !file. . ( )) {
( );
;
}
reader = ();
reader. = {
editor. = event. . ;
currentFilePath = file. ;
isModified = ;
();
();
filePathDisplay. = currentFilePath;
};
reader. (file);
}
});
. . ?.( {
(command) {
:
();
;
:
();
;
:
();
;
:
(isModified && currentFilePath) {
();
}
;
}
});
</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 是真的,用它搞个项目放简历上加分也是真的,但别神话它。工具就是工具,合适的场景用合适的工具,这才是老手的做法。
好了,絮叨了这么多,代码也给了不少,剩下的就是你自己动手折腾了。记住,看十遍不如写一遍,遇到报错先查版本,内存爆了记得打快照,打包体积太大就精简依赖。祝你早日搞出自己的桌面应用,工资翻倍那天记得回来请我喝咖啡——虽然咱们这是文字交流,咖啡就心领了吧。
const
await
window
electronAPI
readFile
if
success
value
content
showStatus
'文件加载成功!'
else
showStatus
'打开失败:'
error
true
async
function
saveFile
const
'test.txt'
const
value
const
await
window
electronAPI
writeFile
if
success
showStatus
'保存成功!'
else
showStatus
'保存失败:'
error
true
console
log
'上次打开的文件:'
addEventListener
'input'
() =>
updateWordCount
markAsModified
if
clearTimeout
setTimeout
() =>
autoSave
2000
addEventListener
'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 ? ' *' : '' } `
function
newFile
if
if
confirm
'当前文件未保存,确定要新建吗?'
return
value
''
null
false
updateTitle
updateWordCount
textContent
'未选择文件'
textContent
'已就绪'
className
'text-xs text-gray-400'
localStorage
removeItem
'lastOpenedFile'
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'
localStorage
setItem
'lastOpenedFile'
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'
localStorage
setItem
'lastOpenedFile'
const
textContent
textContent
'保存成功!'
setTimeout
() =>
textContent
2000
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
addEventListener
'dragover'
(e ) =>
preventDefault
classList
add
'drag-over'
classList
remove
'hidden'
addEventListener
'dragleave'
(e ) =>
preventDefault
classList
remove
'drag-over'
classList
add
'hidden'
addEventListener
'drop'
async
preventDefault
classList
remove
'drag-over'
classList
add
'hidden'
const
dataTransfer
files
if
length
0
const
0
if
type
match
'text.*'
name
match
/\.(txt|md|json|js|html|css)$/i
alert
'暂不支持该文件类型,请拖拽文本文件'
return
const
new
FileReader
onload
(event ) =>
value
target
result
path
false
updateTitle
updateWordCount
textContent
readAsText
window
electronAPI
onCommand
(event, command ) =>
switch
case
'new-file'
newFile
break
case
'open-file'
openFile
break
case
'save-file'
saveFile
break
case
'hide-window'
if
autoSave
break
相关免费在线工具 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