用 Vue 3 + Electron 做桌宠:从启动退出到拖拽、单击与鼠标穿透

🧑‍💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣

一个角色能在桌面上动起来,只是桌宠开发的开始。真正影响使用体验的,往往是几个看似普通的问题:重复启动会不会出现两个宠物?关闭设置窗口后应用还在不在?拖动人物为什么会误触单击?透明窗口为什么挡住了后面的软件?退出时配置是否来得及保存?

本文以我开发的 Windows AI 桌宠「伴星」为例,拆解这些交互背后的实现思路。重点不在动画素材,而在 Electron 生命周期、Vue 指针事件、跨进程通信和异步资源管理。

阅读说明:内容基于项目当前源码的静态核对。文中代码均为解释原理的简化示例,不是可直接替换项目文件的完整实现;Windows 多屏、混合 DPI 和异常退出等场景仍需要实机验证。

一、先划清边界:Vue 处理意图,Electron 操作系统窗口

普通网页里拖动元素,改变的是 DOM 的位置。桌宠则不同:人物绘制在一个透明窗口里,要让它跨桌面移动,最终必须改变操作系统窗口的位置。

因此,我把整个交互分成三层:

text 复制代码
Vue 渲染进程
  识别按下、移动、松开、单击、双击
          ↓
preload
  暴露有限的 petApi 能力
          ↓
Electron 主进程
  校验调用来源与参数,移动窗口、切换穿透、保存数据

这条边界也适用于启动和退出:Vue 可以发出"退出"意图,但不应该拥有任意执行系统命令的能力。

简化的 preload 接口如下:

javascript 复制代码
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('petApi', Object.freeze({
  movePet: (value) => ipcRenderer.invoke('pet:move-pet', value),
  setMouseIgnore: (value) => ipcRenderer.invoke('pet:set-mouse-ignore', value),
  quit: () => ipcRenderer.invoke('pet:quit'),
}));

重点不是给 IPC 换个名字,而是只暴露明确的业务操作。渲染层不需要获得完整的 ipcRenderer,更不需要开启 Node 集成。

二、应用启动:先恢复状态,再创建窗口

2.1 用单实例锁避免重复桌宠

桌宠通常是长驻应用。如果每次双击图标都创建一个新进程,桌面很快就会出现多个人物、多个托盘和多份后台任务。

Electron 提供了单实例锁:

javascript 复制代码
if (!app.requestSingleInstanceLock()) {
  app.quit();
} else {
  app.on('second-instance', () => {
    openSettings();
  });
}

没有拿到锁的新实例直接退出;已经运行的实例收到 second-instance 后打开设置窗口。这里没有选择再创建一个人物,而是把重复启动转化为"找到正在运行的应用"。

2.2 启动顺序决定第一帧是否正确

项目的主要启动流程是:

text 复制代码
取得单实例锁
    ↓
等待 app.whenReady()
    ↓
读取配置与上次位置
    ↓
检查是否已进入退出状态
    ↓
注册 IPC、创建桌宠窗口
    ↓
创建托盘、应用设置、监听显示器变化

为什么先读取位置?如果先显示默认窗口,再异步恢复位置,人物会从屏幕角落突然跳到旧位置。

为什么读取后还要检查退出状态?因为 await 期间应用可能已经收到退出请求。没有这道检查,退出流程刚开始,启动回调却又创建了新窗口。

当前代码还显式设置了 openAtLogin: false。手动启动应用和开机自启动是两件事,不能因为程序能运行就默认开启登录启动。

2.3 透明窗口不只是 transparent

桌宠窗口需要组合多个属性:

javascript 复制代码
const win = new BrowserWindow({
  show: false,
  transparent: true,
  frame: false,
  alwaysOnTop: true,
  skipTaskbar: true,
  resizable: false,
  focusable: false,
  hasShadow: false,
  webPreferences: {
    preload: preloadPath,
    contextIsolation: true,
    sandbox: true,
    nodeIntegration: false,
  },
});

win.once('ready-to-show', () => {
  win.showInactive();
});

这些选项分别解决不同问题:无边框负责外观,透明负责背景,跳过任务栏避免长期占位,非聚焦窗口与 showInactive() 尽量避免打断用户正在输入的内容。

但是,聊天输入框需要键盘焦点。项目打开内置聊天时会临时允许窗口聚焦,并关闭鼠标穿透;关闭聊天后再恢复非打扰展示。

陪伴状态与输入状态应使用不同的焦点策略。

三、窗口关闭与应用退出,必须分开设计

关闭一个设置窗口,不应该等同于退出整个桌宠。

项目对 window-all-closed 注册了空处理函数,没有在这里调用 app.quit()。这是托盘型应用常见的策略:窗口数量变为零,不自动触发退出。

不过,这个事件处理本身也不会自动恢复被关闭的桌宠窗口。是否重建窗口是另一个产品决策,不能把"进程没有退出"理解为"人物一定还在"。

真正的退出入口来自托盘菜单和桌宠菜单,它们最终统一调用 app.quit(),再由 before-quit 收口处理。

四、退出不是一行 app.quit(),而是一个两阶段协议

桌宠退出时,可能同时存在:

  • 尚未写完的设置;
  • 防抖中的位置保存;
  • 正在执行的模型请求;
  • 划词辅助子进程与系统语音进程;
  • 运动定时器与对话记忆写入。

如果立即结束应用,可能丢失最新位置或配置;如果只等待保存,却允许新任务继续进入,程序又可能一直无法收尾。

4.1 第一阶段:停止接收新工作

项目使用 quitting 标记"正在退出":停止运动和划词 helper,取消网络请求,终止已登记的语音子进程,并销毁托盘。

IPC 入口也检查这个标记。退出期间收到新操作,直接拒绝执行。

这相当于先关掉业务入口,再清理在途工作。

4.2 第二阶段:等待写入,再真正退出

核心结构可以简化成:

javascript 复制代码
let quitting = false;
let quitFlushed = false;

app.on('before-quit', (event) => {
  if (quitFlushed) return;

  event.preventDefault();
  if (quitting) return;

  quitting = true;
  stopBackgroundTasks();

  Promise.allSettled([
    persistPosition(),
    saveQueue,
    dialogue.flush(),
  ]).then(() => {
    quitFlushed = true;
    app.quit();
  });
});

这里两个标记不能混为一谈:

  • quitting:第一轮退出已开始,防止重复清理和新任务进入。
  • quitFlushed:本轮等待已结束,允许第二次 app.quit() 通过。

第二次调用仍会触发 before-quit。如果没有放行条件,退出逻辑就会不断拦截自己。

Promise.allSettled() 的作用是等待各项写入结束,即使其中某项失败,也继续完成退出流程。它不代表所有数据保存成功,也不能解决一个永远不结束的 Promise。

这套流程保障的是正常退出的尽力落盘,不是断电、强杀或系统异常情况下的绝对保证。

4.3 防止"退出后复活"

还有一种更隐蔽的竞态:

text 复制代码
设置保存开始
    ↓
用户退出,托盘被销毁
    ↓
旧保存任务结束
    ↓
完成回调试图刷新托盘、启动划词或恢复漫游

因此,不能只在退出按钮处检查状态。更新托盘、广播状态、启动 helper 等副作用入口,也需要识别退出状态。

异步任务完成,不代表应用还处于允许执行后续动作的阶段。

五、拖拽人物:识别手势,而不是按下就移动

5.1 为什么使用 Pointer Events

项目使用 pointerdown、pointermove、pointerup 组织手势,并在按下时调用:

javascript 复制代码
event.currentTarget.setPointerCapture(event.pointerId);

指针捕获解决的是事件连续性:鼠标快速移出人物区域后,仍能把后续移动和松开事件交给当前元素处理,避免拖动半途失联。

同时还监听窗口级的 pointerup、pointercancel,以及元素的 lostpointercapture。结束函数必须允许重复调用,因为一次交互可能经过多个结束入口。

5.2 用移动阈值区分点击与拖动

鼠标按下后,人手通常会有几像素抖动。如果一点位移都算拖拽,单击几乎无法稳定触发。

项目配置的拖动阈值为 5,判断采用起点到当前点的欧氏距离:

javascript 复制代码
const distance = Math.hypot(
  event.screenX - drag.startX,
  event.screenY - drag.startY,
);

if (!moved && distance <= dragThreshold) return;

因此,按下时只进入"候选拖动"状态;超过阈值,才把 moved 设置为 true,切换拖拽动作并移动窗口。

这里还有一条关键规则:一旦确认拖动,就取消尚未执行的单击定时器。 否则人物已经被拖走,之前排队的点击行为仍可能突然出现。

5.3 屏幕坐标与窗口内坐标不要混用

拖动窗口需要关注屏幕位移,所以项目使用相邻事件的 screenX/screenY 计算增量:

javascript 复制代码
const dx = event.screenX - drag.x;
const dy = event.screenY - drag.y;

window.petApi.movePet({ dx, dy });

命中哪个按钮则使用 clientX/clientY,因为它们属于当前视口坐标,适合传给 document.elementFromPoint()。

Electron 的窗口边界采用 DIP。不能把名字里带 screen 的坐标直接理解为原始物理像素,再随意乘以 devicePixelRatio。混合 DPI 显示器之间的真实拖动效果,需要专门实测。

5.4 主进程才是窗口移动的最终执行者

渲染层传入的是移动意图,主进程仍需检查:

  1. 是否来自桌宠窗口及可信主 frame;
  2. dx/dy 是否为有限数;
  3. 是否需要限制单次位移;
  4. 移动结果是否超出显示器工作区。

当前实现将单次增量限制在 ±1000,再用工作区边界夹取窗口位置。同时停止已有运动,避免自动漫游与手动拖动争抢窗口。

多屏场景还必须允许负坐标:放在主屏左边的显示器,完全可能拥有负的横坐标。

六、拖拽的手感:倾斜、速度与松手惯性

人物跟随窗口移动只是基础。项目还根据水平拖动速度设置倾斜目标,并通过弹簧与阻尼,让角度平滑追随、松手回正。

速度估计使用平滑处理:

javascript 复制代码
vx = vx * 0.35 + (dx / seconds) * 0.65;

它比单次位移直接除以时间更不容易抖动。倾斜目标限制在 ±18°,视觉动画由 requestAnimationFrame 更新。

需要区分两个独立效果:

  • 人物倾斜:发生在渲染层,是视觉反馈。
  • 松手抛掷:发生在主进程,真实改变窗口位置。

松手时,如果最后一次移动距当前已经超过 120ms,就按零速度释放,避免"拖到目的地停了一会儿,松手却突然飞出去"。

主进程仅在自由漫游模式、且不受交互状态阻挡时启动释放运动。速度经过非线性压缩,小速度不触发抛掷;后续物理更新包含重力、边界反弹和地面摩擦。

对自动运动与释放运动,项目还检查移动路径,而不仅是终点,避免窗口从不相连的显示器之间直接穿过空白区域。手动拖动则采用工作区夹取,二者不是同一套边界算法。

七、单击、双击与拖动为什么会互相干扰

双击通常伴随着两次 click,随后才收到 dblclick。如果第一次 click 就立即执行单击行为,那么双击一定会先触发一次单击。

项目采用延迟确认单击:

javascript 复制代码
function onClick() {
  clearTimeout(clickTimer);
  if (moved || dragging) return;

  clickTimer = setTimeout(() => {
    if (!moved && !dragging) performSingleClick();
  }, 260);
}

function onDoubleClick() {
  clearTimeout(clickTimer);
  if (!moved && !dragging) openOfficialWebsite();
}

实际处理关系如下:

行为 判断方式 最终结果
单击 未越过拖动阈值,延迟结束 展示操作栏、点击反馈或台词
双击 在单击确认前收到双击事件 取消待执行单击,打开对应官网
拖动 位移超过阈值 移动人物,抑制拖动结束后的 click

260ms 是项目当前采用的取舍,不是读取了操作系统的双击时间。若用户以更慢节奏完成系统认可的双击,先前的单击可能已经执行,因此不能宣称这套固定延迟能完全隔离所有双击场景。

当前源码的单击也不只是"换一句话":在合适的待机或完成状态下,它会从点击动作池选动作;固定动作、编程模式或其他受保护状态下则走台词反馈。双击打开官网,与内置对话面板是两个不同入口。

八、鼠标穿透:透明不等于不挡鼠标

透明窗口的背景虽然看不见,但仍然占着一个矩形窗口区域。用户点击人物周围的透明部分时,如果不处理,事件仍可能被桌宠截获。

Electron 提供了窗口级穿透:

javascript 复制代码
petWindow.setIgnoreMouseEvents(true, { forward: true });

关键是根据当前位置动态切换:

text 复制代码
鼠标位于人物、按钮、菜单或对话区域
    → 关闭穿透,接收点击

鼠标位于无交互的透明区域
    → 开启穿透,让下层软件接收操作

正在拖动、打开菜单或输入聊天
    → 保持可交互,避免中途失去控制

渲染层通过 elementFromPoint() 找到鼠标下的元素,再用选择器判断是否属于交互区域,只在状态发生变化时通知主进程,避免每次移动都重复发送相同 IPC。

在 Windows 上,forward: true 可转发鼠标移动消息,帮助应用在开启穿透后继续识别重新进入可交互区域的移动。它不等于转发所有点击事件。

还要注意:项目这里是 DOM 命中判断,不是逐像素透明度命中。人物容器内部即使有透明像素,也可能仍然算人物区域。想让点击完全贴合轮廓,需要额外的命中区域设计,不能单靠透明 CSS。

九、位置记忆:防抖、串行与正常退出快照

窗口移动事件触发很频繁,每次都写文件既浪费 I/O,也容易产生写入竞争。

项目将位置保存设计为:

  1. 移动后等待 1000ms,后续移动重置计时器;
  2. 保存时读取窗口坐标,生成整数 x/y 快照;
  3. 通过独立 Promise 队列串行写入;
  4. 先写临时文件,再通过 rename 替换目标;
  5. 正常退出时立即取最终位置快照并等待队列。

串行队列同时连接成功与失败分支:

javascript 复制代码
const result = positionQueue.then(save, save);
positionQueue = result.catch(() => {});

这样,一次写入失败不会让整条队列永久停在 rejected 状态。

位置独立保存在用户数据目录的 pet-position.json,不混入包含 API 密钥的加密设置草稿。移动人物,不应该顺便覆盖用户正在编辑的模型配置。

恢复位置时还要根据当前显示器重新夹取;显示器被拔掉后,旧位置可能已经不存在。这里能保证的是按现有工作区重新归位,不是无条件相信上次坐标。

十、组件卸载也需要"小型退出流程"

应用级退出之外,Vue 组件本身也可能卸载或刷新。

交互模块需要清理单击定时器、随机动作定时器、倾斜动画帧、窗口级指针事件、音频资源和 IPC 订阅。

项目通过 preload 让订阅函数返回解除函数,再由组件统一执行。异步回调则配合 disposed 标记检查,避免组件已卸载后继续修改界面。

清理资源不只是为了省内存,还为了避免旧回调干扰下一次交互:旧单击延迟不应在新一轮拖动里执行,旧动作结束事件也不应把新动作切回待机。

十一、建议重点验收的场景

这些交互不能只靠"拖一下能动"就判定完成。建议至少覆盖:

  • 连续双击启动图标,确认只保留一个主实例。
  • 关闭设置窗口,确认桌宠和托盘仍可使用。
  • 配置保存尚未结束时退出,观察是否出现异常回调或后台残留。
  • 按下后轻微抖动再松开,确认仍能单击。
  • 快速拖出人物区域再松开,确认不会卡在拖动状态。
  • 拖动结束后不触发单击;双击尽量不触发额外动作,并测试较慢双击。
  • 鼠标在透明区、人物、操作栏、聊天面板之间移动,检查穿透和焦点切换。
  • 多屏负坐标、混合缩放、拔掉显示器后重启,检查位置恢复。
  • 正常退出后再次启动,检查位置;强杀场景单独记录,不把它与正常退出保证混为一谈。

上述是建议验收清单,不代表本文编写过程中已经逐项完成测试。

结语

桌宠最难的部分,并不是把一段视频放进透明窗口,而是让几个系统协作得足够自然:

  • 启动时只保留一个实例,先恢复状态再显示人物;
  • 退出时先阻止新任务,再等待在途写入;
  • 拖动时用阈值与指针捕获保证手势连续;
  • 点击时协调单击延迟、双击取消与拖动抑制;
  • 透明区域通过动态穿透,把鼠标控制权还给用户;
  • 跨进程操作始终通过有限接口和主进程校验执行。

这些思路不只适用于桌宠,也适用于桌面悬浮球、截图工具、翻译浮窗和托盘型效率工具。做好它们,桌面应用才会从"能运行"变成"不会打扰用户、也不会在边界条件下失控"。

如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。

相关推荐
软件技术NINI2 小时前
基于JavaWeb 汽车租聘
java·vue.js·spring boot·后端·python·毕业设计·毕设
2301_32241428049 小时前
基于SpringBoot+Vue的休闲零食超市管理系统的设计与实现59004-计算机毕设 原创(免费领源码+部署教程+开发环境)
java·vue.js·spring boot·redis·mysql·决策树·推荐算法
酷炫码神15 小时前
Day 8|Vue3 响应式:告别手动操作 DOM,让数据驱动界面
javascript·vue.js·ecmascript
非常好吃的糖16 小时前
Windows 桌面端的语音唤醒:从「识别出文字」到「识别出唤醒词」
electron
星光不问赶路人17 小时前
Vue 3 对象监听与快照:ref 和 reactive 的关键差异
前端·vue.js
稻草人47817 小时前
Vue3.0项目创建步骤
vue.js
Bug收容所18 小时前
Vue+js Day2
前端·javascript·vue.js
honkun620 小时前
vue 下拉树组件 vxe-table-select 实现分页多选
前端·javascript·vue.js
溪语流沙1 天前
【Web全栈进阶】监控与告警:线上系统的“体检报告”
前端·vue.js·python·fastapi