如何为 Web 应用添加键盘快捷键
如何为 Web 应用添加键盘快捷键:使用 document 级 keydown 监听、输入时跳过、兼容 Mac 和 Windows 修饰键、支持按键序列,并在 React 中正确清理。
要为 Web 应用添加键盘快捷键,需要在 document 上挂载一个 keydown 监听器,基于 event.key 与各修饰键布尔值进行匹配,当事件目标为可编辑元素时跳过该事件,并在所属组件卸载时用同一个函数引用移除监听器。
第一个快捷键通常写起来很快。麻烦往往稍后才出现:有人在搜索框里输入 “k”,命令面板却弹了出来;又或者某位使用 Mac 的同事发现这个快捷键根本没反应。
本文从这个朴素的监听器出发,逐一修复各类故障:输入时误触发、Mac 与 Windows 的修饰键差异、双键序列、React 中的监听器泄漏,以及专门适用于快捷键的无障碍规范。绝大多数修复只需几行 TypeScript,可直接嵌入现有的处理函数。
要点速览
- 挂载在
document上的keydown监听器会接收页面上的每一次按键,因此当event.target是input、textarea、select或任何isContentEditable为 true 的元素时,处理函数必须提前返回。 - 只检测
event.ctrlKey的快捷键在 Mac 上永远不会触发,因为 Command 键设置的是event.metaKey;应检测event.metaKey || event.ctrlKey,让一个绑定同时覆盖两个平台。 - 匹配逻辑无需做平台检测;只在展示时解析平台,这也是 MDN 为
navigator.platform记录的唯一用途。 - 像先按
g再按i这样的序列,需要一个缓冲区、一个过期超时、遇到任何非前缀键时重置,以及把该键作为新序列的起点重新检查一遍。 - 在 React 中,在
useEffect里注册监听器,在清理函数中移除同一个引用;如果处理函数读取了 props 或 state,则用useCallback做记忆化。
朴素的 JavaScript 键盘快捷键监听器
最简单可用的快捷键就是一个 keydown 监听器,把 event.key 与某个字符做比较,匹配上就调用 preventDefault()。请使用 event.key,绝不要用已废弃的 keyCode。
document.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.key.toLowerCase() === 'k') {
event.preventDefault();
openCommandPalette();
}
});
把 event.key 转成小写可以让匹配在 Caps Lock 和 Shift 情况下依然生效。但这个监听器的其他一切,都是等着坑用户的 bug。
如何避免用户输入时触发快捷键?
快捷键处理函数在做任何事之前都必须检查 event.target,因为文档级监听器同样会接收到用户在搜索框中敲入的按键。常见的过滤方式是检查三个标签名,而这种思维模型正是漏洞所在:contenteditable 区域保留自己的标签(通常是 div),因此富文本编辑器能通过这项检查,快捷键就在用户打字打到一半时触发了。带全局快捷键的应用,其会话回放里呈现的正是这一幕:用户正在字段里输入,页面却因为某个恰好被绑定的字母而跳走了。
改用 isContentEditable。对于任何用户可编辑的元素它都为 true,包括那些从祖先元素继承可编辑状态的元素:
function isTyping(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
|| target.isContentEditable;
}
document.addEventListener('keydown', (event) => {
if (isTyping(event.target)) return;
// matching below
});
在处理函数的最开头就返回,这样后续的一切逻辑——包括后文会讲到的序列缓冲区——都不会看到用户输入的按键。
如何处理 Mac 和 Windows 的修饰键?
只检测 event.ctrlKey 的快捷键在 Mac 上是死的,因为 Command 键设置的是 event.metaKey。匹配时应接受任一修饰键:
const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === 'k') { /* ... */ }
这样会稍微过度匹配(Ctrl+K 在 Mac 上也能用),但无伤大雅。它避免的是在匹配路径中做平台检测。navigator.platform 在文档中被明确标注为不可靠的检测手段,MDN 认可的唯一用途是:在向用户展示快捷键时,在 ⌘ 和 Ctrl 之间做选择。就让它只干这件事:
| 物理按键 | 事件属性 | 显示 |
|---|---|---|
| Command(macOS) | metaKey | ⌘ |
| Control(Windows/Linux) | ctrlKey | Ctrl |
| Windows 键 | metaKey | 不要绑定 |
const isMac = navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone';
const formatKeys = (keys: string[]) =>
keys.map((k) => (k === 'mod' ? (isMac ? '⌘' : 'Ctrl') : k)).join(isMac ? '' : '+');
如何支持像先 g 后 i 这样的按键序列?
双键序列需要一个缓冲区、一个用于清空缓冲区的超时、当缓冲区不再是有效前缀时的重置,以及把这个”越界”按键作为新序列起点的重新检查。如果直接丢弃该键,用户就得按两次。还有两条规则:忽略 event.repeat 为 true 的 keydown,以免长按某键把缓冲区灌满;忽略仅修饰键的 keydown(Shift、Control、Meta、Alt、AltGraph),否则在组合键之前按下 Shift 会取消正在进行的序列。
| 缓冲区 | 压入按键后的结果 | 动作 |
|---|---|---|
| 任意 | 等于某个绑定 | 执行它,清空缓冲区 |
| 任意 | 是某个绑定的前缀 | 保留缓冲区,重启超时 |
| 长度 > 1 | 什么都不匹配 | 清空缓冲区,将该键单独重新喂入 |
| 长度为 1 | 什么都不匹配 | 清空缓冲区 |
| 任意 | 超时触发 | 清空缓冲区 |
type Binding = { keys: string[]; description: string; run: () => void };
const bindings: Binding[] = [
{ keys: ['g', 'i'], description: 'Go to inbox', run: () => navigate('/inbox') },
{ keys: ['g', 'p'], description: 'Go to projects', run: () => navigate('/projects') },
];
const MODIFIERS = new Set(['Control', 'Meta', 'Shift', 'Alt', 'AltGraph']);
let buffer: string[] = [];
let timer: ReturnType<typeof setTimeout> | undefined;
function reset() { buffer = []; clearTimeout(timer); }
function feed(key: string) {
buffer.push(key);
const exact = bindings.find(
(b) => b.keys.length === buffer.length && b.keys.every((k, i) => k === buffer[i]),
);
if (exact) { exact.run(); reset(); return; }
if (bindings.some((b) => buffer.every((k, i) => b.keys[i] === k))) {
clearTimeout(timer);
timer = setTimeout(reset, 800);
return;
}
const retry = buffer.length > 1;
reset();
if (retry) feed(key);
}
document.addEventListener('keydown', (event) => {
if (isTyping(event.target) || event.repeat || MODIFIERS.has(event.key)) return;
if (event.metaKey || event.ctrlKey || event.altKey) return; // chords go elsewhere
feed(event.key.toLowerCase());
});
800 毫秒的窗口是一种选择,而非实测结论;几百毫秒是比较典型的取值。
在 React 中如何注册和清理快捷键监听器?
在 React 中,在 useEffect 内部添加监听器,并在其清理函数中移除同一个函数引用;如果处理函数读取了 props 或 state,就用 useCallback 做记忆化,并把它列入 effect 的依赖数组。没有清理逻辑的话,每次重新挂载都会叠加一个监听器,一次按键就会执行两遍动作。
function useShortcuts(bindings: Binding[]) {
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (isTyping(event.target)) return;
// match against bindings here
}, [bindings]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
}
注意这个 hook 中的 bindings 依赖。如果调用方直接内联传入一个数组字面量,那它在每次渲染时都是一个新数组,于是 handleKeyDown 的标识发生变化,effect 每次都会移除并重新添加监听器。虽然不会出错,但这种反复折腾纯属浪费。应把数组声明在模块级别,或在调用组件中用 useMemo 包裹它。
自 React 18 起,严格模式会在开发环境中让每个 Effect 额外经历一轮 setup 与 teardown,因此如果清理函数移除的引用与添加时不是同一个,处理函数被重复注册的问题会立刻暴露出来。在 React 之外规则完全相同:一次 addEventListener,对应一次 removeEventListener,同一个函数。
让快捷键保持无障碍且易于发现
有三条规则专门适用于快捷键。不要绑定浏览器保留的组合键,包括 Cmd/Ctrl+W、Cmd/Ctrl+N、Cmd/Ctrl+T 和 Tab,也不要对 Cmd/Ctrl+C 这类原生编辑组合键调用 preventDefault()。绝不要让快捷键成为访问某项功能的唯一途径;同一动作必须存在对应的菜单项或按钮。对于单字符绑定,WCAG 2.1 SC 2.1.4 字符键快捷键(A 级)要求满足以下三者之一:提供关闭该快捷键的方式、提供重新绑定的方式使其包含 Ctrl 或 Alt 之类的按键,或者把作用范围收窄到只在其所属组件获得焦点时才触发。
为提升可发现性,把 ? 绑定到一个帮助对话框,让它渲染匹配器所使用的同一个 bindings 数组。匹配时用 event.key === '?',而不是 Shift 加斜杠键,这样在 ? 位于不同物理按键的键盘布局上也能生效。
if (event.key === '?' && !isTyping(event.target)) {
event.preventDefault();
helpDialog.showModal();
}
// inside the dialog
{bindings.map((b) => (
<li key={b.keys.join(' ')}><kbd>{formatKeys(b.keys)}</kbd> {b.description}</li>
))}
在原生 <dialog> 上调用 showModal() 可免费获得 Escape 键处理能力;至于其内部的焦点管理,可参阅模态框常见无障碍问题一文。
什么时候该引入快捷键库?
一旦绑定超过寥寥数个,作用域划分、冲突检测和序列处理就值得交给库来做了。TanStack Hotkeys 是一个选择:绑定中的 Mod 键在 Mac 上解析为 Command,在其他平台解析为 Control,而且指向已获焦点的输入元素的按键会被自动跳过。不过其概览页面仍将该库标注为 alpha 阶段,并警告 API 可能变更,因此请锁定版本,并做好频繁调整的准备。
结语
快捷键出问题的地方是可预测的:事件目标、修饰键、序列缓冲区,以及监听器生命周期。先在你现有的处理函数中加上 isTyping 守卫和 metaKey || ctrlKey 匹配,然后把所有绑定收拢到一个数组里,让匹配器和 ? 帮助对话框读取同一份数据源。
常见问题
键盘快捷键中 event.key 和 event.code 有什么区别?
event.key 给出的是在考虑键盘布局和已按下修饰键之后该键所产生的字符,而 event.code 标识的是物理按键位置,无论布局如何都保持不变。快捷键应基于 event.key 匹配,这样在任何键盘上,'k' 绑定都对应键帽上印着的那个字母。event.code 则留给基于位置的输入,例如游戏中的 WASD。TanStack Hotkeys 仅对字母和数字键回退到 event.code,且仅在 event.key 返回特殊字符时才这么做——macOS 上 Option 加字母就是这种情况。
键盘快捷键应该用 keydown、keyup 还是 keypress?
用 keydown。MDN 已将 keypress 标记为废弃,而且它只在产生字符的按键上触发,因此永远不会上报 Escape、方向键或单独按下的修饰键。keydown 对每个按键都会触发,暴露 event.key 和各修饰键布尔值,也是 preventDefault 能够阻止浏览器自身行为的那个事件。keyup 在浏览器已经对 keydown 作出响应之后才到达,因此无法阻止原生快捷键或已插入的字符。
用户使用日文或中文等输入法(IME)输入时,键盘快捷键会触发吗?
会。文档级 keydown 监听器在 IME 组字期间依然会接收到按键,因此当 event.isComposing 为 true 时应提前返回。从 IME 开启一次组字会话到关闭这次会话之间的每一个按键事件,该标志都保持为 true,而这整段窗口正是你的快捷键应该退避的时段。isTyping 守卫能覆盖大多数情况,因为组字发生在可编辑元素中,但 isComposing 为自定义文本界面提供了第二重检查。