1. 桌宠这个品类,到底缺了什么
桌宠这东西不新鲜。从早年 Windows 上的 Shimeji、伪春菜,到后来各种 Live2D 看板娘,再到手机上的各种宠物 App,本质上都是同一件事:在屏幕上放一个会动的小东西,陪你打发时间。但说实话,绝大多数桌宠活不过三天——第一天觉得新鲜,第二天觉得碍事,第三天直接关掉自启。原因很简单,它们只会“卖萌”,不会“干活”。
XiaoMate 这个项目有意思的地方就在这。它的定位不是“电子宠物”,而是“桌面助手”——一个真正能帮你处理事情的桌宠。项目用 Electron 做壳,用 WebSocket 做通信,把“桌宠”这个形态从纯装饰品拉到了生产力工具的范畴。你可以把它理解成:一个长着可爱外表的自动化助手,平时安安静静待在屏幕角落,你需要它的时候,它能帮你跑脚本、查信息、做提醒、甚至对接你自己的后端服务。
这篇文章我会从架构设计、技术选型、核心实现、实操部署、问题排查几个维度,把这个项目拆开讲透。不管你是想直接拿来用,还是想基于它改一个自己的版本,或者单纯想学 Electron + WebSocket 的实战组合,都能从里面拿到东西。我默认读者有基本的前端基础,但不会假设你写过 Electron,关键地方我会把原理补上。
2. 为什么是 Electron + WebSocket 这套组合
2.1 桌宠的三种技术路线对比
做桌宠,摆在面前的路其实就三条,我先把它们摊开对比一下,你就明白 XiaoMate 为什么选 Electron 了。
| 技术路线 | 代表方案 | 优势 | 致命短板 |
|---|---|---|---|
| 原生桌面开发 | Qt、WPF、SwiftUI | 性能好、系统集成深 | 跨平台成本极高,UI 开发慢,动画生态弱 |
| 游戏引擎 | Unity、Godot | 动画和特效强 | 包体巨大,内存占用高,做“轻量常驻”不合适 |
| Web 技术栈 | Electron、Tauri | UI 生态丰富、开发快、跨平台 | 内存占用偏高,需要处理进程通信 |
桌宠这个场景有个很特殊的需求:它要常驻后台、随时响应、还要好看。原生方案好看这件事就很难,游戏引擎又太重,一个桌宠占 500MB 内存谁受得了。Electron 虽然常被吐槽“一个 Hello World 就 100MB”,但它恰好卡在了一个甜点位上——用 Chromium 渲染,意味着你可以用 CSS 动画、Canvas、甚至 Live2D 的 Web SDK 来做角色表现,同时 Node.js 环境又能让你直接调用系统能力。
XiaoMate 选 Electron,我认为核心考量是开发效率和表现力的平衡。桌宠的“脸”用 Web 技术画,桌宠的“手”(系统操作能力)用 Node.js 做,两边都不将就。
2.2 WebSocket 在这里扮演什么角色
很多人第一反应会问:一个桌宠,为什么要用 WebSocket?用 HTTP 请求不够吗?
这里要理解桌宠的交互模型。HTTP 是“请求-响应”模式,客户端不问,服务端就不说话。但桌宠需要的是服务端主动推送——比如后端检测到某个任务完成了,要主动让桌宠弹个提示;比如你在别的地方触发了指令,桌宠要实时做出反应。这种“服务端主动说话”的需求,HTTP 轮询能做但很蠢(浪费资源、有延迟),WebSocket 的全双工长连接才是正解。
提示:WebSocket 的核心价值在于“连接建立后,双方随时可以互发消息”,不需要每次重新握手。对于桌宠这种需要实时响应、又要保持低功耗常驻的场景,这是刚需。
而且 WebSocket 还有个隐性好处:它天然适合做多端联动。你的桌宠可以同时连手机端、网页端、后端服务,任何一端发消息,桌宠都能收到。这就为后面要讲的“扩展能力”埋下了伏笔。
2.3 整体架构长什么样
XiaoMate 的架构可以拆成三层,我用文字描述一下数据流,你脑子里应该能画出图:
- 渲染层(Renderer):Electron 的窗口进程,负责画桌宠、播动画、显示气泡对话。这一层就是标准的前端代码,HTML + CSS + JS。
- 主进程层(Main):Electron 的主进程,管理窗口、托盘、系统级操作(比如开机自启、读取系统信息、执行本地命令)。它和渲染层通过 IPC 通信。
- 通信层(WebSocket Client):跑在主进程或独立进程里,负责和后端服务保持长连接,收发消息,做心跳保活。
数据流向是这样的:后端通过 WebSocket 推消息 → 主进程接收 → 通过 IPC 转发给渲染层 → 渲染层更新桌宠状态和气泡内容。反过来,用户在桌宠上点击操作 → 渲染层捕获 → IPC 发给主进程 → 主进程通过 WebSocket 发给后端。
这套架构的好处是职责清晰。渲染层只管好看,主进程只管能力,通信层只管连接。任何一层出问题都好定位,也方便你替换其中一层——比如你不想用 WebSocket,换成 MQTT 或者本地 socket,只动通信层就行。
3. 核心模块拆解与实现要点
3.1 桌宠窗口的“透明穿透”是怎么做的
桌宠最基础也最容易踩坑的一点:窗口要透明、无边框、还要能穿透点击。什么意思?就是桌宠站在屏幕上,你点它身体它能响应,但你点它周围的透明区域,鼠标事件要“穿过去”落到下面的应用上。
Electron 里实现这个靠几个窗口配置组合:
const win = new BrowserWindow({ width: 300, height: 300, transparent: true, // 背景透明 frame: false, // 无边框 alwaysOnTop: true, // 置顶 skipTaskbar: true, // 不在任务栏显示 resizable: false, hasShadow: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true } })光这样还不够,透明区域默认还是会拦截鼠标。真正实现“穿透”要用到setIgnoreMouseEvents:
// 默认让整个窗口忽略鼠标事件,但允许转发 win.setIgnoreMouseEvents(true, { forward: true }) // 然后在渲染层监听鼠标位置,当鼠标移到桌宠“实体”区域时,关闭忽略这里有个关键技巧:forward: true这个参数必须加。它的作用是让窗口在忽略鼠标事件的同时,仍然能收到mousemove事件,这样你才能判断鼠标是不是移到了桌宠身上。如果不加,窗口就彻底“死”了,连鼠标位置都收不到,没法做动态切换。
注意:
setIgnoreMouseEvents的切换逻辑一定要做防抖。我见过有人每帧都调用一次,结果 CPU 直接飙到 30%。正确做法是监听鼠标进入/离开桌宠实体区域的边界事件,只在状态变化时调用。
3.2 WebSocket 心跳机制,别等断了才发现
WebSocket 长连接有个绕不开的问题:网络波动、服务端重启、中间设备超时,都会导致连接悄悄断掉,而客户端可能毫无察觉。等你发现桌宠半天没反应,其实连接早就死了。
XiaoMate 里必须实现心跳保活。原理很简单:客户端每隔一段时间发一个 ping,服务端回一个 pong,如果连续几次没收到 pong,就判定连接断开,触发重连。
class WSClient { constructor(url) { this.url = url this.heartbeatInterval = 30000 // 30秒发一次心跳 this.heartbeatTimeout = 10000 // 10秒没响应判定超时 this.reconnectDelay = 3000 // 重连间隔 this.connect() } connect() { this.ws = new WebSocket(this.url) this.ws.onopen = () => { this.startHeartbeat() } this.ws.onmessage = (e) => { const msg = JSON.parse(e.data) if (msg.type === 'pong') { this.resetHeartbeatTimer() return } this.handleMessage(msg) } this.ws.onclose = () => { this.stopHeartbeat() setTimeout(() => this.connect(), this.reconnectDelay) } } startHeartbeat() { this.heartbeatTimer = setInterval(() => { this.ws.send(JSON.stringify({ type: 'ping' })) this.timeoutTimer = setTimeout(() => { this.ws.close() // 主动关闭,触发重连 }, this.heartbeatTimeout) }, this.heartbeatInterval) } resetHeartbeatTimer() { clearTimeout(this.timeoutTimer) } }这段代码有几个细节值得说。第一,心跳间隔设 30 秒是个经验值——太短浪费流量和电量,太长断线发现不及时。第二,超时时间要小于心跳间隔,否则会出现“上一个心跳还没超时,下一个又发出去了”的混乱。第三,重连一定要做,而且最好加指数退避,避免服务端挂了的时候客户端疯狂重连把服务端打垮。
// 指数退避重连 this.reconnectDelay = Math.min(this.reconnectDelay * 1.5, 30000)3.3 消息协议设计,别用裸字符串
桌宠和后端之间传什么、怎么传,这个协议设计得好不好,直接决定后期扩展顺不顺。我强烈建议用 JSON 结构化消息,别图省事传裸字符串。
一个合理的消息格式大概长这样:
{ "type": "command", "action": "show_bubble", "payload": { "text": "该喝水了", "duration": 5000 }, "timestamp": 1700000000000, "id": "msg_abc123" }type区分消息大类(命令、事件、心跳、错误),action是具体动作,payload装数据,id用于消息追踪和去重。这套结构看起来啰嗦,但当你需要做消息确认、失败重发、日志排查的时候,会感谢自己当初没偷懒。
提示:消息里带
id还有一个用处——做幂等。网络抖动可能导致同一条消息发两次,接收端根据id去重,避免桌宠重复弹同一个提示。
3.4 桌宠动画与状态机
桌宠不能只会站着,得有“待机、说话、思考、开心、睡觉”这些状态。用状态机来管理是最清晰的。
const states = { idle: { animation: 'idle', loop: true }, talking: { animation: 'talk', loop: true }, thinking: { animation: 'think', loop: true }, happy: { animation: 'happy', loop: false, next: 'idle' }, sleeping: { animation: 'sleep', loop: true } } class PetStateMachine { constructor() { this.current = 'idle' } transition(nextState) { if (this.current === nextState) return const state = states[nextState] if (!state) return this.current = nextState this.playAnimation(state) if (!state.loop && state.next) { // 非循环动画播完自动切回 this.onAnimationEnd(() => this.transition(state.next)) } } }状态机的价值在于避免状态混乱。如果没有状态机,你到处写if (isTalking) ... else if (isSleeping) ...,代码很快就变成一团乱麻。有了状态机,任何时刻桌宠只有一个明确状态,切换逻辑集中在一处,加新状态也简单。
4. 从零跑起来:完整实操流程
4.1 环境准备与依赖安装
先把基础环境搭好。你需要 Node.js(建议 18 LTS 以上)和 npm。检查一下版本:
node -v npm -v然后拉代码、装依赖:
git clone <项目仓库地址> cd xiaomate npm installElectron 项目npm install有时候会卡在下载 Electron 二进制包那一步,因为默认从境外源拉。如果你遇到卡住不动,可以配置镜像:
npm config set electron_mirror https://npmmirror.com/mirrors/electron/装完之后,先别急着跑,看一眼package.json里的 scripts,搞清楚启动命令是什么。通常是:
npm run dev # 开发模式,带热重载 npm start # 直接启动4.2 配置后端连接地址
XiaoMate 要连后端 WebSocket 服务,地址一般写在配置文件里。找到config.js或.env之类的文件,把 WebSocket 地址改成你自己的:
// config.js module.exports = { wsUrl: 'ws://localhost:8080/ws', heartbeatInterval: 30000, reconnectMaxDelay: 30000 }如果你本地还没有后端服务,可以先用一个最简单的 Node.js WebSocket 服务做测试:
// test-server.js const { WebSocketServer } = require('ws') const wss = new WebSocketServer({ port: 8080 }) wss.on('connection', (ws) => { console.log('客户端已连接') ws.on('message', (data) => { const msg = JSON.parse(data) if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })) } }) // 每10秒主动推一条消息测试 const timer = setInterval(() => { ws.send(JSON.stringify({ type: 'command', action: 'show_bubble', payload: { text: '我还在呢', duration: 3000 } })) }, 10000) ws.on('close', () => clearInterval(timer)) }) console.log('测试服务运行在 ws://localhost:8080')跑起来这个测试服务,再启动 XiaoMate,你应该能看到桌宠每隔 10 秒弹一次气泡。这一步能通,说明通信链路没问题。
4.3 打包发布
开发跑通了,接下来是打包。Electron 打包主流用electron-builder,配置写在package.json或单独的electron-builder.yml里。
appId: com.example.xiaomate productName: XiaoMate directories: output: dist files: - "**/*" - "!test-server.js" win: target: nsis icon: build/icon.ico mac: target: dmg icon: build/icon.icns linux: target: AppImage icon: build/icon.png打包命令:
npm run build这里有个坑要提醒:打包后 WebSocket 地址不能写死 localhost。开发时连本机没问题,但用户装到别的机器上,localhost 指向的是用户自己的电脑,根本连不上你的服务。正确做法是把服务地址做成可配置项,首次启动时让用户填,或者从环境变量读取。
注意:Electron 打包体积普遍偏大(100MB 起步),这是 Chromium 内核决定的,别想着能压到几十 MB。如果体积是硬指标,可以考虑 Tauri,但那就得换一套技术栈了。
4.4 开机自启与托盘常驻
桌宠要“常驻”,就得处理开机自启和托盘。Electron 提供了现成 API:
const { app, Tray, Menu } = require('electron') // 开机自启 app.setLoginItemSettings({ openAtLogin: true, path: app.getPath('exe') }) // 托盘 let tray = new Tray('build/tray-icon.png') const contextMenu = Menu.buildFromTemplate([ { label: '显示桌宠', click: () => win.show() }, { label: '隐藏桌宠', click: () => win.hide() }, { type: 'separator' }, { label: '退出', click: () => app.quit() } ]) tray.setContextMenu(contextMenu)托盘这块有个细节:关闭窗口时不要真的退出应用,而是隐藏窗口。否则用户点一下关闭,桌宠就没了,还得重新启动。
win.on('close', (e) => { if (!app.isQuitting) { e.preventDefault() win.hide() } })5. 踩坑实录与问题排查
5.1 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 桌宠窗口一片黑 | 透明配置未生效或 GPU 加速问题 | 检查transparent: true,尝试app.disableHardwareAcceleration() |
| 鼠标穿透失效 | setIgnoreMouseEvents调用时机不对 | 确认forward: true,检查切换逻辑防抖 |
| WebSocket 频繁断连 | 心跳间隔过长或服务端超时设置过短 | 缩短心跳间隔,检查服务端 idle timeout |
| 打包后连不上后端 | 地址写死 localhost | 改为可配置,检查生产环境地址 |
| 内存占用持续上涨 | 动画或定时器未清理 | 检查setInterval是否在组件销毁时清除 |
| 桌宠不显示在部分屏幕 | 多显示器坐标问题 | 用screen.getAllDisplays()计算正确位置 |
5.2 内存泄漏这个隐形杀手
Electron 应用最怕内存泄漏,桌宠又是 7x24 小时常驻的,泄漏一点点,几天下来就吃掉几个 G。最常见的泄漏源是定时器和事件监听没清理。
// 错误示范 setInterval(() => { updatePetAnimation() }, 100) // 正确做法:保存引用,在合适时机清理 this.animTimer = setInterval(() => { updatePetAnimation() }, 100) // 销毁时 clearInterval(this.animTimer)还有 WebSocket 的事件监听,重连的时候如果旧的监听没解绑,会越积越多。每次new WebSocket之前,先把旧的onmessage、onclose置空。
提示:排查内存泄漏可以用 Chrome DevTools 的 Memory 面板,隔一段时间拍一次堆快照,对比对象数量变化。如果某个对象数量只增不减,基本就是它了。
5.3 跨平台的那些差异
Windows、macOS、Linux 三个平台,Electron 的行为有不少差异,桌宠这种涉及窗口、托盘、自启的功能尤其明显。
- 窗口置顶:macOS 上
alwaysOnTop有时会被全屏应用盖住,需要设置win.setAlwaysOnTop(true, 'screen-saver')提升层级。 - 托盘图标:macOS 的托盘图标要用模板图片(黑白带透明通道),否则在深色菜单栏下看不清。
- 开机自启:Linux 下
setLoginItemSettings支持有限,可能需要手动写.desktop文件到 autostart 目录。 - 透明窗口:部分 Linux 桌面环境对透明窗口支持不好,可能需要关闭合成器相关设置。
我的建议是,如果你只针对一个平台发布,就别管其他平台。如果要全平台,每个平台都得实机测一遍,别指望“写一次到处跑”。
5.4 调试技巧:把日志打明白
桌宠跑在后台,出问题了你看不到界面,只能靠日志。建议在项目里集成一个简单的日志系统,把关键事件都记下来:
const log = require('electron-log') log.transports.file.level = 'info' log.info('WebSocket 连接建立', { url: this.url }) log.warn('心跳超时,准备重连') log.error('消息解析失败', { raw: data })electron-log会自动把日志写到用户目录下的标准位置,Windows 在%APPDATA%,macOS 在~/Library/Logs,Linux 在~/.config。出问题时让用户把日志发过来,比猜快一百倍。
6. 这个项目还能怎么扩展
XiaoMate 的架构留了不少扩展口子,我分享几个我觉得有意思的方向。
对接本地脚本执行。桌宠收到特定指令后,主进程可以调用child_process执行本地脚本。比如你说“帮我整理下载文件夹”,它就跑一个整理脚本。这里要注意安全,别让任意指令都能执行,最好做个白名单。
多桌宠协同。WebSocket 天然支持多客户端,你可以同时开好几个桌宠,让它们分工——一个负责提醒,一个负责监控,一个负责卖萌。后端根据消息类型分发给不同的桌宠实例。
接入语音能力。桌宠加个语音唤醒和 TTS,就能变成真正的语音助手。Web Speech API 在 Electron 里可用,或者对接后端的语音服务。这块坑比较多,麦克风权限、回声消除、唤醒词误触发,都得慢慢调。
状态持久化。桌宠的“心情值”“亲密度”这些数据,可以存到本地 SQLite 或者后端,让它有“记忆”。下次打开还是上次的状态,体验会好很多。
插件化。把每个功能做成插件,桌宠启动时动态加载。这样核心保持轻量,功能按需扩展,也方便社区贡献。
7. 我个人的一些实操体会
做桌宠这类项目,技术难度其实不算高,难的是让它真的有用。我见过太多桌宠项目,demo 很惊艳,装到电脑上三天就卸载。XiaoMate 的思路是对的——把桌宠当成一个“交互入口”,而不是一个“装饰品”。它长得可爱是加分项,但真正留住用户的是它能干活。
另外一个体会是,通信层的健壮性决定体验下限。桌宠卡一下、断一下,用户立刻就觉得“这玩意不靠谱”。所以心跳、重连、消息去重这些看起来枯燥的东西,恰恰是最该花时间打磨的。我建议在开发早期就把这些基础设施做好,别等到上线了再补,那时候改起来牵一发动全身。
最后说个小的:桌宠的动画帧率别追求 60fps。桌宠大部分时间在待机,30fps 完全够用,还能省电。真正需要高帧率的只有交互瞬间,可以动态调整。这个细节用户感知不到,但笔记本用户会感谢你——毕竟谁也不想桌宠一开,风扇就呼呼转。