Electron 桌面应用复制粘贴失效:一个被压缩器吃掉的 if 分支
症状
在 Hermes Desktop(一个 Electron 桌面 AI 助手)的聊天输入框里:
- ✅ 能选中文字
- ❌ Ctrl+C 复制无效,剪贴板为空
- ❌ 右键菜单的”复制”也不生效
修了一下午,CSS 没问题,事件监听器逻辑看起来也对,构建产物里关键代码还在——但就是不工作。
背景:三层架构
Hermes 的复制粘贴由三层机制共同控制,任何一层出问题都会导致失败:
第一层:CSS user-select
1 2 3 4 5 6 7 8 9 10 11 12 13
| body { user-select: none; }
input, textarea, [contenteditable]:not([contenteditable='false']), [data-slot='aui_user-message-root'], [data-slot='aui_assistant-message-content'] { user-select: text; }
|
这一层只管”能不能选中文字”。症状是能选中,所以这层没问题。
第二层:Copy Guard(复制守卫)
这是问题的核心。Hermes 有一个全局的 copy 事件拦截器,安装在 main.tsx:
1
| installSelectionCopyColorGuard()
|
它的目的是解决一个常见问题:深色主题下复制的文字,粘贴到浅色背景的编辑器里会变成白色——看不见。
守卫的工作流程:
1 2 3 4 5 6 7 8 9 10 11 12 13
| 用户按 Ctrl+C ↓ 捕获阶段 copy 事件触发 ↓ 检查:选区在可编辑区域内? → 是 → 跳过,让 Chromium 原生复制 ↓ 否 获取当前主题模式(深色/浅色) 获取选中文字的颜色亮度(luma) ↓ 判断:文字颜色和主题是"反色系"? (深色主题 + 浅色文字,或浅色主题 + 深色文字) ↓ 是 → 拦截复制,改写剪贴板内容(去掉内联颜色) ↓ 否 → 跳过,让 Chromium 原生复制
|
第三层:IPC 剪贴板
Electron 40+ 在 Windows 上移除了渲染进程的原生剪贴板模块。所以 clipboardData.setData() 会静默失败——不报错,但剪贴板是空的。
Hermes 的解决方案是通过 IPC 走主进程:
1 2 3 4 5 6 7
| writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text)
ipcMain.handle('hermes:writeClipboard', (_event, payload) => { clipboard.write({ text: payload.text, html: payload.html }) })
|
根因:被压缩器吃掉的 if 分支
守卫的源码逻辑是正确的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| const onCopy = (event: ClipboardEvent) => { const sel = document.getSelection() if (!sel || sel.isCollapsed) return
if (selectionStartsInEditable(sel)) return
const mode = renderedMode(document) const inkLuma = selectionInkLuma(sel, document)
if (inkLuma === null || !isOppositeScheme(inkLuma, mode)) { return }
const plain = sel.toString() const html = serializeSelectionStructure(sel, document) writeSelectionToClipboard(event, plain, html) }
|
但构建产物(Vite + esbuild 压缩后)变成了这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function nPt(e) { let t = (t) => { let n = e.getSelection() if (!n || n.isCollapsed || n.rangeCount === 0 || ePt(n)) return XNt(e) QNt(n, e) let r = n.toString() if (!r) return let i = $Nt(n, e) tPt(t, r, i) } e.addEventListener('copy', t, true) }
|
isOppositeScheme() 检查被完全删除了。
为什么会被删除?
JavaScript 压缩器(esbuild/terser)会做”死代码消除”(Dead Code Elimination)。它分析代码的数据流:
renderedMode() 的返回值赋给了 mode
selectionInkLuma() 的返回值赋给了 inkLuma
isOppositeScheme(inkLuma, mode) 的返回值只用于 if 条件判断
if 分支体里只有 return(无副作用)
- 压缩器认为:这个
if 没有可观测的副作用 → 可以删除
但压缩器错了。return 在这里是有副作用的——它阻止了后续的 writeSelectionToClipboard() 执行。压缩器没有追踪到 return 对后续代码流的影响。
这是一个经典的压缩器误优化 bug。
修复
方案一:给 if 分支加副作用
1 2 3 4 5
| if (inkLuma === null || !isOppositeScheme(inkLuma, mode)) { void (inkLuma, mode) return }
|
void 表达式在运行时是 no-op,但压缩器不能证明它没有副作用(因为 inkLuma 和 mode 可能是 getter),所以会保留整个 if 块。
方案二:用事件对象保证副作用
1 2 3 4 5
| if (inkLuma === null || !isOppositeScheme(inkLuma, mode)) { event.preventDefault() return }
|
不对,这样会阻止原生复制。更好的方式:
1 2
| const shouldTakeover = inkLuma !== null && isOppositeScheme(inkLuma, mode) if (!shouldTakeover) return
|
这样 shouldTakeover 的计算不会被消除,因为它直接影响后续的 writeSelectionToClipboard 调用。
方案三:禁用压缩器的死代码消除
在 Vite 配置中:
1 2 3 4 5 6 7 8 9
| build: { minify: 'terser', terserOptions: { compress: { dead_code: false, } } }
|
但这会增大包体积,不推荐。
教训
压缩器不是你的朋友。 它会为了优化而改变你的语义。关键逻辑要确保有可观测的副作用。
return 不是副作用。 压缩器可能认为 if (cond) { return } 是死代码,如果它认为 cond 的计算没有副作用。
构建后验证。 不要只看源码逻辑对不对,还要验证构建产物。用 grep 检查关键函数和常量是否还在。
Electron 40+ 的剪贴板坑。 渲染进程的 clipboardData.setData() 在 Windows 上静默失败。必须走 IPC。
三层架构要分层诊断。 CSS → 事件监听 → 剪贴板写入,逐层排查,不要在一层里死磕。
诊断清单
如果你遇到类似问题,按这个顺序排查:
1 2 3 4 5 6 7 8 9 10 11
| getComputedStyle(el).userSelect
window.__copyGuardLog
await window.hermesDesktop?.writeClipboard?.('test')
grep -c 'isOppositeScheme' dist/assets/index-*.js
|
这个问题的完整诊断过程和修复方案已记录为 Hermes Agent 的内部 skill:hermes-copy-paste-broken。