扩展的快捷键有十几个:填单、转单、提交、开侧栏、打开设置、四个快捷回复……
最早它们是各个模块自己监听 keydown。很快就出现了三个问题:
- 两个功能抢同一个键,谁先注册谁赢,而且没人知道;
- 改键要改代码;
- 焦点在编辑器的 iframe 里时,有的快捷键灵、有的不灵——取决于那个模块有没有想到 iframe。
后来收成了一个全局唯一的键位管理器。所有快捷键都经过它,按 action id 配置。
加一个快捷键的五步
1. 在类型定义里加一个 action id2. 在默认配置里给它一个默认键位(或留空 + 禁用)3. 在设置页的标签表里给它一个显示名4. 在启动代码里 register(id, handler)5. 升配置版本号 + 写迁移第 5 步要分情况:键位表是一个对象,新加的动作 deep-merge 能自动给老用户补上默认值;但如果你改了某个已有动作的默认键,老用户存储里存的还是旧键——这种情况必须写迁移,否则只有新用户拿到新键。配置迁移的细节在另一篇。
实现
/** * 全局唯一的键位监听器。所有快捷键都经过这里,按 action id 配置,可禁用、可改键。 */
export interface Binding { keys: string; // 如 'Alt+Q'、'Ctrl+Shift+K'、'Alt+`' enabled: boolean;}export type Bindings = Record<string, Binding>;
type Handler = (e: KeyboardEvent) => void | Promise<void>;
interface Parsed { alt: boolean; ctrl: boolean; shift: boolean; meta: boolean; key: string;}
export function parseKeys(spec: string): Parsed { const out: Parsed = { alt: false, ctrl: false, shift: false, meta: false, key: '' }; for (const raw of spec.split('+')) { const p = raw.trim().toLowerCase(); if (p === 'alt' || p === 'option') out.alt = true; else if (p === 'ctrl' || p === 'control') out.ctrl = true; else if (p === 'shift') out.shift = true; else if (p === 'meta' || p === 'cmd' || p === 'win') out.meta = true; else if (p) out.key = p; } return out;}
/** 归一化成「修饰键固定顺序 + 主键」,用于冲突检测:'q+alt' 与 'Alt+Q' 应视为同一个 */export function normalizeKeys(spec: string): string { const p = parseKeys(spec); return [p.ctrl && 'ctrl', p.alt && 'alt', p.shift && 'shift', p.meta && 'meta', p.key] .filter(Boolean).join('+');}
/** 主键匹配:先比 e.key,再用物理键位 e.code 兜底 */function keyMatches(e: KeyboardEvent, key: string): boolean { const k = (e.key || '').toLowerCase(); const code = e.code || ''; if (key === '`' || key === 'backquote') return code === 'Backquote' || k === '`' || k === '~'; if (key === '/' || key === 'slash') return code === 'Slash' || k === '/'; if (k === key) return true; // ★ Alt / Option 组合在某些平台和键盘布局下,e.key 会变成别的字符(如 Mac 上 Alt+Q → 'œ'), // 单字母和数字用物理键位兜底 if (/^[a-z]$/.test(key)) return code === `Key${key.toUpperCase()}`; if (/^[0-9]$/.test(key)) return code === `Digit${key}`; return false;}
export function matches(e: KeyboardEvent, spec: string): boolean { const p = parseKeys(spec); if (!p.key) return false; return p.alt === e.altKey && p.ctrl === e.ctrlKey && p.shift === e.shiftKey && p.meta === e.metaKey && keyMatches(e, p.key);}
export class KeybindingManager { private handlers = new Map<string, { handler: Handler; preventDefault: boolean }>(); private installed = false; private attachedDocs = new WeakSet<Document>(); /** ★ 同一个事件可能被多个监听点看到(见 attachIframes),只处理一次 */ private handled = new WeakSet<Event>();
constructor(private getBindings: () => Bindings) {}
register(id: string, handler: Handler, preventDefault = true): void { this.handlers.set(id, { handler, preventDefault }); }
/** 在「每一个 frame」里都调用:焦点可能落在任意 iframe 里 */ install(win: Window = window): void { if (this.installed) return; this.installed = true; win.addEventListener('keydown', (e) => this.dispatch(e), true); // 捕获阶段,先于宿主 this.attachIframes(win.document); }
/** * 焦点在 iframe 里时,keydown 不会冒泡到外层 window。 * 所以给每个同源 iframe 的 document 也挂一份(跨域的会抛 SecurityError,忽略)。 */ private attachIframes(doc: Document): void { const tryAttach = () => { doc.querySelectorAll('iframe').forEach((f) => { let d: Document | null = null; try { d = f.contentDocument; } catch { return; } if (!d || this.attachedDocs.has(d)) return; d.addEventListener('keydown', (e) => this.dispatch(e as KeyboardEvent), true); this.attachedDocs.add(d); }); }; tryAttach(); // iframe 是异步出现的;回调只做廉价检查 new MutationObserver(tryAttach).observe(doc.body ?? doc.documentElement, { childList: true, subtree: true, }); }
dispatch(e: KeyboardEvent): void { if (this.handled.has(e)) return; const bindings = this.getBindings(); for (const [id, reg] of this.handlers) { const b = bindings[id]; if (!b?.enabled || !matches(e, b.keys)) continue; this.handled.add(e); if (reg.preventDefault) { e.preventDefault(); e.stopPropagation(); } void reg.handler(e); return; // 一个按键只触发一个动作 } }
/** 给设置页用:找出被多个动作占用的组合键 */ static findConflicts(bindings: Bindings): Array<[string, string[]]> { const bucket = new Map<string, string[]>(); for (const [id, b] of Object.entries(bindings)) { if (!b.enabled || !b.keys.trim()) continue; const k = normalizeKeys(b.keys); bucket.set(k, [...(bucket.get(k) ?? []), id]); } return [...bucket.entries()].filter(([, ids]) => ids.length > 1); }}用法:
const km = new KeybindingManager(() => configClient.get().keybindings);km.register('ticket.fillCustomer', () => runFillCustomer());km.register('ui.toggleDashboard', () => toggleDashboard());km.install(); // 在每一个 frame 里都调用几个设计点
每次按键现读配置
dispatch 里每次都调 getBindings(),而不是在 install 时缓存一份。
这样用户在设置页改了键、保存,下一次按键就生效,不需要刷新页面、不需要重新注册。按键频率很低,现读的开销可以忽略。
捕获阶段
监听挂在捕获阶段(addEventListener(..., true))。这样扩展的快捷键先于宿主页面拿到事件——宿主自己也有快捷键,而在冒泡阶段监听的话,宿主可能已经把它吃掉了。
配合 preventDefault + stopPropagation:扩展认领了这个组合键,宿主就不会再看到它。所以默认键位要避开宿主常用的组合。
必须在每个 frame 里都装
这一条和很多「只在顶层帧跑」的东西正好相反,而且两者经常被混在一起:
会跑流程的面板要收敛到顶层帧;键位监听必须每个 frame 都装。
原因是:焦点在 iframe 里时,keydown 不会冒泡到外层 window。 用户正在编辑器里打字,按下快捷键——只有那个 iframe 自己的监听器看得到。
曾经有人为了省事把键位注册也放进了 isTopFrame() 守卫里,结果得到一个「有时候好使有时候不好使」的快捷键:取决于用户按键那一刻焦点在哪。(多 frame 的完整代价见 all_frames 的代价。)
同源 iframe 再挂一份,然后按事件去重
有些 iframe 里内容脚本进不去(比如某些没有 src 的编辑器 iframe),所以外层还要给每个同源 iframe 的 document 额外挂一份监听。
这就带来一个问题:同一个按键事件可能被两个监听点看到——iframe 自己那份实例,和外层挂进来的那份。
在 preventDefault = true 的时候这不是问题:捕获阶段先到 iframe 的 window,那里 stopPropagation 了,事件就到不了 document 上外层挂的那个监听器。
但只要有一个动作注册时设了 preventDefault = false,它就会被触发两次。所以加了一层按事件去重:
private handled = new WeakSet<Event>();
dispatch(e: KeyboardEvent): void { if (this.handled.has(e)) return; // …匹配到之后 this.handled.add(e);}WeakSet 按事件对象去重,事件被回收后自动清掉,不会泄漏。
物理键位兜底
在 Mac 上,Alt+Q 产生的 e.key 是 œ,不是 q。一些非美式键盘布局下也会有类似情况。
所以单字母和数字在 e.key 对不上时,用 e.code(物理键位,如 KeyQ、Digit1)兜底。反引号和斜杠这种布局相关的符号也单独处理。
冲突检测要先归一化
设置页会提示「这个组合键被多个动作占用」。最初是按字符串比较的——于是 Alt+Q 和 q + alt 被当成两个不同的组合,冲突查不出来。
现在先解析、再按固定的修饰键顺序拼回去,再比较。
可以被禁用,且禁用的状态要真的生效
每个动作都有 enabled。有一个历史遗留的动作,它的键位留空、enabled: false,只为兼容而保留在配置里。
这里踩过一个认知上的坑:有人以为「进页面自动填单」这个功能是由那个键位的开关控制的——实际上它由另一个独立的配置项控制,那个键位的开关从来没被读过。
一个配置项如果没有任何代码在读它,它就不该出现在设置页上。 拨了没有任何效果的开关,比没有这个开关更糟。
收成一个监听器之后,最大的收益其实不是代码更整洁,而是每个问题都有了唯一的地方去查:
- 这个键为什么没反应?看
dispatch; - 这个键被谁占了?看冲突检测;
- 这个键在 iframe 里为什么不灵?看
attachIframes。
以前这些问题的答案散在十几个模块里,每个模块对「焦点在 iframe 里」的处理都不一样。