JavaScript イベント処理の詳細

preventDefaultstopPropagation: 各メソッドを使用するタイミングと具体的な内容

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)に伝播されます(この用語は stopPropagation() メソッドの仕組みに直接関連するため重要であり、このドキュメントで後述します)。この伝播は、window#C の間にあるすべての要素を経由します。

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

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

次に、イベントは document伝播し、ブラウザは「キャプチャ フェーズで document でクリック イベントをリッスンしていますか?」と尋ねます。一致する場合は、適切なイベント ハンドラが呼び出されます。

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

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

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

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

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

イベントの bubbling

ブラウザに次のように尋ねられます。

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

次に、イベントは親要素 #B伝播し(イベントが DOM ツリーを「上方」進んでいるかのように見えるため、通常は「バブル」と呼ばれます)、ブラウザは「バブリング フェーズで、#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 要素)をクリックすると、これらのイベント ハンドラがすべて呼び出されます。どの要素がどれであるかを明確にするために、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"

下のライブデモでインタラクティブに試してみることができます。#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 要素をクリックし、コンソール出力を確認します。

最後にもう 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"

#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() は何を行いますか?似たような動作をしているようです。ありますか?

そうでもありません。これらは混同されがちですが、実際にはほとんど関係ありません。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 のリンクをクリックして、コンソールの出力(と Avett Brothers のウェブサイトにリダイレクトされないこと)を確認します。

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

デフォルトのアクションを防ぐことができる要素とイベントの組み合わせは他にどのようなものがありますか?すべてを網羅することはできず、実際に試すことが必要になる場合もあります。以下に、いくつかを簡単に紹介します。

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

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

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

  • document +「キーダウン」イベント: 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() も呼び出します。preventDefault() は、デフォルトのアクションを防ぐことを思い出してください。そのため、マウスホイールのスクロール、キーボードのスクロールやハイライト表示やタブ、リンクのクリック、コンテキスト メニューの表示などのデフォルトのアクションがすべて阻止され、ページはあまり役に立たない状態のままになります。

ライブデモ

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

謝辞

Tom Wilson 氏による Unsplash のヒーロー画像