页面快捷键监听不冲突:五条守则

给页面加自定义快捷键(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 === "?") { ... }

或者监听几乎没人用的组合:F8Ctrl+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——五条守则遵守,你的快捷键就不会踩别人也不会被别人踩。