前端

为什么 preventDefault 无法阻止全局 keydown 监听器

两个功能绑定了相同的快捷键,并且都触发了。`preventDefault` 会取消默认操作,而不是其他监听器。此修复方案在切换后依然有效。

本文由 AI 模型从英文原文翻译而来,措辞可能与原文有出入。 阅读英文原文

同一编辑器中的两个功能都绑定了 Cmd/Ctrl+Enter。一个用于发布评论,另一个则启动一个消耗积分并替换现有结果的昂贵重新生成任务。按下该快捷键会同时执行这两个操作。评论框已经调用了 preventDefault(),但这毫无作用,因为 preventDefault 从来就不是解决这个问题的工具。

这个 bug 很容易复现,也很容易被误诊。它还有大多数团队会忽略的另一半问题:移动快捷键可以解决冲突,但可能会让第一周的情况变得更糟。

共享快捷键实际上是什么样子

这两个功能由关注不同问题的人员编写,编写时间相隔数月,位于不同的目录中。

编辑器有一个全局快捷键。它监听 window,检查是否按下了 Enter 键外加一个修饰键,并为获得焦点的片段启动重新生成任务:

useEffect(() => {
  const onKeyDown = (e) => {
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
      e.preventDefault()
      if (focusedId) startExpensiveJob(focusedId)
    }
  }
  window.addEventListener('keydown', onKeyDown)
  return () => window.removeEventListener('keydown', onKeyDown)
}, [focusedId])

后来,我们添加了一个作为独立组件的评论面板。它遵循了常见的网页惯例,即使用 Cmd/Ctrl+Enter 提交文本框:

<textarea
  onKeyDown={(e) => {
    if (e.nativeEvent.isComposing) return
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
      e.preventDefault()
      submit()
    }
  }}
/>

单独来看,两个代码片段都是合理的。两个文件都没有提及对方。面板从未与编辑器的快捷键表进行核对,因为该面板不认为自己是编辑器的一部分。

结果就是:审校者点击一个句段,在评论框中输入备注,按下 Cmd+Enter,然后备注被保存,而句段同时被静默地重新生成。这个本意是记录意见的操作,也覆盖了正在审阅的内容。

为什么 preventDefault 无法阻止其他监听器

评论框调用了 preventDefault()。但它仍然没有“获胜”,因为 DOM 事件上的三个方法各自的作用不同:

方法 它能阻止什么 它不能阻止什么
preventDefault() 浏览器的默认行为(提交、滚动、插入换行符) 任何节点上的任何监听器
stopPropagation() 路径上更远的祖先节点上的监听器 同一节点上的其他监听器
stopImmediatePropagation() 所有尚未被调用的监听器,包括同一节点上的同级监听器 默认行为

preventDefault 会设置一个标志。事件会继续传播,路径上的每个监听器仍然会运行。如果一个监听器从不读取 event.defaultPrevented,它就不会注意到有其他监听器提出了“反对”。

为何一次按键会运行两个处理函数 1 冒泡 2 未被阻止 3 期望的 4 非期望的 评论框 React 根节点 Window 评论已保存 任务重新运行 // preventDefault 会设置一个标志,但不会停止其他监听器

React 带来了一个值得了解的细节。自 React 17 起,合成事件被附加到应用的根容器上,而不是 document 上。你的 onKeyDown prop 并不是 textarea 本身上的监听器。它在 React 通过其自身的树重放事件时运行,而那时原生事件早已被分派到根节点。window 监听器位于该根节点之上,因此无论如何它都能听到该事件。

这就是为什么在组件内部添加 stopPropagation() 这种看似简单的修复方法是脆弱的。它可能有效,因为 React 在底层会调用原生的 stopPropagation,并且在路径中根节点位于 window 之下。但这取决于你所用的 React 版本将其监听器附加到了哪里,并且它会静默地破坏任何你确实想要保留的其他祖先监听器,包括那些由库后续添加的监听器。阻止传播是一种针对你上方所有元素的简单粗暴的工具。

更改快捷键只是修复了一半

显而易见的补救措施是移动两个快捷键中的一个。在我们的例子中,评论框的快捷键移到了 Shift+Enter,这正是报告此 bug 的人所要求的。

这消除了冲突,但没有消除损害。这里有一个容易被忽略的部分:对于任何手指已经形成旧习惯的人来说,第一周的情况会变得更糟,而不是更好。

在更改之前,在评论框中按下 Cmd+Enter 会做两件事,其中一件是你想要的。更改之后,同样的按键操作不会发布任何内容,却仍然会启动那个耗费资源的任务。用户会丢失他们的笔记,并为他们并未请求的重新生成付出代价。你用一个纯粹破坏性的结果换掉了一个令人困惑的结果。

因此,移动快捷键的操作需要一个配套措施:全局处理程序必须忽略来自评论框的按键。

白名单标记优于一揽子的输入检查

第一反应是让全局处理程序跳过所有文本输入:

const tag = e.target.tagName
if (tag === 'TEXTAREA' || tag === 'INPUT') return

不要在这里这样做。在我们的编辑器中,该快捷键的主要用途是:在某个句段中编辑文本时按下 Cmd+Enter,处理程序会使该字段失焦以刷新编辑,然后用新内容重新生成。一刀切的输入检查会为了修复次要冲突而扼杀主要功能。

有效的规则是使用白名单。标记出应抑制全局快捷键的特定字段,并让其他所有内容保持其现有行为:

<textarea data-comment-input onKeyDown={...} />
const target = e.target
if (target?.closest?.('[data-comment-input]')) return
e.preventDefault()

有三个细节使这个方案得以成立:

  • closest() 从节点自身开始查找,因此只需将属性放在 textarea 上即可。你不需要包装元素。
  • 守卫条件必须在 preventDefault() 之前运行。如果你先取消了默认行为,那么即便你决定跳过处理程序,回车键也无法在评论框中插入新行。
  • **可选调用 ?. 很重要。**当焦点在 document 上或事件目标不是元素时,closest 方法不存在,此时强制调用会在全局监听器内部抛出异常。

在这里,属性选择器优于组件级别的标志,因为检查发生在远离组件的地方。全局处理程序无法访问评论面板中的 React 状态,但它可以访问事件来源的 DOM 节点。

同样的道理也适用于默认行为的方向。白名单是故障安全的:任何你忘记标记的字段都会像以前一样正常工作。而黑名单(“跳过除片段编辑器之外的所有内容”)则会以相反的方式出问题,破坏你从未考虑过的功能。

在你自己的应用中要检查什么

如果你有任何全局键盘监听器,请按此列表检查一遍:

  1. 清点快捷键。 分别使用 Grep 搜索 addEventListener('keydown' 和框架的按键处理程序。冲突就存在于这两个列表之间的空白地带,因为编写各自代码的人很少会去看对方的列表。
  2. 计算每个绑定的处理程序数量。 对于每种组合,问问自己今天有多少代码路径会运行。超过一个就是个潜在的 bug,只等着有习惯的用户来触发。
  3. 检查每种输入变体,而不仅仅是显而易见的那一种。 我们的评论面板有四个文本字段:新评论、编辑评论、新回复、编辑回复。报告只提到了第一个。修复四个中的一个会导致单个面板内的行为分裂。
  4. 决定哪一方拥有冲突。 涉及金钱或改变数据的功能应该是让步的一方,因为一次按键失误比一个不想要的任务代价更小。
  5. 验证过渡过程,而不仅仅是最终状态。 按下旧的快捷键,确认结果是无害的。这是你的测试计划会忽略的情况。

我应该改用 stopImmediatePropagation 吗?

只有当你拥有路径上的每个监听器并且希望它们全都不运行时才应该用。它也会停止同一节点上的兄弟元素,这通常超出了你的本意。在全局处理程序中使用有针对性的防护措施一年后更容易理解,因为异常情况与其修改的行为紧挨着。

为什么不让全局处理程序检查 event.defaultPrevented?

当本地处理程序总是取消默认行为时,这种方法是可行的,而且只需修改一行代码。但它也是一个更弱的契约:任何出于正当理由跳过 preventDefault 的本地处理程序都会悄悄地重新启用全局操作。而标记属性则能表明你的意图,即“此区域不参与全局快捷键”。

这适用于 React 之外的场景吗?

是的。合成事件系统改变了本地处理程序的运行位置,但没有改变结果。在原生 JavaScript、Vue 或 Svelte 中,同一路径上的两个监听器都会运行。React 的细节只是解释了为什么在组件内部使用 stopPropagation 有时看起来有效,有时却无效。

快捷键库是真正的答案吗?

一个带有中央注册表的库确实能防止这类 bug,因为在注册时,重复注册相同的绑定会变得可见。对于有许多快捷键的应用来说,这是一个很好的方向。但它的改动比防护措施要大,而且除非每个功能都通过它来路由,否则它也无济于事,而这恰恰是最初失败的那个假设。

可以记住的通用教训很简单:preventDefault 是与浏览器对话,而不是与你的其他代码对话。当两个功能需要共享一个按键时,必须告知其中一个关于另一个的存在,而最安全的告知方式是在事件携带的 DOM 节点上做一个标记。