资讯动态

Electron桌面应用开发实战:从环境搭建到打包发布全流程

发布时间:2026/10/22 14:58:39 来源:尧图企业网站定制
做前端这些年我大部分时间都在跟浏览器标签页打交道直到第一次把桌面应用跑起来才发现原来自己一直被限制在地址栏和刷新按钮围起来的框框里。Electron这个词最开始是在某段时间频繁出现在同事的讨论里一个用HTML、CSS和JavaScript就能写桌面程序的框架。当时我心里是有点怀疑的网页技术写出来的东西能跟原生软件比吗后来实际动手做了一个nodeJs electron程式开发demo从环境搭建到打包发布完整跑通一遍才明白这套东西真正的价值在哪里——它让前端开发者不用重新学C或者系统API就能做出跨平台、带原生能力的桌面程序。这篇文章我会把整个开发过程从头到尾捋一遍。适合谁看如果你写过一点JavaScript对Node.js有个基本概念想试试桌面应用开发但又被网上那些碎片化的教程弄得一头雾水那这篇正好对口。不扯虚的直接讲一个能落地的Demo怎么做出来包括环境怎么配、进程模型怎么理解、代码怎么写、坑怎么踩平、最后怎么打包成安装程序。看完你至少能独立搭出一个能跑的Electron桌面应用骨架。1. 为什么用Electron而不是其他方案先说个实际场景。如果你只是要做一个给团队内部用的工具比如批量处理文件、监控某个目录变化、写个数据看板用传统的桌面开发路线光是把界面搭起来就要折腾很久。Electron相当于给你开了条高速通道界面用浏览器那套技术栈底层能力通过Node.js直接调用操作系统的文件系统、剪贴板、窗口管理等接口。1.1 Electron的本质浏览器加Node运行时的组合Electron的核心没必要想得太玄乎。你完全可以把它理解成三样东西拼在一起Chromium负责渲染界面、Node.js提供底层能力、再加上一套系统级别的API用来创建窗口和跟操作系统交互。Chromium大家熟日常用的浏览器内核Node.js也熟跑后台脚本的工具。Electron做的事情特别朴素把这两者装进同一个程序里关进一个独立的窗口。你在网页里写的DOM操作、CSS动画、请求库、状态管理全部照常工作需要读写文件、调用系统命令、访问数据库的时候再用Node.js那一侧的能力。这种组合带来的结果就是巨大的生态复用。前端社区有几十万个现成的包可以直接用在桌面应用里写过的组件、样式、工程化配置大部分都能搬过来。这也是为什么很多个人开发者愿意选Electron——同样的功能用原生技术可能要写三份代码Electron写一份Windows、macOS、Linux通用。1.2 到底能做什么边界在哪里Electron适合的应用类型我实测下来大概是这么几类需要跟本地文件系统打交道的工具比如文件整理助手、文档批量处理需要常驻后台或者系统托盘的效率型软件比如截图工具、剪贴板管理数据展示类应用比如监控面板、日志分析器还有一些需要跨平台一致体验的对外产品。边界也很明显。如果你要做的是对性能要求极苛刻的软件比如专业视频剪辑、3D引擎、大型游戏那Electron不是首选它体积偏大、内存占用偏高是客观事实。不过对于绝大多数桌面工具类应用这个性能代差在日常使用中根本感受不到。我自己的感受是先把功能做出来再说性能问题如果真到了瓶颈再深入去优化不迟。2. 环境准备初始化项目的三个关键选择这部分按下不表先动手。Electron开发环境其实很简单前提是你电脑上已经有Node.js。Node.js版本建议选长期支持版我开发时用的是16以上的版本后来升到18也没有兼容问题。npm是随Node一起装的包管理器不需要额外配置。2.1 项目初始化的两种路径创建一个Electron项目从我接触过的资料和实际经验看做法可以分成两条路。一条是直接npm init创建空项目后手动加Electron依赖另一条是用官方脚手架工具把自带示例代码的项目拉下来。手动初始化适合想搞懂每一行代码意义的人过程也就三步mkdir electron-demo cd electron-demo npm init -y npm install electron --save-dev装完Electron之后在package.json里的scripts字段加上一段脚本{ scripts: { start: electron . } }然后创建项目入口文件跑起来看窗口。这个路径虽然笨但是对理解Electron的启动流程特别有帮助。官方脚手架这边我自己的经验是不太推荐新手直接用因为生成的项目里往往集成了打包配置、资源处理、热更新等一堆东西文件很多刚接触的人容易迷失在哪是哪。先手动搭一遍搞清楚各个文件之间的关系再去用脚手架才是合理的顺序。2.2 npm安装electron速度慢的应对策略实测安装Electron时npm要从服务器下载一个上百兆的二进制压缩包网络不好的时候非常折磨。这里有一个常规做法通过环境变量指定镜像源把Electron安装包的下载地址替换成国内可用的镜像地址。在项目根目录下新建一个.npmrc文件写入以下内容electron_mirrorhttps://npmmirror.com/mirrors/electron/写完后重新执行npm install速度会快很多。这个文件只影响Electron相关依赖的下载不影响其他包安全稳妥。配置完之后记得把node_modules整个删掉重新装一次确保生效。提示无论用哪种方式安装遇到下载失败的情况先把内存清理一下缓存清了再试比反复重试同一个镜像地址靠谱得多。2.3 顶层文件的角色分工手动初始化的项目结构核心就是三个文件加一个package.json。入口文件通常叫main.js主线程就从这个文件开始运行preload.js是预加载脚本在页面加载前先执行作为主进程和页面之间的沟通桥梁renderer是页面代码可以是HTML文件也可以是打包后的前端项目。我把这几个文件之间的关系用一个更生活化的例子解释入口文件像是餐厅的后厨总控决定什么时候开业、几点关门、什么菜上到什么桌渲染进程是顾客看到的餐桌和菜品展示预加载脚本则像是传菜员后厨想告诉顾客什么信息由传菜员带过去顾客有什么需求也是通过传菜员传给后厨。这个比喻后面讲进程模型时还会用。3. 进程模型主进程、渲染进程和预加载脚本Electron之所以经常被人问为什么这么设计是因为它跟传统桌面开发最大的不同就在这个进程模型上。很多报错、白屏、崩溃问题根源都是没有理解谁在干活、谁能拿到什么权限。3.1 主进程和渲染进程各管什么主进程是应用启动时先运行的程序它负责生命周期管理、创建窗口、跟操作系统底层交互比如弹系统对话框、读写文件、访问网络等。主进程有完整的Node.js能力可以随意require各种模块。渲染进程就是窗口内部加载网页页面的进程每个窗口对应一个。渲染进程的核心工作是展示界面和响应交互按理说它应该只关心UI但Electron给了它两个选择可以像普通浏览器一样只能跑网页代码也可以开启Node集成后直接require模块。后一种方便但危险我后文会讲为什么不建议这么干。两者之间的消息传递叫IPC即进程间通信。主进程不能直接操作页面的DOM渲染进程也不能直接调用系统的dialog所有跨界操作都要通过统一的通信机制来协商。理解这一点之后很多莫名其妙的错误就都说得通了——比如你在渲染进程里写了require(fs)报错说fs找不到就是因为渲染进程默认压根没有Node环境权限。3.2 安全模式下用预加载脚本桥接权限新版Electron强烈推荐在创建窗口时开启安全配置把渲染进程的Node集成关掉同时开启上下文隔离然后把需要的能力通过预加载脚本暴露给页面。创建窗口的核心代码长这样const { app, BrowserWindow, ipcMain } require(electron); const path require(path); function createWindow() { const win new BrowserWindow({ width: 960, height: 640, webPreferences: { preload: path.join(__dirname, preload.js), nodeIntegration: false, contextIsolation: true } }); win.loadFile(renderer/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(); });关闭nodeIntegration是为了防止页面里的任意JavaScript直接获得操作系统级别的权限。页面如果被注入了恶意代码或者某个依赖包有漏洞攻击者就能通过它读写你的文件。用contextIsolation配合预加载脚本等于在两者之间砌了一堵墙只把用得到的几个接口小心翼翼地递过去。预加载脚本的写法const { contextBridge, ipcRenderer } require(electron); contextBridge.exposeInMainWorld(desktopAPI, { selectDirectory: () ipcRenderer.invoke(dialog:selectDirectory), scanDirectory: (dirPath) ipcRenderer.invoke(file:scan, dirPath), deleteFiles: (filePaths) ipcRenderer.invoke(file:delete, filePaths), onScanProgress: (callback) ipcRenderer.on(scan:progress, (_, data) callback(data)) });在页面代码里只需要调用window.desktopAPI这个白名单接口完全感觉不到下面在做进程通信。3.3 一次完整的IPC通信流程以弹窗选择目录为例完整流程是这样的页面调用window.desktopAPI.selectDirectory()这个调用经过预加载脚本的桥接变成一条ipcRenderer.invoke消息传到主进程主进程收到后弹出一个原生系统目录选择框等用户选完路径再把结果原路返回。主进程侧对应的监听代码ipcMain.handle(dialog:selectDirectory, async () { const result await dialog.showOpenDialog({ properties: [openDirectory] }); return result.canceled ? null : result.filePaths[0]; });这套流程我强烈建议刚开始学的人亲手敲一遍哪怕只为了把感觉找对。因为很多后续的高级用法都是在这个基础模型上叠加的比如监听某个事件实时推送进度、在主进程里主动发消息给窗口等。4. Demo实战做一个批量文件清理助手理论讲得再多不如一个能动的Demo。这次我做的Demo是一个批量文件清理助手主要功能是扫描用户选择的目录找出重复文件列出重复分组用户勾选后把文件移到回收站。这个工具不复杂但是把Electron的对话框、IPC通信、Node文件系统能力、界面交互、进度条更新全串起来了非常适合作为练手项目。4.1 功能拆解和实现思路先明确逻辑重复文件的判定分两步走。第一步按文件大小分组只有大小相同的文件才有可能是重复的这样能一下子过滤掉绝大多数无关文件第二步对大小相同的文件计算哈希值哈希一样的判定为重复。技术选型上文件扫描、分组、哈希全都放在主进程做因为主进程有完整的Node能力直接使用内置的fs和crypto模块不依赖额外包。渲染进程只负责展示结果和接收用户勾选。扫描时文件量很大不能等全部扫描完再一股脑发结果否则界面会陷入长时间无响应。我采用分批发送进度的方案主进程每扫描完一定数量的文件就通过webContents.send往渲染进程推一条进度消息页面上的进度条实时刷新体感会舒服太多。4.2 主进程扫描与哈希计算扫描核心代码递归遍历目录const fs require(fs); const path require(path); const crypto require(crypto); function scanFiles(dirPath, callback) { const results []; let scanned 0; function walk(currentPath) { const entries fs.readdirSync(currentPath, { withFileTypes: true }); for (const entry of entries) { const fullPath path.join(currentPath, entry.name); if (entry.isDirectory()) { walk(fullPath); } else { const stat fs.statSync(fullPath); results.push({ path: fullPath, size: stat.size }); scanned; callback(scanned, results.length); } } } walk(dirPath); return results; } function getFileHash(filePath) { const content fs.readFileSync(filePath); return crypto.createHash(md5).update(content).digest(hex); }注意这里用了readdirSync和statSync是同步方法。在小目录下没感觉但目录层级深、文件数量大的时候同步方法会卡住主进程的事件循环。Demo项目这样做没问题做正式产品时建议改成异步版本或者分批处理。我在实际项目里就遇到过扫描一个几十万文件的目录时主进程直接假死的现象后来改成异步遍历才解决。4.3 分组去重逻辑拿到文件清单后先按size维度分组function findDuplicates(fileList) { const bySize new Map(); for (const file of fileList) { if (!bySize.has(file.size)) bySize.set(file.size, []); bySize.get(file.size).push(file); } const duplicates []; for (const group of bySize.values()) { if (group.length 2) continue; const byHash new Map(); for (const file of group) { const hash getFileHash(file.path); if (!byHash.has(hash)) byHash.set(hash, []); byHash.get(hash).push(file); } for (const hashGroup of byHash.values()) { if (hashGroup.length 1) { duplicates.push(hashGroup); } } } return duplicates; }这里的Map键用的是文件大小文件大小通常能用整数表示但注意JavaScript的Map对于键的比较是严格相等所以不用担心数值类型混乱。哈希计算那一步是最耗时的因为要读完整个文件才算出摘要大文件多的时候建议加上文件大小阈值过滤比如小于1MB的文件直接跳过哈希省时间也够用。4.4 渲染进程界面与进度更新渲染进程的界面用原生HTML加少量CSS就能搞定。布局就是一个顶部操作栏加一个结果列表操作栏放“选择目录”和“开始扫描”按钮结果列表按重复分组展示每组里文件名前面放复选框默认勾选除了第一个文件以外的所有重复项逻辑上很合理。进度条部分是通过事件绑定实现的在预加载脚本里已经暴露了onScanProgress接口页面初始化时注册好回调window.desktopAPI.onScanProgress((data) { const percent Math.round((data.scanned / data.total) * 100); progressBar.style.width percent %; progressText.textContent 已扫描 ${data.scanned} 个文件; });扫描完成后主进程通过invoke返回完整结果页面拿到结果渲染分组卡片。勾选完点击“删除选中文件”按钮页面收集选中文件的路径数组通过IPC传给主进程。删除操作这里我特意不用fs.unlink直接删而是用系统自带的回收站功能。Electron提供了shell.trashItem方法可以把文件移动到系统回收站这样万一误删了还能恢复安全事故少一桩const { shell } require(electron); async function moveToTrash(filePaths) { for (const filePath of filePaths) { await shell.trashItem(filePath); } return { success: true, deletedCount: filePaths.length }; }这个设计思路我认为比其他方案更稳妥尤其是给非技术人员使用的工具直接永久删除文件的风险不可控。只用一个方法调用就把文件送进回收站又安全又省心。5. 调试工具与常见问题速查Electron应用的调试方式跟网页调试很像因为界面本身就是浏览器页面。打开开发工具快捷键是CtrlShiftI在窗口里直接用这一点曾让很多从客户端开发转过来的同事大呼惊奇。但在主进程里的逻辑没办法只用渲染进程的开发工具调试因为主进程跑在Node环境里平时控制台打印日志需要从启动终端里看。5.1 主进程的调试方式最朴素的调试方法就是在主进程代码里加console.log打印日志。Electron启动时如果是从终端执行的npm start所有主进程的console.log都会打印在终端里。但这也带来一个问题终端中文编码没设置好的时候Windows下打印中文日志偶尔会显示乱码实测把终端编码切到UTF-8基本能解决。需要正式断点调试时有两条路可走。一条是用专门的调试工具另一条是连接Chrome开发者工具的调试端口。后者操作方法是在启动命令上加一段远程调试开关Electron就能打开一个特殊端口接受调试请求。用这个方法可以像调试普通Node.js程序一样设置断点、查看变量状态。我自己的习惯是小问题日志优先严重问题才动调试工具效率最高。5.2 高频报错整理我把实际开发过程中撞到的几个高频问题整理成了一张表每一个都真实踩过症状原因分析解决办法窗口打开后白屏入口HTML路径不对检查loadFile的路径是否基于__dirname拼接require报错找不到模块渲染进程默认没有Node集成改用预加载脚本暴露接口或开启nodeIntegration在预加载脚本里用了import预加载脚本运行环境是CommonJS改用require方式引入模块IPC调用无响应事件名不一致或主进程未注册核对ipcMain和ipcRenderer的事件名是否一致打包后页面图片不显示资源路径用了相对路径改为path.join(__dirname)拼接资源路径窗口关闭后进程不退出没监听window-all-closed事件在主进程里补上对应生命周期处理目录选择框弹不出来这个问题其实报错不多更多的是静默失败。我在排查时发现是窗口的父级参数传错了导致对话框在后台打开人眼看不到。解决方式是确定showOpenDialog时把当前活跃窗口作为模态窗口传入改完之后一切正常。5.3 开发时提升效率的三个小技巧第一启动时自动打开开发者工具省得每次手动按快捷键win.webContents.openDevTools({ mode: detach });第二开启自动刷新。Electron对修改主进程文件不会自动重启这里推荐用官方文档里提到的直接做法监听文件变化后调用app.relaunch方法简单粗暴但有效。修改渲染进程的HTML、CSS则不需要重启应用页面里加两行代码配合热更新就可以了。第三在渲染进程的console里打印主进程传来的数据时注意数据结构很大时控制台会折叠右键变量可以展开完整内容并选择Store as Global Variable这样后续可以在控制台直接操作这个变量排查数据筛选逻辑非常方便。6. 打包发布从开发环境到安装程序开发完的Electron应用要给别人用不可能每个人电脑上都装Node环境再跑npm start。打包这一步把整个应用编译成一个可执行文件或安装包双击就能装。这步里坑多但也算一个好玩的环节因为看到自己写的程序突然变成一个独立的安装包成就感是很真切的。6.1 打包工具选型electron-builder还是打包器Electron生态里打包方案常用的有两个一个是electron-builder另一个是electron-packager。electron-packager更轻量能把应用打包成一个免安装的绿色文件夹但不生成安装程序electron-builder更全面支持生成Windows的nsis安装包、macOS的dmg、Linux的AppImage配置起来也更复杂一些。我这次Demo选的是electron-builder原因很直接工具要给别人用最友好的分发方式就是一个安装包。安装过程中用户不需要懂命令行一直下一步就可以。6.2 electron-builder配置要点在package.json里添加build配置段。下面是适合Windows平台的配置模板build: { appId: com.example.filedemo, productName: 文件清理助手, files: [ main.js, preload.js, renderer/**/* ], directories: { output: dist }, win: { target: nsis, icon: build/icon.ico }, nsis: { oneClick: false, allowToChangeInstallationDirectory: true, createDesktopShortcut: true } }然后执行npx electron-builder --win打包过程会下载打包工具及其依赖有时会因为网络原因卡很久。我实测过的处理方式跟前面安装Electron时相同在.npmrc里配置镜像源打包速度提升明显。两个容易忽略的细节第一icon图标必须用.ico格式整个打包过程不支持直接用png转ico需要事先准备好对应的图标文件第二files字段里不写进去的文件不会被打包如果主进程里引用了node_modules下的包但files字段没把它包含进去打包出来的应用启动时会报模块找不到排错时很难想到是这个原因。6.3 打包后应用运行的几个典型故障最典型的问题是双击exe后毫无反应。排查顺序我建议先看任务管理器里进程是否起来了如果起了又秒退大概率是代码里某个模块没有找到用命令行方式启动安装后的exe文件能看到具体的报错信息这比瞎猜强太多。第二个问题是窗口出来了但页面加载失败。多半是资源文件路径写死了相对路径打包后目录结构变化导致找不到。规范做法是用path.join(__dirname)动态拼接资源路径而不是写死相对路径。第三个问题是免安装版正常但安装版出现管理员权限问题。如果你写的应用需要访问某些系统目录需要在打包配置里声明请求管理员权限。我的经验是能不申请就别申请普通目录权限够用省得每次启动都弹UAC提示体验很差。6.4 安装包体积的优化思路Electron应用大是出了名的一个空转的Hello World打包出来轻轻松松超过80MB。我有几个降低体积的实际经验先说去重的思路Electron会把整个Chromium内核打进去所以体积大头没法消灭但可以通过选用Electron的lint版本节约一些空间其次在files字段里只包含必需的代码不要把node_modules里那些测试目录和文档带进去第三如果对容量极度敏感可以考虑用asar打包模式这能让应用目录整体压成一个存档文件还能顺带防手贱改代码。不过要放平心态一个带完整内核的桌面客户端170MB和90MB的差异对现在的主流电脑并不致命。比起体积我更建议把精力放在应用的内存占用优化上。6.5 后续可扩展的方向Demo做完之后你会发现它的骨架天然可以扩展出很多功能。比如给这个文件清理工具加上一个全局快捷键任何窗口下按一下就能触发扫描或者增加命令行启动参数让用户通过终端带参数运行再或者加上自动更新机制新版发布后应用自己检查更新并下载安装。Electron在这三个方向上都有对应的API不用重写核心代码只是往这个骨架上加东西。我个人觉得Electron最难的地方不是某个具体API怎么用而是建立整体心智模型——知道什么事情该放到主进程、什么事情该放到渲染进程、什么事情要通过IPC中转。这套模型一旦成了直觉以后写什么功能都会很顺。最后分享一个我个人的习惯。每次开新项目不管多简单我都会先把main.js、preload.js、renderer这三个框架文件搭好安全配置拉满再在这个固定结构上码功能。看起来多花了几分钟但省掉的是后面大量排查安全问题和结构混乱的时间。如果你正准备开始第一个Electron项目建议你也试试这个固定起手式很值得。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑