前端人别卷网页了!7天用Electron搞定桌面应用,工资翻倍不是梦
前端人别卷网页了!7天用Electron搞定桌面应用,工资翻倍不是梦
- 前端人别卷网页了!7天用Electron搞定桌面应用,工资翻倍不是梦
前端人别卷网页了!7天用Electron搞定桌面应用,工资翻倍不是梦
瞎扯淡的开场白:为什么你的简历还缺个"桌面端"项目
说实话,现在前端这行卷到什么程度了?打开BOSS直聘,十个岗位九个要求"全栈",剩下那个写着"精通Vue/React,有小程序经验优先"——点进去一聊,人家还问你"会不会搞点桌面端的东西"。
我就纳了闷了,当年咱们入坑前端的时候,不就是说好了"一次编写,到处运行"吗?结果现在倒好,H5卷不动了卷小程序,小程序卷不动了又开始卷桌面端。前两天我一哥们儿面试,面试官原话是:"你们前端现在不都会Electron吗?我们后台管理系统想做个客户端版本。"哥们儿当场懵逼,回来就问我:“这玩意儿不是搞VS Code那帮大佬玩的吗?咱这水平能碰?”
能碰,太能碰了。而且我跟你说,这玩意儿上手比你想象得简单十倍,但简历上写出来逼格能高十倍。你想啊,别人简历上都是"开发了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,那用户打开个第三方页面不就完蛋了?
// 主进程 main.js - 这就是整个应用的入口,Node.js环境const{ app, BrowserWindow, ipcMain }=require('electron');const path =require('path');const fs =require('fs');// 保持窗口对象的全局引用,不然会被垃圾回收搞没let mainWindow;functioncreateWindow(){// 创建一个浏览器窗口,这玩意儿就是渲染进程的容器 mainWindow =newBrowserWindow({width:1200,height:800,webPreferences:{// 这个很关键,把预加载脚本挂上去,打通主进程和渲染进程的任督二脉preload: path.join(__dirname,'preload.js'),// 一定要开上下文隔离,后面讲安全的时候再说为啥contextIsolation:true,// 别允许远程模块,这玩意儿坑过太多人,官方都弃用了enableRemoteModule:false,// 渲染进程里直接用Node.js?想啥呢,关了关了nodeIntegration:false}});// 加载本地HTML文件,也可以加载远程URL,但生产环境别这么干 mainWindow.loadFile('index.html');// 打开开发者工具,调试神器,打包的时候记得注释掉// mainWindow.webContents.openDevTools();// 窗口关闭时的清理工作 mainWindow.on('closed',()=>{ mainWindow =null;});}// Electron初始化完成,准备创建窗口 app.whenReady().then(createWindow);// 所有窗口都关了,应用退出(Windows/Linux上) app.on('window-all-closed',()=>{// macOS上习惯不一样,点了红叉应用还在后台跑if(process.platform !=='darwin'){ app.quit();}});// macOS特有:点击dock图标重新创建窗口 app.on('activate',()=>{if(BrowserWindow.getAllWindows().length ===0){createWindow();}});// 处理来自渲染进程的读文件请求 ipcMain.handle('read-file',async(event, filePath)=>{try{// 这里可以做路径校验,别让用户读到不该读的东西const content =await fs.promises.readFile(filePath,'utf-8');return{success:true, content };}catch(error){return{success:false,error: error.message };}});// 处理写文件请求,带个简单的备份逻辑,显得专业 ipcMain.handle('write-file',async(event, filePath, content)=>{try{// 先备份原文件,万一写崩了还能恢复if(fs.existsSync(filePath)){const backupPath =`${filePath}.backup.${Date.now()}`;await fs.promises.copyFile(filePath, backupPath);}await fs.promises.writeFile(filePath, content,'utf-8');return{success:true};}catch(error){return{success:false,error: error.message };}});// preload.js - 预加载脚本,这是渲染进程和主进程之间的"翻译官"const{ contextBridge, ipcRenderer }=require('electron');// 用contextBridge把API暴露给渲染进程,这样比较安全 contextBridge.exposeInMainWorld('electronAPI',{// 暴露读文件的方法,渲染进程调用这个就能间接用到Node.js的fs模块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 }});<!-- index.html - 这就是普通的HTML,跟写网页一模一样 --><!DOCTYPEhtml><html><head><metacharset="UTF-8"><title>我的第一个Electron应用</title><style>/* 这里随便写CSS,反正里面是个Chrome,ES6+随便用 */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><divclass="container"><h1>🚀 Electron 文件编辑器</h1><p>当前平台:<spanid="platform"></span> | Electron版本:<spanid="version"></span></p><textareaid="editor"placeholder="在这里输入内容..."></textarea><div><buttononclick="openFile()">📂 打开文件</button><buttononclick="saveFile()">💾 保存文件</button></div><divid="status"class="status"></div></div><script>// 页面加载时显示系统信息,这些数据是从preload.js里暴露出来的 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;// 显示状态消息,成功失败不同颜色functionshowStatus(message, isError =false){ statusDiv.textContent = message; statusDiv.className ='status '+(isError ?'error':'success');setTimeout(()=>{ statusDiv.className ='status';},3000);}// 打开文件,这里调用的其实是主进程的fs.readFileasyncfunctionopenFile(){// 实际项目中应该用对话框让用户选文件,这里简化一下直接写死路径演示const path ='test.txt';const result =await window.electronAPI.readFile(path);if(result.success){ editor.value = result.content; currentFilePath = path;showStatus('文件加载成功!');}else{showStatus('打开失败:'+ result.error,true);}}// 保存文件,同样是通过IPC交给主进程处理asyncfunctionsaveFile(){const path = currentFilePath ||'test.txt';const content = editor.value;const result =await window.electronAPI.writeFile(path, content);if(result.success){ currentFilePath = path;showStatus('保存成功!');}else{showStatus('保存失败:'+ result.error,true);}}</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,因为它封装好了打包、签名、自动更新这些脏活累活,省得你后面踩坑。
# 全局安装electron-forge的CLI工具npminstall -g @electron-forge/cli # 创建新项目,my-app是你的项目名 electron-forge init my-app # 进入目录cd my-app # 启动开发模式,带热重载的,改代码自动刷新npm start 就这么三行命令,一个完整的Electron项目就搭好了。目录结构长这样:
my-app/ ├── src/ # 源代码目录 │ ├── index.html # 主界面 │ ├── index.js # 主进程入口(就是前面的main.js) │ ├── preload.js # 预加载脚本 │ └── renderer.js # 渲染进程的JS(如果你不想把JS写在HTML里) ├── forge.config.js # Electron Forge的配置文件 ├── package.json # 依赖管理 └── assets/ # 放图标、图片等静态资源 forge.config.js里可以配置打包选项,比如应用图标、安装包格式、签名证书这些。开发阶段基本不用动它,默认配置就能跑。
main.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');// 需要npm install electron-store// 用这个库来存用户配置,比直接读写文件方便多了const store =newStore();let mainWindow;let tray;functioncreateWindow(){// 从store里读取上次保存的窗口状态,没有就用默认值const windowState = store.get('windowState',{width:1200,height:800,x:undefined,y:undefined,maximized:false}); mainWindow =newBrowserWindow({width: windowState.width,height: windowState.height,x: windowState.x,y: windowState.y,minWidth:800,// 限制最小尺寸,别让用户缩得太小minHeight:600,titleBarStyle:'hiddenInset',// macOS上好看一点的标题栏show:false,// 先不显示,等加载完了再show,避免白屏尴尬webPreferences:{preload: path.join(__dirname,'preload.js'),contextIsolation:true,enableRemoteModule:false,nodeIntegration:false,// 安全相关:禁止eval,禁止外部内容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;});}// 创建系统托盘图标functioncreateTray(){// 不同平台图标格式不一样,Windows用ico,macOS用png/templateconst iconPath = process.platform ==='win32'? path.join(__dirname,'assets','tray.ico'): path.join(__dirname,'assets','trayTemplate.png'); tray =newTray(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();// macOS上点击dock图标重新创建窗口 app.on('activate',()=>{if(BrowserWindow.getAllWindows().length ===0){createWindow();}});});// 所有窗口关闭时退出(Windows/Linux) 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里的实现,渲染进程的代码可以完全不动。
// 一个更完善的preload.js示例const{ contextBridge, ipcRenderer, shell }=require('electron');// 白名单机制,只允许访问特定的文件路径constALLOWED_PATHS=[ app.getPath('userData'),// 应用数据目录 app.getPath('documents'),// 用户文档目录// 其他允许的路径...];functionisPathAllowed(filePath){// 简单的路径校验,防止目录遍历攻击const normalizedPath = path.normalize(filePath);returnALLOWED_PATHS.some(allowedPath=> normalizedPath.startsWith(allowedPath));} contextBridge.exposeInMainWorld('electronAPI',{// 文件操作,带路径白名单校验file:{read:async(filePath)=>{if(!isPathAllowed(filePath)){thrownewError('Access denied: Path not allowed');}return ipcRenderer.invoke('read-file', filePath);},write:async(filePath, content)=>{if(!isPathAllowed(filePath)){thrownewError('Access denied: Path not allowed');}// 内容大小校验,别让用户写个几个G的文件把硬盘撑爆if(Buffer.byteLength(content,'utf-8')>10*1024*1024){thrownewError('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)=>{// 简单的URL校验,只允许http/httpsif(!/^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)生产环境根本用不到。
// forge.config.js 示例,控制打包内容 module.exports ={packagerConfig:{// 忽略这些文件和目录,减小体积ignore:[/^\/src\/assets\/raw\//,// 原始资源文件/^\/tests?\//,// 测试文件/^\/\.git\//,// git历史/^\/\.vscode\//,// 编辑器配置/README\.md/,// 文档/\.map$/// source map文件],// 压缩级别,9是最高压缩,但打包时间会长compression:'maximum',// 去掉调试符号,减小体积prune:true},makers:[{name:'@electron-forge/maker-squirrel',config:{// Windows安装包配置name:'my_electron_app',// 图标路径setupIcon:'assets/icon.ico',// 跳过创建Delta包,如果你不需要增量更新的话skipUpdateIcon:true}},{name:'@electron-forge/maker-dmg',config:{// macOS DMG配置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分析。
// 在main.js里定期打印内存使用情况,调试用setInterval(()=>{const usage = process.memoryUsage(); console.log('主进程内存使用:',{rss:`${(usage.rss /1024/1024).toFixed(2)} MB`,// 常驻内存heapTotal:`${(usage.heapTotal /1024/1024).toFixed(2)} MB`,// V8总堆内存heapUsed:`${(usage.heapUsed /1024/1024).toFixed(2)} MB`,// V8已用堆内存external:`${(usage.external /1024/1024).toFixed(2)} MB`// C++对象内存});},30000);// 每30秒打印一次// 渲染进程里也可以用,但要在preload里暴露process对象// 或者在控制台直接输入setInterval(()=>{const usage = performance.memory;// Chrome特有的API 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
// main.js里集成自动更新const{ autoUpdater }=require('electron-updater');const log =require('electron-log');// 配置日志 autoUpdater.logger = log; autoUpdater.logger.transports.file.level ='info';// 检查更新functioncheckForUpdates(){// 开发环境不检查更新,不然调试的时候烦死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('更新下载完成');// 可以提示用户重启应用,或者静默安装// autoUpdater.quitAndInstall(); // 强制重启安装});// 应用启动后检查更新 app.whenReady().then(()=>{createWindow();// 延迟5秒检查更新,避免启动时卡死setTimeout(checkForUpdates,5000);});安全性:别让应用变成筛子
Electron应用因为能直接访问系统API,如果被攻击者利用,危害比普通网页大得多。几个必须做的安全措施:
- 开启上下文隔离(contextIsolation):前面代码里一直强调这个,一定要设为
true,不然渲染进程能直接访问Node.js API,XSS攻击直接变RCE(远程代码执行)。 - 禁用Node集成(nodeIntegration):设为
false,通过preload脚本按需暴露API。 - 内容安全策略(CSP):在HTML里设置CSP头,限制能加载的资源。
- 校验所有输入:特别是文件路径、URL这些,防止目录遍历和协议劫持。
<!-- 在index.html的head里加CSP --><metahttp-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随便画画,主打一个性冷淡风。核心功能是调用本地文件系统,数据存在用户电脑上,不用担心隐私泄露。
<!-- src/index.html --><!DOCTYPEhtml><htmllang="zh-CN"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><!-- CSP安全头 --><metahttp-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><!-- 用Tailwind CDN快速开发,生产环境建议换成构建后的CSS --><scriptsrc="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><bodyclass="bg-gray-50 h-screen flex flex-col overflow-hidden"><!-- 标题栏区域,macOS上隐藏原生标题栏后自己画一个 --><divclass="h-10 bg-white border-b border-gray-200 flex items-center px-4 select-none drag-region"><divclass="flex items-center space-x-2"><divclass="w-3 h-3 rounded-full bg-red-400"></div><divclass="w-3 h-3 rounded-full bg-yellow-400"></div><divclass="w-3 h-3 rounded-full bg-green-400"></div></div><divclass="flex-1 text-center text-sm text-gray-600 font-medium"id="title"> 摸鱼记事本 - 未命名 </div><divclass="text-xs text-gray-400"id="save-status"> 已就绪 </div></div><!-- 工具栏 --><divclass="bg-white border-b border-gray-200 px-4 py-2 flex items-center space-x-2"><buttononclick="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><buttononclick="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><buttononclick="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><divclass="flex-1"></div><spanclass="text-xs text-gray-400"id="word-count">0 字</span></div><!-- 编辑器区域,支持拖拽打开文件 --><divclass="flex-1 relative"id="drop-zone"><textareaid="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><!-- 拖拽提示遮罩 --><divid="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"><divclass="text-center"><divclass="text-4xl mb-2">📄</div><divclass="text-lg text-blue-600 font-medium">松开以打开文件</div></div></div></div><!-- 底部状态栏 --><divclass="bg-gray-100 border-t border-gray-200 px-4 py-1.5 flex items-center justify-between text-xs text-gray-500"><divclass="flex items-center space-x-4"><spanid="file-path">未选择文件</span><spanid="encoding">UTF-8</span></div><divclass="flex items-center space-x-4"><spanid="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){// 这里简化处理,实际应该用IPC询问主进程文件是否还存在 console.log('上次打开的文件:', lastFile);}});// 监听输入,自动统计字数和标记修改状态 editor.addEventListener('input',()=>{updateWordCount();markAsModified();// 防抖自动保存(如果已经保存过文件)if(currentFilePath && saveTimeout){clearTimeout(saveTimeout); saveTimeout =setTimeout(()=>{autoSave();},2000);// 停止输入2秒后自动保存}});// 监听光标位置变化 editor.addEventListener('keyup', updateCursorPos); editor.addEventListener('click', updateCursorPos);functionupdateWordCount(){const text = editor.value;// 中文字符和英文单词分别统计const cnCount =(text.match(/[\u4e00-\u9fa5]/g)||[]).length;const enCount =(text.match(/[a-zA-Z]+/g)||[]).length;const total = cnCount + enCount +(text.length - cnCount -(text.match(/[a-zA-Z]/g)||[]).length); wordCount.textContent =`${total} 字`;}functionupdateCursorPos(){const pos = editor.selectionStart;const textUpToCursor = editor.value.substring(0, pos);const lines = textUpToCursor.split('\n');const currentLine = lines.length;const currentCol = lines[lines.length -1].length +1; cursorPos.textContent =`行 ${currentLine}, 列 ${currentCol}`;}functionmarkAsModified(){if(!isModified){ isModified =true;updateTitle(); saveStatus.textContent ='未保存'; saveStatus.className ='text-xs text-orange-500';}}functionupdateTitle(){const fileName = currentFilePath ? currentFilePath.split(/[\\/]/).pop():'未命名'; title.textContent =`摸鱼记事本 - ${fileName}${isModified ?' *':''}`;}// 新建文件functionnewFile(){if(isModified){// 实际项目这里应该弹窗询问是否保存if(!confirm('当前文件未保存,确定要新建吗?')){return;}} editor.value =''; currentFilePath =null; isModified =false;updateTitle();updateWordCount(); filePathDisplay.textContent ='未选择文件'; saveStatus.textContent ='已就绪'; saveStatus.className ='text-xs text-gray-400'; localStorage.removeItem('lastOpenedFile');}// 打开文件asyncfunctionopenFile(){try{const result =await window.electronAPI.file.openDialog();if(result.canceled)return;if(result.success){ editor.value = result.content; currentFilePath = result.path; isModified =false;updateTitle();updateWordCount(); filePathDisplay.textContent = currentFilePath; saveStatus.textContent ='已保存'; saveStatus.className ='text-xs text-green-600'; localStorage.setItem('lastOpenedFile', currentFilePath);}else{alert('打开文件失败:'+ result.error);}}catch(error){ console.error('打开文件出错:', error);alert('打开文件时发生错误');}}// 保存文件asyncfunctionsaveFile(){try{const content = editor.value;const result =await window.electronAPI.file.saveDialog(content, currentFilePath);if(result.canceled)return;if(result.success){ currentFilePath = result.path; isModified =false;updateTitle(); filePathDisplay.textContent = currentFilePath; saveStatus.textContent ='已保存'; saveStatus.className ='text-xs text-green-600'; localStorage.setItem('lastOpenedFile', currentFilePath);// 显示个短暂的保存成功提示const originalText = saveStatus.textContent; saveStatus.textContent ='保存成功!';setTimeout(()=>{ saveStatus.textContent = originalText;},2000);}else{alert('保存失败:'+ result.error);}}catch(error){ console.error('保存文件出错:', error);alert('保存文件时发生错误');}}// 自动保存(静默保存,不弹提示)asyncfunctionautoSave(){if(!currentFilePath ||!isModified)return;try{// 这里应该调用一个后台保存的IPC,不弹对话框// 简化起见,复用saveDialog,但传入当前路径const result =await window.electronAPI.file.saveDialog(editor.value, currentFilePath);if(result.success){ isModified =false;updateTitle(); saveStatus.textContent ='自动保存于 '+newDate().toLocaleTimeString(); saveStatus.className ='text-xs text-blue-500';}}catch(error){ console.error('自动保存失败:', error);}}// 键盘快捷键 document.addEventListener('keydown',(e)=>{const isMac = navigator.platform.toUpperCase().indexOf('MAC')>=0;const ctrlKey = isMac ? e.metaKey : e.ctrlKey;if(ctrlKey){switch(e.key.toLowerCase()){case'n': e.preventDefault();newFile();break;case'o': e.preventDefault();openFile();break;case's': e.preventDefault();saveFile();break;case'h': e.preventDefault();// 老板键:通知主进程隐藏窗口 window.electronAPI.window.hide();break;}}});// 拖拽文件打开功能 dropZone.addEventListener('dragover',(e)=>{ e.preventDefault(); dropZone.classList.add('drag-over'); dragOverlay.classList.remove('hidden');}); dropZone.addEventListener('dragleave',(e)=>{ e.preventDefault(); dropZone.classList.remove('drag-over'); dragOverlay.classList.add('hidden');}); dropZone.addEventListener('drop',async(e)=>{ e.preventDefault(); dropZone.classList.remove('drag-over'); dragOverlay.classList.add('hidden');const files = e.dataTransfer.files;if(files.length >0){const file = files[0];// 检查文件类型,只接受文本文件if(!file.type.match('text.*')&&!file.name.match(/\.(txt|md|json|js|html|css)$/i)){alert('暂不支持该文件类型,请拖拽文本文件');return;}// 读取拖拽的文件,这里简化处理,实际应该用IPC传给主进程读取const reader =newFileReader(); reader.onload=(event)=>{ editor.value = event.target.result; currentFilePath = file.path;// Electron里file对象有path属性 isModified =false;updateTitle();updateWordCount(); filePathDisplay.textContent = currentFilePath;}; reader.readAsText(file);}});// 监听主进程发来的事件(比如全局快捷键触发) window.electronAPI.onCommand?.((event, command)=>{switch(command){case'new-file':newFile();break;case'open-file':openFile();break;case'save-file':saveFile();break;case'hide-window':// 窗口隐藏前自动保存if(isModified && currentFilePath){autoSave();}break;}});</script></body></html>// 更新后的main.js,加上全局快捷键支持const{ app, BrowserWindow, globalShortcut, ipcMain }=require('electron');// ... 其他引入和之前的代码类似functioncreateWindow(){// ... 窗口创建代码// 注册全局快捷键(老板键)constregisterShortcuts=()=>{// Ctrl/Cmd + Shift + H 全局隐藏/显示 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这个库,它能把日志写到本地文件里,崩溃了去日志文件里找线索。
// 在main.js最前面就引入日志const log =require('electron-log');// 配置日志路径,存在用户数据目录里 log.transports.file.resolvePath=()=>{return path.join(app.getPath('userData'),'logs/main.log');};// 把console也重定向到日志文件,这样console.log也能被记录 Object.assign(console, log.functions);// 捕获未处理的异常 process.on('uncaughtException',(error)=>{ log.error('未捕获的异常:', error);// 可以在这里弹个错误对话框,或者上报到服务器 dialog.showErrorBox('应用出错', error.message);});// 捕获Promise未处理的rejection 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();// 或者只打开特定的面板,比如Memory 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工具
# 安装electron-rebuildnpminstall --save-dev electron-rebuild # 重新编译所有原生模块 npx electron-rebuild # 或者指定Electron版本 npx electron-rebuild -v 28.0.0 几个让代码看起来像"老手"写的骚操作
别把逻辑都堆在渲染进程
渲染进程里跑太多计算密集型任务,界面会卡成PPT。比如你要处理个大JSON文件、生成个PDF、压缩图片,这些都应该扔给主进程或者Worker线程。
// 主进程里处理耗时任务,通过IPC返回结果const{ Worker }=require('worker_threads'); ipcMain.handle('heavy-task',async(event, data)=>{returnnewPromise((resolve, reject)=>{const worker =newWorker('./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。可以做个窗口管理器,统一管理窗口状态、通信、生命周期。
// window-manager.jsclassWindowManager{constructor(){this.windows =newMap();}create(name, options){if(this.windows.has(name)){const win =this.windows.get(name); win.focus();return win;}const win =newBrowserWindow(options);this.windows.set(name, win); win.on('closed',()=>{this.windows.delete(name);});return win;}get(name){returnthis.windows.get(name);}broadcast(channel,...args){this.windows.forEach(win=>{if(!win.isDestroyed()){ win.webContents.send(channel,...args);}});}} module.exports =newWindowManager();静态资源压缩
打包的时候把图片压缩一下,CSS和JS用webpack或者vite压缩,别让安装包胖得像个球。Electron-builder支持asar打包,能把资源文件打包成归档格式,减小体积还能防篡改。
// forge.config.js里配置asarpackagerConfig:{asar:true,// 排除某些大文件不打包进asar,按需加载asarUnpack:['node_modules/sharp/**','assets/large-files/**']}最后唠两句:这玩意儿也不是万能药
说了这么多Electron的好,也得泼点冷水。这玩意儿确实不是银弹,有些场景下用它就是给自己找不痛快。
如果你只是写个简单的内部工具,比如给运营同学用的数据录入工具、给测试同学用的接口调试工具,Electron确实爽翻天。开发快、跨平台、界面好看,还能直接调用本地资源,比用Python写个Tkinter界面强多了。
但如果你要做的是性能敏感型应用,比如视频剪辑软件、大型游戏、专业音频处理,趁早换赛道。Electron的性能瓶颈在Chromium和Node.js的架构上,不是优化能解决的。给十年前的老电脑用?别想了,Electron应用最低配置要求就不低。
还有,如果你的应用需要深度系统集成,比如杀毒软件、系统优化工具、驱动程序,Electron也搞不定。它毕竟是个套壳浏览器,系统底层的东西碰不了。
技术选型这事儿,最忌讳的就是"为了用而用"。我见过太多人,明明就是个简单的表单页面,非要用Electron打包成客户端,结果用户下载个200MB的安装包,打开一看就是个网页,骂声一片。也见过人明明要做个高性能的图像处理工具,非要用Electron写,结果处理个大图卡成狗。
所以啊,7天学会Electron是真的,用它搞个项目放简历上加分也是真的,但别神话它。工具就是工具,合适的场景用合适的工具,这才是老手的做法。
好了,絮叨了这么多,代码也给了不少,剩下的就是你自己动手折腾了。记住,看十遍不如写一遍,遇到报错先查版本,内存爆了记得打快照,打包体积太大就精简依赖。祝你早日搞出自己的桌面应用,工资翻倍那天记得回来请我喝咖啡——虽然咱们这是文字交流,咖啡就心领了吧。
开工!
