Electron 入门:Web 应用打包成桌面软件
2026/5/25 17:18:25 网站建设 项目流程

本文面向:想把 Web 应用打包成桌面软件的前端开发者。
预计阅读时间:12 分钟
最终效果:理解 Electron 的核心概念(主进程、渲染进程、安全模型),掌握 BrowserWindow 配置、服务器嵌入和窗口状态持久化。


Electron 是什么

Electron 把 Chromium(Chrome 的内核)和 Node.js 打包到了一起。你的 Web 前端跑在 Chromium 里,你的后端逻辑跑在 Node.js 里,两者通过 IPC(进程间通信)连接。VS Code、Slack、Discord 都是 Electron 应用。

对 Web 开发者来说,最大的好处是:你已有的 HTML/CSS/JS/React 代码可以直接复用,不需要学 Swift 或 C++。

最小项目结构

一个 Electron 项目至少需要三样东西:

my-app/ package.json # 入口指向 main.js main.js # 主进程:创建窗口、管理生命周期 preload.js # 预加载脚本:安全地暴露 API 给前端 index.html # 你的 Web 页面

package.json里的"main": "main.js"告诉 Electron 从哪个文件启动。运行npx electron .就能打开一个桌面窗口,里面显示你的 HTML。

主进程 vs 渲染进程

这是 Electron 最核心的概念:

  • 主进程(Main Process):运行main.js的 Node.js 环境。它能访问文件系统、操作系统 API、创建窗口。一个应用只有一个主进程。
  • 渲染进程(Renderer Process):每个BrowserWindow里跑的是一个独立的 Chromium 页面,和你在浏览器里打开网页一样。它不能直接访问 Node.js API。

两个进程各司其职。主进程负责"管家"工作(窗口、托盘、菜单、系统交互),渲染进程负责"展示"工作(UI、用户交互)。它们通过contextBridge安全地通信。

BrowserWindow 配置

创建窗口的核心是new BrowserWindow(options)。ChatCrystal 的配置如下:

constwin=newBrowserWindow({width:state.width,// 从保存的状态恢复,或默认 1280height:state.height,// 默认 800x:state.x,y:state.y,minWidth:900,// 最小宽度,防止窗口被拖得太小minHeight:600,show:false,// 先不显示,等页面加载完再显示title:"ChatCrystal",icon:iconPath,webPreferences:{preload:path.join(__dirname,"preload.js"),contextIsolation:true,nodeIntegration:false,sandbox:true,},});

几个关键点:

  • show: false配合ready-to-show事件使用,避免窗口先闪一下白屏再加载内容。
  • minWidth/minHeight保证 UI 不会被压到变形。
  • webPreferences是安全相关的配置,下面专门讲。

安全设置

Electron 的安全模型遵循一个原则:渲染进程不应该有特权。三个配置项实现这一点:

contextIsolation: true:渲染进程的 JavaScript 和 preload 脚本运行在不同的上下文里。即使网页被注入恶意代码,它也无法访问 preload 脚本里的 Node.js 对象。

nodeIntegration: false:渲染进程里不能直接require('fs')之类的 Node.js 模块。这是关闭的,因为网页内容可能来自用户输入(比如 AI 对话内容),如果能执行任意 Node.js 代码就是远程代码执行漏洞。

sandbox: true:进一步限制,让渲染进程连 Chromium 的扩展 API 都用不了,只保留最基本的 Web 能力。

那渲染进程怎么和主进程通信?通过 preload 脚本:

// preload.tsimport{contextBridge}from"electron";contextBridge.exposeInMainWorld("electronAPI",{isElectron:true,versions:{electron:process.versions.electron,node:process.versions.node,chrome:process.versions.chrome,},});

contextBridge.exposeInMainWorld是唯一安全的方式,它把指定的对象挂到window.electronAPI上。前端代码可以读window.electronAPI.isElectron来判断自己是不是跑在 Electron 里,但无法访问任何危险的 Node.js API。

CSP(内容安全策略)是另一层防护。ChatCrystal 在生产环境设置了严格的 CSP 头:

session.defaultSession.webRequest.onHeadersReceived((details,callback)=>{callback({responseHeaders:{...details.responseHeaders,"Content-Security-Policy":["default-src 'self';"+" script-src 'self';"+" style-src 'self' 'unsafe-inline';"+" img-src 'self' data: blob:;"+" font-src 'self' data:;"+" connect-src 'self' http://localhost:* ws://localhost:*;"+" object-src 'none';"+" base-uri 'self'",],},});});

CSP 告诉浏览器:只允许加载同源的脚本,禁止内联脚本(script-src 'self'),禁止插件(object-src 'none')。这对 ChatCrystal 尤其重要,因为它会渲染 AI 对话内容,必须防止 XSS 注入。

注意开发环境跳过了 CSP,因为 Vite 的 HMR(热更新)需要注入内联脚本。

嵌入 Fastify 服务器

很多 Electron 应用只是展示静态页面,但 ChatCrystal 需要一个后端服务器来处理数据库、向量搜索等逻辑。做法是把 Fastify 服务器直接嵌入主进程:

asyncfunctionstartServer(port:number){constserverEntry=pathToFileURL(path.join(app.getAppPath(),"server","dist","server","src","index.js"),).href;constserverModule=awaitFunction("specifier","return import(specifier)",)(serverEntry);returnserverModule.createServer({port,host:"127.0.0.1"});}

这里有个技巧:Function("specifier", "return import(specifier)")是一个绕过 Electron 主进程 CJS 限制的 workaround。Electron 的主进程默认是 CommonJS 模块,不能直接用await import()加载 ESM 模块。通过Function构造器可以绕过这个限制。

启动时先检测端口,如果 3721 被占用就随机分配一个:

functionfindFreePort(preferred:number):Promise<number>{returnnewPromise((resolve,reject)=>{constsrv=net.createServer();srv.listen(preferred,"127.0.0.1",()=>{srv.close(()=>resolve(preferred));});srv.on("error",()=>{constsrv2=net.createServer();srv2.listen(0,"127.0.0.1",()=>{constport=(srv2.address()asnet.AddressInfo).port;srv2.close(()=>resolve(port));});});});}

然后创建窗口,加载服务器的 URL:

mainWindow=createWindow();consturl=devUrl||`http://localhost:${serverPort}`;awaitmainWindow.loadURL(url);

开发模式下devUrl指向 Vite 开发服务器(http://localhost:13721),生产模式下指向内嵌的 Fastify 服务器。

窗口状态持久化

用户把窗口拖到副屏、调了大小,下次打开应该恢复到原来的位置和尺寸。ChatCrystal 用一个 JSON 文件实现:

functionloadWindowState():WindowState{try{constdata=readFileSync(getWindowStatePath(),"utf-8");returnJSON.parse(data);}catch{return{width:1280,height:800,isMaximized:false};}}functionsaveWindowState(win:BrowserWindow):void{constisMaximized=win.isMaximized();constbounds=isMaximized?(lastNormalBounds??win.getBounds()):win.getBounds();writeFileSync(getWindowStatePath(),JSON.stringify(state));}

保存的路径是app.getPath("userData")/window-state.json,这是 Electron 提供的用户数据目录,跨平台且不会和应用代码混在一起。

还有一个细节:如果用户拔掉了外接显示器,上次保存的窗口位置可能在屏幕外面。所以恢复时要检查:

if(state.x!==undefined&&state.y!==undefined){constdisplays=screen.getAllDisplays();constvisible=displays.some((d)=>{constb=d.bounds;return(state.x!>=b.x-50&&state.x!<b.x+b.width&&state.y!>=b.y-50&&state.y!<b.y+b.height);});if(!visible){state.x=undefined;// 重置位置,让系统自动放置state.y=undefined;}}

- 50的容差是为了处理窗口边缘刚好贴着屏幕边界的情况。

单实例锁

桌面应用通常只允许运行一个实例。用户双击图标时,如果已经有实例在跑,应该把已有窗口激活,而不是再开一个。

constgotLock=app.requestSingleInstanceLock();if(!gotLock){app.quit();}

拿到锁的实例继续运行。没拿到锁的直接退出。同时监听second-instance事件,当用户再次尝试启动时,把已有窗口显示出来:

app.on("second-instance",()=>{if(mainWindow){if(mainWindow.isMinimized())mainWindow.restore();mainWindow.show();mainWindow.focus();}});

应用生命周期

Electron 的生命周期事件串起了整个应用的运行逻辑。ChatCrystal 的启动流程在app.whenReady()里:

app.whenReady().then(async()=>{// 1. 确定数据目录constdataDir=getDataDir();mkdirSync(dataDir,{recursive:true});// 3. 设置环境变量process.env.ELECTRON="true";process.env.DATA_DIR=dataDir;if(app.isPackaged){process.env.ELECTRON_PACKAGED="true";}// 4. 设置 CSP(生产环境)// 5. 检测端口serverPort=awaitfindFreePort(3721);// 6. 启动 Fastify 服务器(开发模式跳过,服务器单独运行)if(!process.env.VITE_DEV_URL){constserver=awaitstartServer(serverPort);serverShutdown=server.shutdown;}// 7. 创建窗口、加载页面、创建托盘mainWindow=createWindow();awaitmainWindow.loadURL(url);createTray(mainWindow,serverPort);});

退出时,before-quit事件触发优雅关闭:

app.on("before-quit",(e)=>{if(!isQuitting){e.preventDefault();isQuitting=true;consttimeout=setTimeout(()=>{app.exit(1);// 10 秒超时强制退出},10000);gracefulShutdown().finally(()=>{clearTimeout(timeout);app.quit();});}});

gracefulShutdown依次关闭 Fastify 服务器和系统托盘。10 秒超时是为了防止关闭流程卡死——如果数据库保存之类的事情出了问题,应用不会永远挂在那里。

窗口关闭时不是真正退出,而是隐藏到托盘:

win.on("close",(e)=>{saveWindowState(win);if(!isQuitting){e.preventDefault();win.hide();}});

用户通过托盘菜单的"Quit"才真正退出,这时isQuitting已经被设为trueclose事件不会被拦截。

下一步

你现在了解了 Electron 的核心概念:主进程与渲染进程的分工、BrowserWindow 配置、安全模型、服务器嵌入、窗口状态持久化、单实例锁和生命周期管理。

如果你想深入:

  • IPC 通信ipcMain.handle/ipcRenderer.invoke用于渲染进程调用主进程的功能(比如打开文件对话框)
  • 自动更新electron-updater可以实现应用内更新
  • 打包发布electron-builder可以打包成 Windows 安装包(NSIS)、macOS DMG、Linux AppImage
  • 性能优化:懒加载窗口、减少主进程阻塞操作

ChatCrystal 的完整 Electron 代码在electron/目录下,可以直接作为参考项目。从一个能跑的最小结构开始,逐步加上你需要的功能,这是最快的学习路径。


项目地址:github.com/ZengLiangYi/ChatCrystal

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询