JavaScript イベント処理の詳細

preventDefaultstopPropagation: 各メソッドをいつ、どのように使用するか。

Event.stopPropagation() と Event.preventDefault()

JavaScript イベントの処理は多くの場合単純明快です。これは、シンプルな(比較的フラットな)HTML 構造を扱う場合に特に当てはまります。ただし、イベントが要素の階層を通過する(伝播する)場合は、事態がより複雑になります。これは通常、デベロッパーが抱える問題を解決するために stopPropagation()preventDefault() にアクセスする場合に該当します。「preventDefault() を試してもうまくいかない場合は stopPropagation() を試し、うまくいかない場合は両方を試してみましょう」と思ったことがある方にはおすすめの記事です。各メソッドの具体的な内容と、どのメソッドをどのような場合に使用するのかを説明し、実際に利用できるさまざまな例を示します。私の目標は、お客様の混乱を決して解消することです。

詳細に入る前に、JavaScript で可能な 2 種類のイベント処理について簡単に説明しておきます(最新のブラウザでは、バージョン 9 より前の Internet Explorer ではイベントのキャプチャがまったくサポートされていませんでした)。

イベント スタイル(キャプチャとバブリング)

最新のブラウザはすべてイベントのキャプチャをサポートしていますが、デベロッパーが使用することはほとんどありません。興味深いことに、これは Netscape が元々サポートしていた唯一のイベント形式でした。Netscape の最大のライバルである Microsoft Internet Explorer は、イベントのキャプチャをまったくサポートしておらず、イベント バブリングと呼ばれる別のスタイルのイベントしかサポートしていませんでした。W3C が形成されたとき、両スタイルのイベント処理にメリットがあることが判明し、addEventListener メソッドの 3 番目のパラメータを介してブラウザで両方のイベントをサポートすることが宣言されました。元々、このパラメータは単なるブール値でしたが、最新のブラウザはすべて 3 番目のパラメータとして options オブジェクトをサポートしています。このオブジェクトを使用すると、イベント キャプチャを使用するかどうかを指定できます。

someElement.addEventListener('click', myClickHandler, { capture: true | false });

options オブジェクトと capture プロパティは省略可能です。いずれかを省略した場合、capture のデフォルト値は false であり、イベントのバブリングが使用されます。

イベントのキャプチャ

イベント ハンドラが「キャプチャ フェーズでリッスン」しているのはどういう意味ですか。これを理解するには イベントの発生元と 移動経路を把握する必要があります以下は、デベロッパーがそれを活用したり、重要視したり、考えたりしない場合でも、すべてのイベントに当てはまります。

すべてのイベントはウィンドウから始まり、まずキャプチャ フェーズを経ます。つまり、イベントがディスパッチされると、ウィンドウが開始し、ターゲット要素に向かって「下方向」に移動します。これは、流れの段階でのみ聞いている場合でも起こります。次のマークアップと JavaScript の例について考えてみましょう。

<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('#C was clicked');
  },
  true,
);

ユーザーが要素 #C をクリックすると、window で発生したイベントがディスパッチされます。このイベントは、次のように子孫で伝播されます。

window => document => <html> => <body> => というように、目標に到達するまでこれを繰り返します。

window 要素、document 要素、<html> 要素、<body> 要素(あるいは、ターゲットに到達する途中のその他の要素)でクリック イベントをリッスンしていないかどうかは関係ありません。イベントはまだ window から始まり、説明したようにその行程が始まります。

この例では、クリック イベントが window#C の間のすべての要素を経由して window からターゲット要素(この場合は #C)に伝播します(これは stopPropagation() メソッドの動作に直接関連するため、重要な用語です。このドキュメントの後半で説明します)。

つまり、クリック イベントは window で開始され、ブラウザは次のような質問をします。

「キャプチャ フェーズで、window でクリック イベントをリッスンしているものはありますか?」イベントがある場合は、適切なイベント ハンドラが起動します。この例では何も起こらないので、ハンドラは起動しません。

次に、イベントは document に伝播され、ブラウザは「キャプチャ フェーズで document でクリック イベントをリッスンしているものはありますか?」と尋ねます。イベントが発生すると、該当するイベント ハンドラが起動します。

次に、イベントは <html> 要素に伝播され、ブラウザは「キャプチャ フェーズで <html> 要素のクリックをリッスンしていますか?」と尋ねます。イベントが発生すると、該当するイベント ハンドラが起動します。

次に、イベントは <body> 要素に伝播され、ブラウザは「キャプチャ フェーズで <body> 要素でクリック イベントをリッスンしていますか?」と尋ねます。イベントが発生すると、該当するイベント ハンドラが起動します。

次に、イベントは #A 要素に伝播されます。ここでも、ブラウザから「キャプチャ フェーズで #A のクリック イベントをリッスンしていますか。リッスンしている場合は、適切なイベント ハンドラが起動されます。

次に、イベントが #B 要素に伝播されます(同じ質問が表示されます)。

最後に、イベントがターゲットに到達し、ブラウザから「キャプチャ フェーズで #C 要素のクリック イベントをリッスンしているものはありますか?」と尋ねられます。今回の正解は「イエス」です。イベントがターゲットに到達したこの短い時間を「ターゲット フェーズ」と呼びます。この時点でイベント ハンドラが起動し、ブラウザは console.log で「#C がクリックされました」と応答します。これで完了です。 不正解です。まだ終わりではありません。このプロセスは続きますが、現在はバブルフェーズに変化しています。

イベントのバブリング

ブラウザから次の質問が表示されます。

「バブルフェーズで #C のクリック イベントをリッスンしているものはありますか?」特に注意してください。 キャプチャ フェーズとバブリング フェーズの両方で、クリック(または任意のイベントタイプ)をリッスンできます。両方のフェーズでイベント ハンドラを構成している場合(たとえば、.addEventListener() を 2 回呼び出し、capture = true で 1 回、capture = false で 1 回呼び出す)、はい。両方のイベント ハンドラが同じ要素に対して必ず呼び出されます。ただし、異なるフェーズ(キャプチャ フェーズとバブリング フェーズ)で発動する点にも注意が必要です。

次に、イベントが親要素 #B に伝播(一般的には「バブル」と呼ばれます)し、ブラウザは「バブルフェーズの #B でクリック イベントをリッスンしているものはありますか?」と尋ねます。この例では何も起こらないので ハンドラは起動しません

次に、イベントが #A にバブルし、ブラウザから「バブルフェーズで #A でクリック イベントをリッスンしているものはありますか?」と尋ねます。

次に、イベントは <body> にバブルで実行されます。「バブルフェーズで、<body> 要素でクリック イベントをリッスンしているものはありますか?」

次に、<html> 要素: バブルフェーズで <html> 要素でクリック イベントをリッスンしているものはありますか?

次に、document: 「バブルフェーズで、document でクリック イベントをリッスンしているものはありますか?」

最後に、window: 「バブルフェーズで、ウィンドウでクリック イベントをリッスンしているものはありますか?」

これで長い道のりでした。私たちのイベントはかなり疲れていたと思いますが、信じられないかもしれませんが、それはすべてのイベントが通過する旅です。デベロッパーは通常、どちらか一方のイベント フェーズのみに関心があるため(通常はバブリング フェーズ)、これが気づくことはありません。

イベントのキャプチャやイベントのバブリングを試し、ハンドラが起動したときにコンソールにメモをロギングしてみてください。イベントの経路を確認することは非常に有益です。次に示すのは、両方のフェーズですべての要素をリッスンする例です。

<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in capturing phase');
  },
  true,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in capturing phase');
  },
  true,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in capturing phase');
  },
  true,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in capturing phase');
  },
  true,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in capturing phase');
  },
  true,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in capturing phase');
  },
  true,
);

document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in bubbling phase');
  },
  false,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in bubbling phase');
  },
  false,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in bubbling phase');
  },
  false,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in bubbling phase');
  },
  false,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in bubbling phase');
  },
  false,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in bubbling phase');
  },
  false,
);

コンソール出力は、クリックする要素によって異なります。DOM ツリーの「最も深い」要素(#C 要素)をクリックすると、これらのイベント ハンドラがそれぞれ起動されます。どの要素がどの要素かをわかりやすくするために、コンソール出力の #C 要素を次に示します(スクリーンショットも参照)。

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"
"click on <body> in bubbling phase"
"click on <html> in bubbling phase"
"click on document in bubbling phase"

以下のライブデモで、インタラクティブに試すことができます。#C 要素をクリックし、コンソール出力を確認します。

event.stopPropagation()

キャプチャ フェーズとバブリング フェーズの両方で、イベントの発生場所と、イベントがどのように DOM 内を移動(つまり伝播)するかを把握したら、次に event.stopPropagation() に注目してみましょう。

stopPropagation() メソッドは、(ほとんどの)ネイティブ DOM イベントで呼び出すことができます。このメソッドを呼び出しても何も実行されないものがいくつかあるため、「ほとんど」と呼んでいます(イベントが最初に伝播されないため)。focusblurloadscroll などのイベントがこのカテゴリに分類されます。stopPropagation() を呼び出すことはできますが、これらのイベントは伝播しないため、何も起こりません。

では、stopPropagation は何を行うのでしょうか。

そのとおりです。このメソッドを呼び出すと、イベントは、その時点から、移動すべきすべての要素への伝播を停止します。これは、両方の方向(キャプチャとバブリング)で当てはまります。そのため、キャプチャ フェーズの任意の場所で stopPropagation() を呼び出した場合、イベントはターゲット フェーズまたはバブル フェーズに到達しません。バブルフェーズで呼び出すと、キャプチャ フェーズはすでに完了していますが、呼び出した時点から「バブルアップ」は停止します。

先ほどのマークアップ例に戻り、キャプチャ フェーズで #B 要素で stopPropagation() を呼び出した場合、どうなるでしょうか。

次のような出力になります。

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"

以下のライブデモで、インタラクティブに試すことができます。ライブデモの #C 要素をクリックし、コンソール出力を確認します。

バブリング フェーズの #A で伝播を停止させるにはどうすればよいでしょうか。この場合、次のような出力になります。

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"

以下のライブデモで、インタラクティブに試すことができます。ライブデモの #C 要素をクリックし、コンソール出力を確認します。

楽しみながら、#Cターゲット フェーズstopPropagation() を呼び出すとどうなりますか?「ターゲット フェーズ」とは、イベントがターゲットに到達した期間に付けられた名前です。次のような出力になります。

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"

「キャプチャ フェーズの #C のクリック」をログに記録する #C のイベント ハンドラは引き続き実行されますが、「バブルフェーズで #C をクリック」をログに記録するイベント ハンドラは実行されません。これはまったく理にかなっているはずです。前者から stopPropagation() を呼び出したため、イベントの伝播は停止します。

以下のライブデモで、インタラクティブに試すことができます。ライブデモの #C 要素をクリックし、コンソール出力を確認します。

いずれのライブデモでも、実際に試してみることをおすすめします。#A 要素のみ、または body 要素のみをクリックしてみてください。何が起こるか予測し、それが正解かどうかを観察します。この時点で、かなり正確に予測できるはずです。

event.stopImmediatePropagation()

この奇妙であまり使われない方法は何ですか?stopPropagation と似ていますが、このメソッドは、イベントの子孫への移動(キャプチャ)や祖先への移動(バブリング)を停止するのではなく、複数のイベント ハンドラが 1 つの要素に接続されている場合にのみ適用されます。addEventListener() はマルチキャスト スタイルのイベントをサポートしているため、イベント ハンドラを 1 つの要素に複数回接続することは可能です。この場合、ほとんどのブラウザでは、イベント ハンドラは接続されている順序で実行されます。stopImmediatePropagation() を呼び出すと、後続のハンドラは呼び出されなくなります。たとえば次のようになります。

<html>
  <body>
    <div id="A">I am the #A element</div>
  </body>
</html>
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I shall run first!');
  },
  false,
);

document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I shall run second!');
    e.stopImmediatePropagation();
  },
  false,
);

document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I would have run third, if not for stopImmediatePropagation');
  },
  false,
);

上記の例では、次のコンソール出力が表示されます。

"When #A is clicked, I shall run first!"
"When #A is clicked, I shall run second!"

2 番目のイベント ハンドラが e.stopImmediatePropagation() を呼び出すため、3 番目のイベント ハンドラは実行されません。代わりに e.stopPropagation() を呼び出した場合、3 番目のハンドラは引き続き実行されます。

event.preventDefault()

stopPropagation() によってイベントが「下向き」(キャプチャ)または「上向き」(バブル)で移動できない場合、preventDefault() はどのように動作しますか。同様の動作のようです。どうでしょうか

そうでもありません。この 2 つはよく混同されますが、実は互いにあまり関係ありません。preventDefault() と表示されたら、頭に「action」という単語を追加します。「デフォルトアクションを防ぐ」ことを考えます

どのような操作をデフォルトにしますか?残念ながら、その答えは問題の要素とイベントの組み合わせに大きく依存するため、明確ではありません。また、デフォルトのアクションがまったくない場合、問題がさらに複雑になります。

理解するために、非常にシンプルな例から始めましょう。ウェブページのリンクを クリックしたときはどうなりますか?ブラウザはそのリンクで指定された URL に移動することを想定しているのは当然です。この場合、要素はアンカータグであり、イベントはクリック イベントです。この組み合わせ(<a>click)には、リンクの href に移動する「デフォルトのアクション」が設定されています。ブラウザがデフォルトのアクションを実行しないようにするにはどうすればよいでしょうか。つまり、<a> 要素の href 属性で指定された URL にブラウザが移動できないようにするとします。これが preventDefault() の役割です。次の例を考えてみましょう。

<a id="avett" href="https://www.theavettbrothers.com/welcome">The Avett Brothers</a>
document.getElementById('avett').addEventListener(
  'click',
  function (e) {
    e.preventDefault();
    console.log('Maybe we should just play some of their music right here instead?');
  },
  false,
);

以下のライブデモで、インタラクティブに試すことができます。The Avett Brothers のリンクをクリックして、コンソールの出力を確認します(また、Avett Brothers のウェブサイトにリダイレクトされないことを確認します)。

通常、「The Avett Brothers」というラベルのリンクをクリックすると、www.theavettbrothers.com が表示されます。この例では、クリック イベント ハンドラを <a> 要素に接続し、デフォルトのアクションが行われないように指定しています。したがって、ユーザーがこのリンクをクリックしても、どこにも移動されず、コンソールは単に「代わりに、音楽の一部をここで再生すべきか?」というログを記録します。

デフォルト アクションを回避するために、他にどのような要素とイベントの組み合わせを使用できますか?すべてをリストすることはできませんし、試してみることになる場合もあります。ここでは、その一部をご紹介します。

  • <form> 要素と「submit」イベント: preventDefault() この組み合わせでフォームが送信されなくなります。これは、検証を行うときになんらかのエラーが発生した場合、条件付きで PreventDefault を呼び出し、フォームの送信を停止する場合に便利です。

  • <a> 要素と「クリック」イベント: この組み合わせで preventDefault() が発生すると、ブラウザは <a> 要素の href 属性で指定された URL に移動できなくなります。

  • document + 「マウスホイール」イベント: この組み合わせに対する preventDefault() は、マウスホイールによるページ スクロールを防ぎます(ただし、キーボードによるスクロールは引き続き機能します)。
    ↜ これを行うには、{ passive: false }addEventListener() を呼び出す必要があります

  • document + キーダウン イベント: preventDefault() は致命的です。これにより、ページの大部分が無用になり、キーボードのスクロール、タブによる移動、キーボードによるハイライト表示ができなくなります。

  • document + 「マウスダウン」イベント: この組み合わせに preventDefault() を使用すると、マウスによるテキストのハイライト表示や、マウスダウン時に呼び出されるその他の「デフォルト」アクションが防止されます。

  • <input> 要素 + 「keypress」イベント: この組み合わせで preventDefault() が発生すると、ユーザーが入力した文字が入力要素に到達できなくなります(ただし、正当な理由があるとは限りません)。

  • document + 「contextmenu」イベント: この組み合わせで preventDefault() を使用すると、ユーザーが右クリックまたは長押し(またはコンテキスト メニューが表示されるその他の方法)を行ったときに、ネイティブ ブラウザのコンテキスト メニューが表示されなくなります。

このリストはすべてを網羅しているわけではありませんが、preventDefault() の使用方法について理解するのに役立ちます。

面白いおもしろいジョーク?

ドキュメントからキャプチャ フェーズで stopPropagation()preventDefault() を実行するとどうなるでしょうか。笑いが生じる次のコード スニペットを使用すると、どのウェブページもまったく役に立たなくなります。

function preventEverything(e) {
  e.preventDefault();
  e.stopPropagation();
  e.stopImmediatePropagation();
}

document.addEventListener('click', preventEverything, true);
document.addEventListener('keydown', preventEverything, true);
document.addEventListener('mousedown', preventEverything, true);
document.addEventListener('contextmenu', preventEverything, true);
document.addEventListener('mousewheel', preventEverything, { capture: true, passive: false });

どうしてこのようなことを行うのかはわかりませんが(誰かに冗談を言い合うためを除く)、ここで何が起こっているのかを考え、それがなぜそのような状況になるのかを理解することは役に立ちます。

すべてのイベントは window で発生するため、このスニペットでは、すべての clickkeydownmousedowncontextmenumousewheel イベントが、リッスンしている可能性のある要素に到達しないように停止しています。また、stopImmediatePropagation も呼び出して、この後にドキュメントに接続するハンドラも阻止されます。

stopPropagation()stopImmediatePropagation() は、ページを無意味にレンダリングするものではなく(少なくともほとんどの場合は)ありません。イベントが意図した場所に届かないようにするだけです。

ただし、preventDefault() も呼び出します。これはデフォルトのアクションを防止します。そのため、マウスホイールによるスクロール、キーボードでのスクロール、ハイライト表示またはタブ操作、リンクのクリック、コンテキスト メニューの表示など、デフォルトの操作はすべて阻止され、ページはかなり役に立たない状態のままになります。

ライブデモ

この記事の例をすべて 1 か所で確認するには、以下の埋め込みデモをご覧ください。

謝辞

ヒーロー画像(作成者: Tom WilsonUnsplash