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 から window#C の間のすべての要素を介してターゲット要素(この例では #C)に伝播します(これは stopPropagation() メソッドの動作に直接関係する重要な単語であり、このドキュメントで後述します)。

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

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

次に、イベントは document伝播し、ブラウザは「キャプチャ フェーズで document のクリック イベントをリッスンしているものがあるか?」と尋ねます。その場合、適切なイベント ハンドラが起動します。

次に、イベントは <html> 要素に伝播し、ブラウザは「キャプチャ フェーズで <html> 要素のクリックをリッスンしているものがあるか?」と尋ねます。その場合、適切なイベント ハンドラが起動します。

次に、イベントは <body> 要素に伝播し、ブラウザは「キャプチャ フェーズで <body> 要素のクリック イベントをリッスンしているものがあるか?」と尋ねます。その場合、適切なイベント ハンドラが起動します。

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

次に、イベントは #B 要素に伝播します(同じ質問が再度行われます)。

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

イベント バブリング

ブラウザに次のメッセージが表示されます。

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

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

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

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

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

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

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

ぜひ長い道のりでしたが、イベントは今頃疲れているかもしれません。しかし、信じられないかもしれませんが、すべてのイベントがこの道のりをたどります。ほとんどの場合、デベロッパーは通常、イベントの 1 つのフェーズ(通常はバブリング フェーズ)にしか関心がないため、このことに気づくことはありません。

イベントのキャプチャとイベントのバブリングを試して、ハンドラが起動したときにコンソールにメモを記録してみることをおすすめします。イベントがたどるパスを確認することは、非常に有益です。両方のフェーズのすべての要素をリッスンする例を次に示します。

<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 要素)をクリックすると、これらのイベント ハンドラがすべて起動します。CSS スタイルを少し追加して、どの要素がどれであるかをわかりやすくしたコンソール出力 #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"

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"

バブリング フェーズの #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"

もう 1 つ、おまけです。#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"

「click on #C in the capturing phase」をログに記録する #C のイベント ハンドラはまだ実行されますが、「click on #C in the bubbling phase」をログに記録するイベント ハンドラは実行されません。これは理にかなっています。前の例では stopPropagation()from で呼び出したため、イベントの伝播はこの時点で停止します。

これらのライブデモでは、ぜひいろいろ試してみてください。#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() が表示されたら、頭の中で「アクション」という単語を追加します。「デフォルトのアクションを防止する」と考えてください。

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

まず、非常に簡単な例から見ていきましょう。ウェブページのリンクをクリックするとどうなると思いますか?当然、ブラウザはそのリンクで指定された 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] というラベルのリンクをクリックすると、www.theavettbrothers.com に移動します。ただし、この例では、クリック イベント ハンドラを <a> 要素に接続し、デフォルトのアクションを防止するように指定しています。そのため、ユーザーがこのリンクをクリックしても、どこにも移動せず、コンソールに「Maybe we should just play some of their music right here instead?」と記録されるだけです。

デフォルトのアクションを防止できる要素とイベントの組み合わせは他にありますか?すべてをリストすることはできません。実際に試してみる必要がある場合もあります。以下に簡単に説明します。

  • <form> 要素 + 「submit」イベント: この組み合わせの preventDefault() は、フォームの送信を防止します。これは、検証を実行し、失敗した場合は preventDefault を条件付きで呼び出してフォームの送信を停止する場合に便利です。

  • <a> 要素 + 「click」イベント: この組み合わせの preventDefault() は、ブラウザが <a> 要素の href 属性で指定された URL に移動するのを防ぎます。

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

  • document + keydown イベント: この組み合わせの preventDefault() は致命的です。これにより、ページがほぼ使用不能になり、キーボード スクロール、タブ移動、キーボード ハイライト表示ができなくなります。

  • document + 「mousedown」イベント: この組み合わせの 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() も呼び出しています。これは、デフォルトのアクションを防止するものです。そのため、デフォルトのアクション(マウスホイールのスクロール、キーボードのスクロール、ハイライト表示、タブ移動、リンクのクリック、コンテキスト メニューの表示など)はすべて禁止され、ページはかなり役に立たない状態になります。

謝辞

ヒーロー画像: Tom WilsonUnsplash