给页面加自定义快捷键(Ctrl+K 打开搜索、Alt+1 切换 tab 之类),最容易踩的坑是和别的监听打架——把系统快捷键、编辑器热键、别的组件 hotkey 全干掉。列几条实用守则。
守则一:用 addEventListener,别赋值
错的写法:
document.onkeydown = handler;
这会覆盖之前的所有 keydown 监听——包括别的组件、别的库、浏览器扩展设置的。
正确写法:
window.addEventListener("keydown", handler, { passive: false });
// 组件卸载时移除
window.removeEventListener("keydown", handler);
addEventListener 是往队列里追加,多个监听会一起执行。
守则二:判断 defaultPrevented
Monaco Editor、CodeMirror、富文本组件、浏览器本身、系统快捷键——很多东西会调 e.preventDefault()。尊重它们:
const handler = e => {
if (e.defaultPrevented) return; // 别人已经处理了
// 你的逻辑
};
不做这个判断,用户在 CodeMirror 里按 Ctrl+S 会既触发编辑器保存、又触发你的东西,行为不确定。
守则三:输入框里不触发
const t = e.target;
const isEditable =
t instanceof HTMLInputElement ||
t instanceof HTMLTextAreaElement ||
t?.isContentEditable;
if (isEditable) return;
打字时的每个键都会走 keydown,你的 按 K 打开面板 会毁掉用户所有 K 字母的输入。
守则四:少用 stopPropagation
看到这种写法要警觉:
e.stopPropagation();
stopPropagation 阻止事件冒泡到父级,等于把父组件、全局快捷键全干死。
除非你明确需要(比如自己写一个 modal,希望里面的键盘操作不冒到外面),否则别用。
守则五:单字母触发要慎重
if (e.key === "s") { ... } // 危险
s 太常见了——浏览器 Ctrl+S 保存、编辑器保存、任何输入框都会用到。要监听单字母,几乎必须搭配 modifier:
if (e.ctrlKey && e.key.toLowerCase() === "k") { ... }
if (e.altKey && e.key === "1") { ... }
if (e.shiftKey && e.key === "?") { ... }
或者监听几乎没人用的组合:F8、Ctrl+Shift+P、?(在没输入框时)。
Vue3 封装
import { onMounted, onUnmounted } from "vue";
type KeyPredicate = (e: KeyboardEvent) => boolean;
export function useHotkey(predicate: KeyPredicate, callback: (e: KeyboardEvent) => void) {
const handler = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const t = e.target as Element | null;
if (t instanceof HTMLInputElement ||
t instanceof HTMLTextAreaElement ||
(t as HTMLElement)?.isContentEditable) return;
if (predicate(e)) {
e.preventDefault();
callback(e);
}
};
onMounted(() => window.addEventListener("keydown", handler, { passive: false }));
onUnmounted(() => window.removeEventListener("keydown", handler));
}
// 用法
useHotkey(
e => e.ctrlKey && e.key.toLowerCase() === "k",
() => openSearch(),
);
React 版
import { useEffect } from "react";
export function useHotkey(
predicate: (e: KeyboardEvent) => boolean,
callback: (e: KeyboardEvent) => void,
) {
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const t = e.target as HTMLElement;
if (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable) return;
if (predicate(e)) {
e.preventDefault();
callback(e);
}
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [predicate, callback]);
}
passive: false 是什么
现代浏览器为了滚动性能,某些事件默认变成 passive——你在里面调 e.preventDefault() 会报警告并被忽略。keydown 不受影响,但显式写 passive: false 是好习惯,声明”我可能会 preventDefault”:
window.addEventListener("keydown", h, { passive: false });
对 keydown 来说是无用功但无害。对 touchstart / wheel 就必须写了。
特殊环境的坑
- Electron:主进程
globalShortcut会拦截全局键,注意别覆盖用户系统的 - iframe:跨域 iframe 里的 keydown 拿不到,需要
postMessage转发 - Monaco / CodeMirror:编辑器有自己的键映射系统,用它们提供的 API 注册,别在外面挂 keydown
- 浏览器扩展 content script:可能碰到页面已经拦截了某些键,要么
capture: true抢先,要么改键位
一句话总结
addEventListener 追加、检查 defaultPrevented、跳过输入框、避免 stopPropagation、单字母必须搭 modifier——五条守则遵守,你的快捷键就不会踩别人也不会被别人踩。
