Electron 桌面应用复制粘贴失效:一个被压缩器吃掉的 if 分支

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 全局禁用选择 */
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
// preload.ts
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text)

// main.ts
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

// 1. 可编辑区域 → 跳过
if (selectionStartsInEditable(sel)) return

// 2. 获取主题和颜色亮度
const mode = renderedMode(document)
const inkLuma = selectionInkLuma(sel, document)

// 3. 关键判断:同色系 → 跳过
if (inkLuma === null || !isOppositeScheme(inkLuma, mode)) {
return // ← 让 Chromium 原生复制接管
}

// 4. 反色系 → 拦截,改写剪贴板
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) // renderedMode() — 返回值被丢弃!
QNt(n, e) // selectionInkLuma() — 返回值被丢弃!

let r = n.toString()
if (!r) return
let i = $Nt(n, e) // serializeSelectionStructure()
tPt(t, r, i) // writeSelectionToClipboard() — 总是执行!
}
e.addEventListener('copy', t, true)
}

isOppositeScheme() 检查被完全删除了。

为什么会被删除?

JavaScript 压缩器(esbuild/terser)会做”死代码消除”(Dead Code Elimination)。它分析代码的数据流:

  1. renderedMode() 的返回值赋给了 mode
  2. selectionInkLuma() 的返回值赋给了 inkLuma
  3. isOppositeScheme(inkLuma, mode) 的返回值只用于 if 条件判断
  4. if 分支体里只有 return(无副作用)
  5. 压缩器认为:这个 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
// vite.config.ts
build: {
minify: 'terser',
terserOptions: {
compress: {
dead_code: false, // 禁用死代码消除
}
}
}

但这会增大包体积,不推荐。

教训

  1. 压缩器不是你的朋友。 它会为了优化而改变你的语义。关键逻辑要确保有可观测的副作用。

  2. return 不是副作用。 压缩器可能认为 if (cond) { return } 是死代码,如果它认为 cond 的计算没有副作用。

  3. 构建后验证。 不要只看源码逻辑对不对,还要验证构建产物。用 grep 检查关键函数和常量是否还在。

  4. Electron 40+ 的剪贴板坑。 渲染进程的 clipboardData.setData() 在 Windows 上静默失败。必须走 IPC。

  5. 三层架构要分层诊断。 CSS → 事件监听 → 剪贴板写入,逐层排查,不要在一层里死磕。

诊断清单

如果你遇到类似问题,按这个顺序排查:

1
2
3
4
5
6
7
8
9
10
11
# 1. 检查 CSS
getComputedStyle(el).userSelect # 应该是 'text'

# 2. 检查守卫是否跳过
window.__copyGuardLog # 看有没有 'editable-skip' 或 'same-scheme'

# 3. 检查 IPC 桥
await window.hermesDesktop?.writeClipboard?.('test') # 应该返回 true

# 4. 检查构建产物
grep -c 'isOppositeScheme' dist/assets/index-*.js # 应该 > 0

这个问题的完整诊断过程和修复方案已记录为 Hermes Agent 的内部 skill:hermes-copy-paste-broken。


Electron 桌面应用复制粘贴失效:一个被压缩器吃掉的 if 分支
https://genui.art/2026/10/02/2026-10-02-electron-copy-paste-bug/
作者
快乐
发布于
2026年10月2日
许可协议