フロントエンド

`preventDefault` がグローバルな `keydown` リスナーを停止させない理由

2つの機能が同じショートカットにバインドされ、両方が発火しました。`preventDefault`はデフォルトのアクションをキャンセルするものであり、他のリスナーをキャンセルするものではありません。これが、その切り替え後も有効な修正です。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

同じエディター内の2つの機能が Cmd/Ctrl+Enter にバインドされていました。一方はコメントを投稿し、もう一方はクレジットを消費して既存の結果を置き換える高コストな再生成ジョブを開始しました。このキーを押すと、両方のアクションが実行されました。コメントボックスはすでに preventDefault() を呼び出していましたが、何も変わりませんでした。なぜなら、preventDefault はこの問題に対処するためのツールではなかったからです。

このバグは再現が容易で、誤診もしやすいものです。また、ほとんどのチームが見逃す後半部分があります。ショートカットを移動させると衝突は解消されますが、最初の1週間は状況を悪化させる可能性があります。

共有ショートカットが実際にどのようなものか

その2つの機能は、異なる問題に注目していた人々によって、数ヶ月の間隔をあけて、異なるディレクトリに書かれました。

エディタにはグローバルショートカットがありました。それは 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でテキストボックスを送信するという、Webの一般的な慣習に従っていました:

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

どちらのスニペットも、単体で見れば妥当です。どちらのファイルも、もう一方のファイルに言及していません。パネルはエディターのショートカットテーブルと照合されることはありませんでした。なぜなら、パネルは自身をエディターの一部だとは考えていなかったからです。

その結果、レビュー担当者がセグメントをクリックし、コメントボックスにメモを入力して Cmd+Enter を押すと、メモが保存されると同時に、そのセグメントがバックグラウンドで再生成されてしまいます。意見を記録するためのアクションが、レビュー対象のものを上書きしてしまったのです。

なぜpreventDefaultは他のリスナーを停止しないのか

コメントボックスはpreventDefault()を呼び出します。それでも優先されません。なぜなら、DOMイベントの3つのメソッドは、それぞれ異なる3つのことを行うからです。

メソッド 停止するもの 停止しないもの
preventDefault() ブラウザーのデフォルトアクション (送信、スクロール、改行の挿入) どのノード上のどのリスナーでも
stopPropagation() 伝播経路上にある祖先要素のリスナー 同じノード上の他のリスナー
stopImmediatePropagation() まだ呼び出されていないすべてのリスナー (同じノードの兄弟リスナーを含む) デフォルトアクション

preventDefaultはフラグを設定します。イベントは伝播を続け、伝播経路上のすべてのリスナーが実行されます。event.defaultPreventedを読み取らないリスナーは、誰かが反対したことに気づきません。

なぜ1回のキー入力で2つのハンドラーが実行されたのか 1 バブリング 2 ブロックされず 3 期待通り 4 意図しない コメントボックス Reactルート Window コメント保存済み ジョブ再実行 // preventDefaultはフラグを設定するだけで、他のリスナーを停止させません

Reactには、知っておくべき厄介な点があります。React 17以降、合成イベントはdocumentではなく、アプリのルートコンテナにアタッチされます。onKeyDownプロパティは、textarea自体に付けられたリスナーではありません。これは、Reactが独自のツリーでイベントを再生する間に実行されますが、その時点ではネイティブイベントはすでにルートにディスパッチ済みです。windowリスナーはそのルートの上位にあるため、どちらにせよイベントを受け取ります。

これが、コンポーネント内でstopPropagation()を追加するという安易な修正が、脆弱である理由です。これが機能することがあるのは、Reactが内部でネイティブのstopPropagationを呼び出し、パスにおいてルートがwindowの下位に位置するためです。しかし、これは使用しているReactのバージョンがどこにリスナーをアタッチするかに依存しますし、ライブラリによって後から追加されたものを含め、本来意図していた他の祖先リスナーを暗黙のうちに壊してしまいます。伝播のブロックは、自分より上位のすべてを対象とする、大雑把な手段です。

キーの変更は修正の半分でしかない

明白な解決策は、2つのショートカットのうちの1つを移動することです。私たちの場合、コメントボックスはShift+Enterに移動しました。これはバグを報告した人が求めていたものです。

これにより衝突はなくなります。しかし、損害はなくなりません。そして、ここが見落としやすい部分です。古い習慣が指に染みついている人にとっては、最初の1週間は改善されるどころか、悪化します。

変更前は、コメントボックスでCmd+Enterを押すと2つのことが起こり、そのうちの1つはユーザーが望んだものでした。変更後、同じキー操作では何も投稿されず、それでもコストの高いジョブが開始されてしまいます。ユーザーはメモを失い、求めてもいない再生成のコストを支払うことになります。あなたは、紛らわしい結果を、純粋に破壊的な結果と交換したのです。

したがって、ショートカットの移動には、それに付随する対応が必要です。グローバルハンドラーは、コメントボックスから来たキーストロークを無視しなければなりません。

ホワイトリストマーカーは包括的な入力チェックに勝る

最初に思いつくのは、グローバルハンドラーにすべてのテキスト入力をスキップさせることです:

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()

この仕組みを成り立たせているのは、3つの詳細です。

  • closest()はノード自体から検索を開始するため、textareaに属性を置くだけで十分です。ラッパーは必要ありません。
  • ガードはpreventDefault()の前に実行する必要があります。先にデフォルトのアクションをキャンセルしてしまうと、ハンドラーをスキップすると決めたにもかかわらず、コメントボックスでEnterキーが改行を挿入しなくなります。
  • オプショナルな呼び出し?.が重要です。 フォーカスがdocument上にある場合や、イベントターゲットが要素でない場合、closestは存在せず、通常の呼び出しではグローバルリスナー内でエラーがスローされます。

ここでは、チェックがコンポーネントから離れた場所で行われるため、属性セレクターの方がコンポーネントレベルのフラグよりも優れています。グローバルハンドラーはコメントパネル内のReactのstateにアクセスできませんが、イベントが発生したDOMノードにはアクセスできます。

同じ理屈がデフォルトの方向性にも当てはまります。ホワイトリストはフェイルセーフです。マークし忘れたフィールドは、以前とまったく同じように動作し続けます。ブラックリスト(「セグメントエディター以外はすべてスキップする」)は逆の方向に失敗し、考えもしなかった機能を壊してしまいます。

自分のアプリで確認すべきこと

グローバルなキーボードリスナーがある場合は、このリストを一度確認してください:

  1. ショートカットを棚卸しする。 addEventListener('keydown' とフレームワークのキーハンドラーを別々に grep します。衝突はこれら2つのリストの間に存在します。なぜなら、それぞれを作成する人々はめったに他方を読まないからです。
  2. バインディングごとのハンドラーを数える。 各組み合わせについて、今日実行されるコードパスがいくつあるかを確認します。1つより多い場合、それは習慣を持つユーザーを待つバグです。
  3. 明白なものだけでなく、すべての入力バリアントを確認する。 私たちのコメントパネルには、新しいコメント、コメントの編集、新しい返信、返信の編集という4つのテキストフィールドがありました。報告書では最初のものしか言及されていませんでした。4つのうち1つを修正すると、単一パネル内の動作が分かれてしまいます。
  4. どちら側が衝突の責任を負うかを決定する。 金銭がかかる、またはデータを変更する機能が譲るべきです。なぜなら、キーストロークの失敗は、望まないジョブよりも安価だからです。
  5. 最終状態だけでなく、遷移を検証する。 古いショートカットを押し、結果が無害であることを確認します。これは、あなたのテスト計画が見逃すであろうケースです。

代わりに stopImmediatePropagation を使うべきか?

パス上のすべてのリスナーを所有しており、そのどれも実行したくない場合にのみ使用します。これは同じノードの兄弟要素も停止させるため、通常は意図した以上のものになります。グローバルハンドラー内のターゲットを絞ったガードの方が、1年後に推論するのが簡単です。なぜなら、例外はそれが変更する動作の隣に存在するからです。

なぜグローバルハンドラーに event.defaultPrevented をチェックさせないのか?

ローカルハンドラーが常にデフォルトをキャンセルする場合、それは機能し、1行の変更で済みます。しかし、それはより弱い契約でもあります。正当な理由で preventDefault をスキップするローカルハンドラーは、静かにグローバルアクションを再度有効にしてしまいます。マーカー属性は、「この領域はグローバルショートカットに参加しない」というあなたの意図を伝えます。

これは React 以外にも適用されるか?

はい。合成イベントシステムはローカルハンドラーが実行される場所を変更しますが、結果は変わりません。同じパス上の2つのリスナーは、プレーンな JavaScript、Vue、または Svelte の両方で実行されます。React の詳細は、コンポーネント内の stopPropagation が時々機能するように見え、時々機能しない理由を説明するだけです。

ショートカットライブラリが本当の答えか?

中央レジストリを持つライブラリは、この種のバグを防ぎます。なぜなら、同じバインディングを2回登録すると、登録時にそれが可視化されるからです。これは、多くのショートカットを持つアプリにとっては良い方向性です。しかし、ガードよりも大きな変更であり、すべての機能がそれを経由しない限り役立ちません。そして、それこそが最初に失敗した仮定そのものです。

覚えておくべき一般的な教訓は小さいものです。preventDefault はブラウザと対話するものであり、あなたの他のコードと対話するものではありません。2つの機能がキーを共有する必要がある場合、一方に他方について伝える必要があり、それを伝える最も安全な方法は、イベントが伴う DOM ノードにマークを付けることです。