扩展的快捷键有十几个:填单、转单、提交、开侧栏、打开设置、四个快捷回复……

最早它们是各个模块自己监听 keydown。很快就出现了三个问题:

  • 两个功能抢同一个键,谁先注册谁赢,而且没人知道;
  • 改键要改代码;
  • 焦点在编辑器的 iframe 里时,有的快捷键灵、有的不灵——取决于那个模块有没有想到 iframe。

后来收成了一个全局唯一的键位管理器。所有快捷键都经过它,按 action id 配置。

加一个快捷键的五步

1. 在类型定义里加一个 action id
2. 在默认配置里给它一个默认键位(或留空 + 禁用)
3. 在设置页的标签表里给它一个显示名
4. 在启动代码里 register(id, handler)
5. 升配置版本号 + 写迁移

第 5 步要分情况:键位表是一个对象,新加的动作 deep-merge 能自动给老用户补上默认值;但如果你改了某个已有动作的默认键,老用户存储里存的还是旧键——这种情况必须写迁移,否则只有新用户拿到新键。配置迁移的细节在另一篇。

实现

src/shared/keybinding-manager.ts
/**
* 全局唯一的键位监听器。所有快捷键都经过这里,按 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 里」的处理都不一样。