12k
All articles

如何为 Web 应用添加键盘快捷键

如何为 Web 应用添加键盘快捷键:使用 document 级 keydown 监听、输入时跳过、兼容 Mac 和 Windows 修饰键、支持按键序列,并在 React 中正确清理。

OpenReplay Team
OpenReplay Team
如何为 Web 应用添加键盘快捷键

要为 Web 应用添加键盘快捷键,需要在 document 上挂载一个 keydown 监听器,基于 event.key 与各修饰键布尔值进行匹配,当事件目标为可编辑元素时跳过该事件,并在所属组件卸载时用同一个函数引用移除监听器。

第一个快捷键通常写起来很快。麻烦往往稍后才出现:有人在搜索框里输入 “k”,命令面板却弹了出来;又或者某位使用 Mac 的同事发现这个快捷键根本没反应。

本文从这个朴素的监听器出发,逐一修复各类故障:输入时误触发、Mac 与 Windows 的修饰键差异、双键序列、React 中的监听器泄漏,以及专门适用于快捷键的无障碍规范。绝大多数修复只需几行 TypeScript,可直接嵌入现有的处理函数。

要点速览

  • 挂载在 document 上的 keydown 监听器会接收页面上的每一次按键,因此当 event.targetinputtextareaselect 或任何 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)ctrlKeyCtrl
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,以免长按某键把缓冲区灌满;忽略仅修饰键的 keydownShiftControlMetaAltAltGraph),否则在组合键之前按下 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 为自定义文本界面提供了第二重检查。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.