目录
- 坑一:focusable: false 会让系统把你的点击吃掉
- 坑二:hide() 再 show() 会让鼠标穿透的转发机制失效
- 坑三:悬停判定不能靠系统原生的拖拽区
- 心法
做过桌面应用的大概率遇到过这种需求:一个常驻屏幕的小挂件——番茄钟计时器、阅读进度条、系统监控条——要透明、要置顶、大部分时候要点击穿透(不挡着底下的桌面和其他窗口操作),但鼠标移到它身上时又要能正常点击、能拖动。
这个需求描述起来平平无奇,Electron 官方文档里 setIgnoreMouseEvents 一个 API 好像就能搞定。真做起来才发现,中间连环踩了三个坑,每一个都不报错、看起来毫不相关,直到把因果链摸清才发现它们其实是同一类问题:Electron 的某些"便捷 API"背后悄悄把两件不同的事捆在了一起,只看文档发现不了,只有真正搞懂 Windows 窗口消息模型才能把它们拆开。
坑一:focusable: false 会让系统把你的点击吃掉
最初的实现很直觉:建窗时传 focusable: false,告诉 Electron “这扇窗不需要拿到焦点”。测试的时候立刻发现不对劲:鼠标悬停在窗口上有反应(能正常触发 hover 态),点击却完全没反应,拖拽更是纹丝不动。
第一反应是怀疑自己的事件绑定写错了,查了半天 pointer-events、查了半天点击处理函数的注册时机,一无所获。直到做了个笨办法:在按下和抬起两个事件里各打一条日志,连点 5 次窗口——日志显示 5 条 mouseup,0 条 mousedown。
这个证据很关键:mousemove 和 mouseup 都能正常送达,唯独 mousedown 整条消失。说明问题不在网页层的事件处理逻辑上,而在更上游——操作系统压根没把 mousedown 转发给这扇窗。
查下去才发现,focusable: false 在 Electron 内部同时做了两件事:一是把 Chromium 层的"可激活"标志关掉,二是这个标志会让 Windows 在收到 WM_MOUSEACTIVATE 消息时返回 MA_NOACTIVATEANDEAT——操作系统看到这扇窗不可激活,干脆把这次点击的 mousedown 整个吃掉,mousemove/mouseup 因为不涉及激活判定,照常放行。这也是为什么现象这么迷惑:悬停正常、点击"看着"没反应,跟网页层的 bug 表现几乎一模一样。
而实际需求只要"操作系统层不要抢焦点"这一半,不要"Chromium 层不可激活"那一半——因为后者恰恰是导致 mousedown 被吃的元凶。Electron 没有暴露单独控制这半件事的选项,只能自己用 FFI 直接调 Windows API,手动给窗口句柄打上 WS_EX_NOACTIVATE 这个扩展样式,同时保持 focusable: true:
constWS_EX_NOACTIVATE=0x0800_0000constGWL_EXSTYLE=-20functionapplyNoActivate(win,tag){if(process.platform!=='win32')returntrueconstkoffi=require('koffi')constuser32=koffi.load('user32.dll')constGetWindowLongW=user32.func('int32 GetWindowLongW(uint64 hWnd, int32 nIndex)')constSetWindowLongW=user32.func('int32 SetWindowLongW(uint64 hWnd, int32 nIndex, int32 dwNewLong)')constbuf=win.getNativeWindowHandle()consth=buf.length>=8?buf.readBigUInt64LE(0):BigInt(buf.readUInt32LE(0))constex=GetWindowLongW(h,GWL_EXSTYLE)if(!ex){// GetWindowLongW 失败时也返回 0,不拦这一下的话,0 | WS_EX_NOACTIVATE// 会把扩展样式整个写成只剩 NOACTIVATE——LAYERED(透明)、TOPMOST(置顶)、// TRANSPARENT(穿透)全部丢失,窗口直接被写坏console.warn(`[${tag}] 读窗口扩展样式失败,放弃设置`)returnfalse}if(ex&WS_EX_NOACTIVATE)returntrueSetWindowLongW(h,GWL_EXSTYLE,ex|WS_EX_NOACTIVATE)// 回读确认,SetWindowLongW 失败时不抛异常也不改任何东西,不回读会误以为生效了return(GetWindowLongW(h,GWL_EXSTYLE)&WS_EX_NOACTIVATE)!==0}这里有个很容易忽略的坑中坑:GetWindowLongW失败时返回的也是0,如果不判断这一点直接做按位或写回去,会把窗口原有的扩展样式整个覆盖掉,透明、置顶、穿透全部失效,表现为窗口突然变成一个不透明的、能被别的窗口盖住的普通方块——而且这个函数是挂在每次show上反复执行的,一次读取失败就能把窗口写坏。所以读到0必须当失败处理,绝不能拿它去做位运算。
坑二:hide() 再 show() 会让鼠标穿透的转发机制失效
解决坑一之后,窗口点得动了,但过了几天又收到反馈:从托盘把窗口重新显示出来之后,又变成了看得见但点不动。
这次因为已经知道 focusable 那条坑了,第一反应是怀疑 WS_EX_NOACTIVATE 又被什么操作重置了,加了日志追踪样式值——结果样式一直是对的,问题出在完全不同的地方。
穿透态下,渲染层判断"鼠标现在在不在窗口的可点区域上",唯一的信号来源是主进程通过 setIgnoreMouseEvents(true, { forward: true }) 转发过来的 mousemove 事件——forward: true 意味着即便窗口在穿透状态、鼠标事件本该直接穿过去,Electron 仍然会把 mousemove 转发给这扇窗自己的网页内容,让它能实时判断"要不要翻成可点态"。而 Windows 下,一次 hide() 再 show()(不管是锁屏进入安全桌面、还是主动从托盘隐藏再显示)会让这个转发悄悄失效,窗口从此再也收不到任何 mousemove,永远卡在穿透状态,鼠标怎么移都翻不成可点态。
修法是在 show 事件里补两件事,缺一不可:
functionrearmPetInput(){if(!win||win.isDestroyed())return// ① 重新武装转发:hide()→show() 会丢掉 forward,不重设就再也收不到 mousemovewin.setIgnoreMouseEvents(true,{forward:true})// ② 通知渲染层复位:主进程这边已经把窗口强设成穿透态,但渲染层自己维护的// ignoring 局部变量并不知道,如果它还停在「可点」,后续的纠正调用会被// 自己的「值没变就不发」这道门拦住,鼠标移到窗口上也翻不成可点态win.webContents.send('reset-hover')}win.on('show',rearmPetInput)第二步容易被漏掉,是因为渲染层通常会做一个防抖优化:只有状态真的变化了才去调底层 API,避免同一个状态反复调用。但这道优化恰好会在"主进程状态已经变了、渲染层还不知道"的场景里帮倒忙——渲染层记的还是旧值,新的正确调用因为"看起来没变化"被自己拦下了。
坑三:悬停判定不能靠系统原生的拖拽区
第三个坑出现在"让窗口可以被拖动"这个功能上。CSS 里有个很方便的属性 -webkit-app-region: drag,加在某个元素上,用户就能像拖动标题栏一样拖动整个窗口,很多教程都会这么写。加上之后发现:鼠标划过这块拖拽区域时,可点/穿透状态会疯狂闪烁。
原因是 Windows 下这类拖拽区是靠 HTCAPTION(“这里是标题栏”)这个命中测试结果实现的,属于非客户区,网页层根本收不到这块区域的 mousemove 事件。而一旦窗口从穿透态切回可点态、命中测试重新生效,光标位置在系统看来立刻从"客户区"变成了"标题栏",触发一次 mouseleave,代码逻辑一看鼠标离开了就切回穿透态;穿透态下命中测试又变化,光标位置又被系统重新判定,如此反复横跳,肉眼看就是状态一直在抖。
正解是不依赖这个原生属性:拖动窗口这件事完全交给应用层的 IPC 消息(渲染层算好位移量发给主进程,主进程调用 setBounds 移动窗口),悬停判定则用 window 级别的 mousemove 事件全量重算,配合 getBoundingClientRect() 做纯几何范围判断:
constoverRect=(el,x,y)=>{constr=el.getBoundingClientRect()returnx>=r.left&&x<=r.right&&y>=r.top&&y<=r.bottom}letignoring=truefunctionsetIgnore(b){if(b!==ignoring){ignoring=b bridge.setIgnore(b)// 通知主进程切换穿透态}}window.addEventListener('mousemove',(e)=>{constonTarget=overRect(hudEl,e.clientX,e.clientY)// 或其它任意可点元素的矩形判断setIgnore(!onTarget)})这样悬停判定完全在网页层的坐标系里算,不依赖系统对"客户区/非客户区"的划分,也就不会再被命中测试的切换打断。
心法
三个坑排下来会发现一个共同的模式:它们都不是网页层代码的 bug,而是 Electron 某个看起来很方便的 API,背后悄悄把两件不同层面的事情捆在了一起——focusable 同时管住了 Chromium 的激活语义和 Windows 的激活语义;-webkit-app-region: drag 同时处理了"可以拖动"和"这块区域收不到网页事件"。这类坑翻遍 Electron 官方文档往往找不到直接答案,因为文档描述的是 Chromium 那一层的行为,而实际卡住的地方在 Windows 消息模型这一层——真正定位到根因,靠的是像坑一那样做"连点 5 次数 mousedown"这种笨但有效的对照实验,把"网页层的事件确实没收到"和"事件被系统层拦截了"这两种可能性分开。
顺带一提,这类窗口还有第四个更隐蔽的坑——WS_EX_NOACTIVATE 拦住的只是操作系统层的激活判定,Chromium 自己内部的焦点状态照常记录,用户点过一次之后 isFocused() 会变成一个再也清不掉的 true。这个坑留到后面单独写。
这是我这几个月在用 AI(Claude Code)结对写一个桌面宠物应用"团子"的过程中,给它做悬浮挂件窗(右下角一个可以点开的常驻小组件)时踩到的三个坑,跟上一篇的 GPU 白屏问题算是同一个系列。仓库里现在是修好之后的版本。
系列第 1 篇:Electron 应用偶发白屏:一次"已经修好的问题突然复发"的排查记录