JavaScript イベント処理の詳細

preventDefaultstopPropagation: どちらを使用するか、各メソッドが具体的に何を行うか。

JavaScript のイベント処理は、多くの場合簡単です。これは、単純な(比較的フラットな)HTML 構造を扱う場合に特に当てはまります。イベントが要素の階層を移動(伝播)する場合は、状況が少し複雑になります。通常、これは、デベロッパーが stopPropagation()preventDefault() を使用して発生している問題を解決しようとするときに発生します。「preventDefault() を試して、それでもうまくいかなければ stopPropagation() を試して、それでもうまくいかなければ両方を試す」と考えている方には、この記事がおすすめです。各メソッドの機能と、どのメソッドをどのタイミングで使用するかについて詳しく説明します。また、さまざまなサンプルコードも用意していますので、ぜひご確認ください。お客様の混乱を完全に解消することが私の目標です。

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

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

最新のブラウザはすべてイベント キャプチャをサポートしていますが、デベロッパーが使用する機会はほとんどありません。興味深いことに、これは Netscape が最初にサポートした唯一のイベント形式でした。Netscape の最大のライバルである Microsoft Internet Explorer は、イベント キャプチャをまったくサポートしていませんでした。サポートしていたのは、イベント バブルリングと呼ばれる別のスタイルのイベントのみでした。W3C が設立されたとき、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 回呼び出し、1 回は capture = true で、もう 1 回は capture = false で呼び出した場合)、両方のイベント ハンドラが同じ要素に対して確実に呼び出されます。ただし、これらは異なるフェーズ(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 + 「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() も呼び出します。preventDefault() は、デフォルトのアクションを防ぐことを思い出してください。そのため、デフォルトのアクション(マウスホイールのスクロール、キーボードのスクロール、ハイライト、タブ移動、リンクのクリック、コンテキスト メニューの表示など)はすべてブロックされ、ページはほとんど使用できなくなります。

ライブデモ

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

謝辞

UnsplashTom Wilson によるヒーロー画像。