
我印象很深半年前手上有个内部工具一直跑在浏览器里每次开会演示都得先开后台、再找 URL、还得祈祷缓存别捣乱。被恶心了几次之后我下了决心把它搬成一个桌面端应用。正好那段时间一直在折腾聊天类 UI干脆就拿“仿微信桌面端聊天系统”当练手项目技术栈直接选了 Vite Electron。这套组合做完之后我的感受是Electron 仍然是做跨平台桌面端最稳妥的选择而 Vite 能把它前端的开发体验拉到接近 Web 项目的水平。这篇就把整个项目从选型到落地的完整过程写出来包括工程化搭建、窗口设计、聊天数据流、打包与性能优化以及我实际踩过的坑。想入坑 Electron 的前端同学或者正在纠结桌面端技术选型的朋友可以参考。1. 技术选型复盘为什么最终是 Vite Electron 这个组合很多朋友一上来就问我做个桌面聊天软件为什么不用 Tauri为什么不用 C# 或者 Qt这里我把自己的对比思路完整摆出来顺便解释一下 Electron 在这个场景下到底不可替代在哪。1.1 四种桌面端方案的横向对比先给一张我当时的选型对比表参数是我的真实体验不代表绝对权威但很有参考价值。方案技术栈要求安装包体积内存占用跨平台一致性生态成熟度Electron前端技术栈即可较大约 60MB中高高极高Tauri需要会 Rust前端部分不变很小约 5MB低高但底层坑偏多中C# WinForms/WPF需要 .NET 技术栈中等低仅限 Windows中高Qt需要 C 或 Python中等中等高高对于我这个纯前端背景、而且希望“一个代码库同时覆盖 Windows 和 macOS”的人来说Electron 几乎是唯一答案。C# 直接排除因为跨平台就过不了Qt 虽然有 PySide 这种 Python 绑定但复杂的界面布局写起来完全没有前端顺手尤其做仿微信这种像素级 UI 会非常痛苦。Tauri 我确实认真考虑过也跑过 demo最后还是退了原因是团队的 Rust 水平不足以支撑日常开发排错再加上它依赖系统 WebView不同系统上的兼容性问题比 Electron 多一个数量级。做产品要的是稳定交付不是给自己增加排错负担。1.2 Vite 在 Electron 开发里解决了什么真问题Electron 的老玩家应该都经历过 Webpack 时代改一行样式等编译、等热更新、再等窗口刷新一顿操作下来半分钟没了。Vite 带来的第一个革命性体验就是开发服务器启动快到几乎是瞬间的因为它用原生 ES Module 按需编译真正做到了“改哪编译哪”。对于聊天系统这种迭代频繁的 UI 项目来说这个开发体验差距是决定性的。第二个价值在生产构建上。Vite 底层用 Rollup代码分割和 Tree-Shaking 能力很强。Electron 渲染进程里的代码如果是一个巨大的单文件加载慢不说内存也得不到释放。Vite 默认会按动态 import 自动分包比如聊天界面、通讯录界面、设置界面这些大模块可以被拆开按需加载白屏和卡顿都会明显改善。第三个价值是插件生态。我现在已经离不开vitejs/plugin-vue或vitejs/plugin-react这套官方插件了它们解决了 JSX/TSX 编译、热更新边界、CSS 预处理等一堆细节。如果不用 Vite这些都要手动配。1.3 Electron 本体在这个项目里的不可替代项有些同学会问既然前端部分用 Vite那纯前端做一个网页版聊天系统不行吗我当时也想过但后来列出了浏览器永远做不到的几件事系统托盘常驻聊天工具的核心使用习惯就是“挂在托盘里”浏览器标签页被误关太常见了原生窗口控制自定义边框、置顶、最小化到托盘这些只有桌面端能做本地文件交互头像图片缓存、聊天记录备份、导出文件浏览器受限很多系统通知Windows 上原生通知和网页 Notification 的体验差距还是挺明显的独立进程隔离聊天工具卡死不能影响用户其他工作浏览器一个标签卡死可能整站遭殃所以 Electron 不是“没有选择的选择”而是这个产品形态真正的合理容器。Vite 负责把容器里的前端体验做到极致两者各司其职。2. 工程化搭建把 Vite 和 Electron 拧成一股绳的完整方案这个阶段是我感觉最容易劝退新手的环节。单纯跑一个 Vite 项目很容易单独跑一个 Electron 官方示例也容易但让两者在开发环境下无缝协作需要理解它们各自的运行机制。2.1 先看最终的工程目录结构我先直接放出我用下来的目录结构这是一套经历过实战考验的组织方式。electron-wechat/ ├── electron/ │ ├── main.ts # 主进程入口 │ ├── preload.ts # 预加载脚本 │ ├── tray.ts # 托盘逻辑 │ └── window.ts # 窗口管理 ├── src/ │ ├── main.ts # 渲染进程入口 │ ├── App.vue │ ├── components/ # 聊天界面组件 │ ├── stores/ # zustand/pinia 状态 │ ├── assets/ # 静态资源 │ └── styles/ ├── index.html ├── vite.config.ts ├── electron-builder.yml ├── package.json └── tsconfig.json关键点在于electron/目录放主进程和预加载脚本src/目录放渲染进程代码。两者有着完全不同的运行环境但共用同一个package.json。主进程跑在 Node.js 环境渲染进程跑在 Chromium 环境通过contextBridge搭建安全的通信桥梁。2.2 开发模式的核心Vite Dev Server 与 Electron 的启动时序开发模式的理想状态是启动一条命令Vite 服务器先起来Electron 再带着界面连上去后续的代码变更全部走 HMR热更新。这里有个经典的时序问题electron .启动太快Vite 服务器还没 ready窗口打开之后就是一片白屏。我的解决方案是依赖两个工具concurrently和wait-on。先说运行脚本{ scripts: { dev: concurrently -k \vite\ \wait-on tcp:5173 cross-env NODE_ENVdevelopment electron .\, build: vite build tsc -p electron/tsconfig.json, start: electron . } }启动流程是这样的concurrently并行跑 Vite 开发服务器和 Electron 启动命令。Electron 那条命令前面挂着wait-on tcp:5173意思是只有探测到 5173 端口可以访问了才真正执行electron .。这样 Vite 服务器一定是先就绪的Electron 窗口一打开就能加载到页面。主进程这边的加载逻辑也要区分开发和生产import { app, BrowserWindow } from electron; import path from path; const isDev process.env.NODE_ENV development; function createMainWindow() { const win new BrowserWindow({ width: 1200, height: 800, frame: false, webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true, nodeIntegration: false } }); if (isDev) { win.loadURL(http://localhost:5173); } else { win.loadFile(path.join(__dirname, ../dist/index.html)); } } app.whenReady().then(createMainWindow);这里有个容易栽的坑生产环境的loadFile路径不是随便写的。如果vite build的输出目录是dist/而主进程编译后的文件在electron/dist/下就需要用path.join(__dirname, ../dist/index.html)精确指路。很多人的白屏问题就是路径算错了。2.3 安全模型contextIsolation 与 preload 的设计Electron 从 12 版本起默认开启contextIsolation这意味着渲染进程里不能直接用 Node.js 的require或process对象。这个设计看着限制很大实际上是安全底线聊天工具要加载用户消息、链接、头像这些内容如果拥有 Node 权限等于把电脑敞开给任何恶意脚本。正确姿势是使用 preload 脚本做桥接配合contextBridge暴露白名单 APIimport { contextBridge, ipcRenderer } from electron; contextBridge.exposeInMainWorld(chatAPI, { sendMessage: (content: string) ipcRenderer.invoke(chat:send, content), onMessageReceived: (callback: (msg: Message) void) { const handler (_event: unknown, msg: Message) callback(msg); ipcRenderer.on(chat:receive, handler); return () ipcRenderer.removeListener(chat:receive, handler); }, minimizeWindow: () ipcRenderer.send(window:minimize), maximizeWindow: () ipcRenderer.send(window:maximize), closeWindow: () ipcRenderer.send(window:close) });渲染进程里通过window.chatAPI.sendMessage(你好)这种方式调用永远接触不到底层 Node 能力。这也是我做这个项目时特别想强调的部分不要在渲染进程里开 nodeIntegration这个习惯一旦养成就很难改而且会埋雷。2.4 生产构建的两段式流程生产构建需要认识到这个项目里有两种代码要分别打包。渲染进程代码Vue/React 组件、业务逻辑、样式通过vite build打包成纯静态资源主进程和 preload 的 TypeScript 代码通过tsc编译成 CommonJS我最初把两边混合在一起处理结果主进程代码被 Rollup 处理后丢失了 Node 依赖的上下文各种报错。后来拆开就非常清爽主进程代码保持 CommonJS 格式渲染进程代码走 Vite 的 ESM 分割。生产环境的启动路径已经在 2.2 写过了不再赘述。3. 仿微信桌面端的窗口与界面架构做仿微信这个项目UI 层是最感性、最出效果的部分但也最容易只做表面功夫。我先把窗口层谈透再谈界面细节。3.1 无边框窗口与自定义标题栏微信电脑版没有系统标题栏是纯自定义的。Electron 里对应设置就是frame: false把系统原生边框去掉然后用 HTML/CSS 自己画标题栏。我的标题栏结构长这样div classtitlebar div classtitlebar__left img src./logo.svg classlogo altlogo / span classapp-titleWeChat Desktop/span /div div classtitlebar__right button clickminimize--/button button clicktoggleMaximize[]/button button clickclosex/button /div /div关键 CSS 是-webkit-app-region这套属性决定了哪些区域可以被鼠标拖拽移动窗口.titlebar { height: 48px; display: flex; justify-content: space-between; align-items: center; background: #f5f5f5; -webkit-app-region: drag; /* 整个标题栏都是拖拽区 */ } .titlebar button { -webkit-app-region: no-drag; /* 按钮区域必须排除拖拽 */ }我的实测经验是务必把可交互元素都标上no-drag。否则你点击按钮时会发现鼠标事件像被一层透明罩盖住按钮按下去没反应这个坑太常见了。3.2 左侧导航栏的像素级还原思路微信左侧栏的核心特征固定宽度约 60-70px深色背景一列图标的导航列表选中态是高亮色块旁边的红点角标表示未读消息。这个 UI 本身不难难在细节一致性。我总结了三件套统一用 SVG 图标线条粗细保持 1.5px 一致选中态的过渡动画控制在 150ms快了生硬、慢了拖沓红点用绝对定位的伪元素实现不额外增加 DOM 节点红点代码可以放出来参考div classnav-item svg!-- 图标 --/svg span classbadge v-ifunreadCount 0{{ unreadCount 99 ? 99 : unreadCount }}/span /div.nav-item { position: relative; } .badge { position: absolute; top: 2px; right: 2px; min-width: 16px; height: 16px; background: #fa5151; border-radius: 8px; color: #fff; font-size: 11px; line-height: 16px; text-align: center; padding: 0 4px; }3.3 置顶、缩放与多窗口的 IPC 控制自定义标题栏意味着原生窗口控制钮没了这些操作必须转发到主进程。我用ipcRenderer.send发事件主进程用BrowserWindow实例响应。ipcMain.on(window:minimize, (event) { BrowserWindow.fromWebContents(event.sender)?.minimize(); }); ipcMain.on(window:maximize, (event) { const win BrowserWindow.fromWebContents(event.sender); if (!win) return; win.isMaximized() ? win.unmaximize() : win.maximize(); }); ipcMain.on(window:close, (event) { BrowserWindow.fromWebContents(event.sender)?.hide(); });注意这里最后一项close按钮不是真正退出应用而是隐藏窗口到托盘。这是模仿微信的基础行为——用户点关闭应用继续在托盘里待命消息来了照样弹通知。这是聊天工具最核心的交互习惯之一。3.4 系统托盘与常驻逻辑托盘在 Electron 里实现非常成熟核心就是用Tray和Menu两个类。我的实现是import { Tray, Menu, app } from electron; let tray: Tray | null null; export function createTray() { tray new Tray(path.join(__dirname, assets/tray-icon.png)); const contextMenu Menu.buildFromTemplate([ { label: 显示主界面, click: () mainWindow.show() }, { type: separator }, { label: 退出, click: () app.quit() } ]); tray.setToolTip(WeChat Desktop); tray.setContextMenu(contextMenu); tray.on(click, () { mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show(); }); }需要在app.whenReady()之后调用createTray()。另外 **macOS 的托盘图标需要处理空白边距**一个常见的坑是图标过小或像素不清晰用2x分辨率的图片更稳妥。4. 聊天核心数据流消息、会话与多窗口弹窗界面做得再像没有一套硬核的数据流设计项目也只是皮囊。这一章是全文的灵魂我会把消息模型、状态管理、窗口间通信、虚拟滚动全部交代清楚。4.1 先把消息模型定义清楚聊天系统最重要的就是消息数据结构。我的 TypeScript 定义如下interface User { id: string; nickname: string; avatar: string; signature?: string; } interface Message { id: string; conversationId: string; senderId: string; content: string; type: text | image | file | system; timestamp: number; status: sending | sent | delivered | read; } interface Conversation { id: string; peer: User; messages: Message[]; unreadCount: number; lastMessage?: Message; pinned?: boolean; draft?: string; }单聊、群聊都统一用Conversation承载消息通过conversationId归属。字段设计上我特别保留了status因为聊天软件里消息状态提示是刚需虽然我这个 demo 里只是 mock但字段先设计到位后面接真实后端时不用动结构。4.2 状态管理与持久化的轻量方案聊天软件的状态量非常大会话列表、当前会话、未读数、窗口状态、搜索关键词……如果用 React 的useState手动管理很快就会一团乱麻。我用的是 Zustand比 Redux 轻太多写起来既简单又能满足复杂状态需求。import { create } from zustand; interface ChatState { conversations: Conversation[]; activeConversationId: string | null; sendMessage: (conversationId: string, content: string) void; receiveMessage: (message: Message) void; markAsRead: (conversationId: string) void; } export const useChatStore createChatState((set, get) ({ conversations: seedConversations(), activeConversationId: null, sendMessage: (conversationId, content) { // 构造消息并追加到对应会话 }, receiveMessage: (message) { // 根据 message.conversationId 找到会话并追加 }, markAsRead: (conversationId) { // 未读清零 } }));Zustand 的优势在于不需要 Provider 包裹、可以在任何组件或普通函数里调用、状态变化只触发真正订阅的组件重渲染。聊天列表每秒都可能更新未读数用 Zustand 的selector做精准订阅性能表现非常好。持久化这块要注意localStorage在 Electron 渲染进程里是可用的但存敏感数据时我强烈建议走主进程写文件。原因有两个一是 localStorage 在清除浏览器数据时会丢二是contextIsolation开启后渲染进程里的localStorage其实受 Chromium 存储分区管理用户体验不可控。我的做法是 IPC 调用主进程把聊天记录 JSON 写入用户数据目录下的chat_history.json。4.3 聊天窗口的模式选择单页切换还是独立窗口这里我要多说一点。很多仿微信的教程都是路由切换点击左侧会话右侧聊天面板换成对应内容。这个方案简单但和微信电脑版的真实体验有差距。微信电脑版里双击某个会话可以弹出一个独立聊天窗。我在项目里做了一个混合方案默认主窗口右侧面板是聊天区域但双击会话列表的外层区域会打开一个独立的BrowserWindow聊天窗。这个独立窗口有独立的会话上下文通过 URL query 传递conversationIdfunction openChatWindow(conversationId: string) { const chatWindow new BrowserWindow({ width: 400, height: 600, frame: false, webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true } }); const url isDev ? http://localhost:5173/#/chat/${conversationId} : ${path.join(__dirname, ../dist/index.html)}#/chat/${conversationId}; chatWindow.loadURL(url); }主窗口和独立聊天窗之间如何同步消息状态这里有个经验总结多窗口场景下消息更新应该走“广播”而不是父子查询。也就是说当某个窗口发出消息后主进程把新消息广播给所有相关窗口。每层窗口自己维护 UI 状态各自更新。function broadcastMessage(message: Message) { const windows BrowserWindow.getAllWindows(); windows.forEach((win) { win.webContents.send(chat:receive, message); }); }这个方案最接近真实 IM 客户端的设计也避免了一个窗口修改另一个窗口状态的耦合问题。4.4 从发送到回执一条消息的完整生命周期完整链路如下用户在聊天输入框按下回车渲染进程调用window.chatAPI.sendMessage(conversationId, content)Preload 通过ipcRenderer.invoke把请求转发给主进程主进程在ipcMain.handle里构造 Message 对象存到本地然后广播给所有窗口主进程模拟对方回复延迟 1-2 秒后再广播一条新的Message渲染进程监听chat:receive更新 Zustand store界面刷新这里ipcRenderer.invoke和ipcRenderer.send的区别要澄清一下invoke是异步请求/响应模式渲染进程能拿到主进程的返回值send是单向通知不关心返回值。发送消息需要拿到构造好的消息 ID所以用invoke窗口控制之类的不需要返回值用send就够了。主进程的核心代码ipcMain.handle(chat:send, async (event, payload) { const { conversationId, content } payload; const message: Message { id: crypto.randomUUID(), conversationId, senderId: me, content, type: text, timestamp: Date.now(), status: sent }; saveMessageToDisk(message); broadcastMessage(message); // mock 回复 setTimeout(() { const reply: Message { id: crypto.randomUUID(), conversationId, senderId: peer, content: 收到你的消息啦这里是自动回复, type: text, timestamp: Date.now(), status: sent }; saveMessageToDisk(reply); broadcastMessage(reply); }, 1200); return message; });4.5 聊天列表的虚拟滚动不能省聊天记录会越来越多如果直接把数组全量渲染成 DOM几千条消息之后界面就会开始卡顿。虚拟滚动是必须的。我的精简实现思路固定每一项高度比如文本消息 64px图片消息自适应一种固定宽高然后用一个可滚动容器只渲染视口可见范围内的消息项。核心代码const scrollRef refHTMLDivElement(); const visibleRange ref({ start: 0, end: 20 }); const ITEM_HEIGHT 64; const buffer 5; // 上下各多渲染几条作为缓冲 function onScroll() { const scrollTop scrollRef.value?.scrollTop ?? 0; const viewportHeight scrollRef.value?.clientHeight ?? 0; const start Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - buffer); const end Math.min( messages.value.length, Math.ceil((scrollTop viewportHeight) / ITEM_HEIGHT) buffer ); visibleRange.value { start, end }; }当会话有大量历史消息时用虚拟滚动可以把 DOM 节点数量从几千降到几十个滚动流畅度是质的区别。我实测过3000 条消息全量渲染需要几百毫秒虚拟滚动后滚动帧率保持 60FPS 没有压力。5. 性能优化与打包上线的关键细节做完功能只是开始Electron 项目的真正分水岭在性能和打包。5.1 图片资源的加载策略聊天应用里最耗费资源的是头像和聊天图片。我总结出了一套分级策略小图标16px、24px 的导航图标直接用 SVG打包进 bundle避免任何网络加载用户头像默认头像用本地 base64 或 asset 文件用户后台上传的头像走 HTTP 缓存聊天图片用懒加载 按需解码loadinglazy配合 IntersectionObserver还有一个容易忽略的点Electron 里加载 HTTP 资源受到 Web Security 限制。如果页面是file://协议的本地文件直接请求http://接口会被 CORS 拦截。我当时在本地走http://localhost:5173没问题打包后变成file://协议就踩了 CORS 的坑。解决方案有两层主进程用session.defaultSession.webRequest.onBeforeSendHeaders配置跨域头或者后端接口配置正确的 CORS 允许来源。5.2 electron-builder 配置与打包体积控制我用的是 electron-builder配置写在electron-builder.ymlappId: com.example.wechat-desktop productName: WeChatDesktop directories: output: release files: - dist/** - electron/** - package.json win: target: - nsis mac: target: - dmg nsis: oneClick: false allowToChangeInstallationDirectory: true createDesktopShortcut: true createStartMenuShortcut: true这里有三个容易踩的坑files配置决定哪些文件进安装包如果漏了dist/**打包后启动就是白屏Electron 版本会带来体积差异每升一个 major 版本安装包体积可能增加 10-20MB建议锁定版本而不是一直追新图标必须是.ico格式Windows和.icns格式macOS普通 PNG 转换不规范会导致打包报错实际打完包的体积基础 Electron 约 60MB加上应用代码和资源最后大约 80MB。相比 Tauri 确实大但换来的是跨平台一致性和庞大的生态支持这个交易我是接受的。5.3 白屏问题的排查路径白屏是 Electron 新手最容易遇到的问题我梳理一下自己的排查顺序确认加载方式开发环境用loadURL时检查端口是否被占用生产环境用loadFile时检查路径是否正确看 DevTools Console主进程报错会在终端显示渲染进程报错需要手动打开 DevTools 查看检查index.html里的资源路径Vite 默认base: /如果打包后以file://协议加载绝对路径会失效需要在vite.config.ts里设置base: ./export default defineConfig({ base: ./, // 关键否则 file:// 协议下资源加载失败 plugins: [vue()], build: { outDir: dist, assetsDir: assets } });初始化白屏还有一种可能渲染进程代码里某个await一直 pending比如初始化时调用了chatAPI.getHistory()但主进程没有对应 handler。排查方法是在渲染进程代码里加日志或setTimeout定位卡住的位置。5.4 长时间驻留的内存问题与排查托盘常驻应用的特性就是“长期不退出”这会让内存问题变得特别敏感。我遇到过聊天窗口开久了内存缓慢上涨的情况。需要检查的几个方面是否每个消息都触发了整个会话列表的重渲染用 Zustand selector 控制到消息项粒度是否有 EventListener 没移除尤其是每开一个聊天窗口就挂一个onMessageReceived监听器关闭窗口时忘了清理远程图片是否被加入内存缓存长时间不释放调试工具用的是 Electron 内置的webContents.openDevTools()里的 Performance 面板配合 Memory 快照对比能很快定位到泄漏点。我的经验是**90% 的内存问题出在“监听器重复挂载”和“大列表全量渲染”**把这两个治理好内存就基本稳了。6. 实操中的遗留问题与最终体会项目做到最后总有几个问题没能彻底完美解决这里也坦诚分享一下。第一是视频通话功能没有做。桌面 IM 客户端的视频通话涉及摄像头采集、音视频编码、P2P 连接这不是一个人短期内能打磨完的我选择了先把基础聊天体验做透这个取舍我认为是对的。第二是消息加密与本地数据库方案。目前用 JSON 文件存聊天记录只适合 demo 和小型内部工具如果要做得更正式建议引入 SQLite 或 LevelDB配合主进程的加密模块做落地。这块涉及better-sqlite3的编译以及和 electron-rebuild 的配合步骤还算丰富但需要额外预算。第三是自定义标题栏在 macOS 上的细节。macOS 的窗口红绿灯按钮位置和交互习惯和 Windows 不一样微信电脑版在两种系统上的标题栏布局也不同。我这个项目的标题栏更贴近 Windows 风格如果真要考虑 mac 用户窗口控制按钮的位置要单独适配。做完整个项目我最大的体会是Electron 开发的最大瓶颈不是框架本身而是你对“桌面端产品完整性”的理解。会不会做托盘常驻、会不会处理多窗口通信、会不会做消息持久化、会不会控制内存增长——这些才是真正拉开差距的地方。Vite 解决了开发体验和构建质量Electron 提供了容器和系统能力两者结合的工程化沉淀下来能非常直接地复用到其他任何 Electron 项目里。最后再分享一个我后来才养成的习惯每个 Electron 项目里都用electron-log记录主进程日志开发环境和生产环境都留出口用户现场出的问题能靠着日志快速定位省下的排查时间远超写日志的成本。这个建议我强烈安利给所有准备做 Electron 项目的朋友。