12周年記念パーティ開催! 2024/5/10(金) 19:00

addEventListener再入門 第1回 バブリングによるイベントの伝播

addEventListenerの第三引数useCaptureを利用して問題を解決した例から、イベントの仕組みを解説していきます。

発行

著者 高津戸 壮 テクニカルディレクター
addEventListener再入門 シリーズの記事一覧

はじめに

JavaScriptを書いていく上で必要な知識というのは、たくさんあります。それは例えば、JavaScriptの文法のルールであったり、ブラウザの挙動であったり、オブジェクト指向的な考え方であったり、jQueryやBackbone.jsなどの具体的なライブラリの使い方であったりします。

このシリーズでは、基礎と言えば基礎に分類されるけれど、普段何気なく書いているがゆえに、そこまで意識しなかった仕様や、仕組みを改めて見直すことでJavaScriptに対する理解をより深められるようなトピックを選び、解説していきます。

取り上げるテーマは、addEventListenerについてです。addEventListenerの第三引数useCaptureを利用し、問題を解決した例を紹介しながらイベントの仕組みを解説していきます。

なお、今回紹介するコードのサンプルは次のaddEventListenerサンプルリポジトリにまとめてあります。併せて参照してください。

addEventListenerサンプルリポジトリ

addEventListenerとは

まず、addEventListenerとはなんでしょう。これは基礎的な内容であるため、ここではサラッと解説するにとどめますが、addEventListenerとは、DOMの仕様で用意されている、イベントリスナーを登録するためのメソッドです。クリック、マウスオーバーなど、ブラウザ上ではさまざまなイベントが発生します。addEventListenerを使用すれば、これらのイベントが発生した時、指定したfunctionを実行することができます。このように、イベントの発生に合わせて実行させるfunctionのことを、イベントリスナーと呼びます。

addEventListenerを利用した、ごく単純なデモを見てみましょう。

HTML

<div id="foo">click me!</div>

JavaScript

var el = document.getElementById('foo');

el.addEventListener('click', function(e) {
  e.preventDefault();
  alert('foo clicked!');
  alert(e.pageX); // 120 とか
});

上記は、id属性がfooである要素(以降、#fooのように記述します)に、クリックイベントを設定しています。addEventListenerを利用してイベント名とイベントリスナーを指定すると、指定したイベントが発生した時、イベントリスナーの内容が実行されます。イベントリスナー内では、引数に渡されたイベントオブジェクトを介し、発生したイベントに関する種々の情報を得ることができます。

このデモでは、#fooがクリックされたら、まずは「foo clicked!」とアラートされ、その次に、クリックされた場所のページ上でのX座標が表示されます。

このようにイベントを設定できるaddEventListenerですが、大昔から問題なく利用可能だったわけではありません。このメソッドがInternet Explorerに実装されたのはバージョン9からであり、それ以前のバージョンのIEでは、attachEventという別のメソッドを利用し、ブラウザ上で起こるイベントを設定する必要がありました。そういった面倒な状況があったがゆえに、簡単にイベントを扱えるようにしてくれるjQueryなどのライブラリが便利に利用されてきました。

しかし、比較的新しい環境や、スマートフォンのブラウザを対象とする場合は、そのような旧IEを考慮する必要がありません。問題なくaddEventListenerを利用することができます。

また、addEventListenerをそのまま使う場合、第三引数のuseCaptureを指定することで、より細かなイベント制御を行うことができるというのも、大きな利点です。

el.addEventListener('click', fn, true);

このuseCaptureというものは、jQueryの.on()では対応していません。ですので、この機能が必要であれば、どのみちaddEventListenerをそのまま使う必要があります。いざというときにパッと使えるよう、addEventListenerとイベントの仕組みをおさらいしてみましょう。

今回のお題

addEventListenerの第三引数が便利なものであると述べましたが、正直なところ、これを使うケースはそれほど多くはないです。しかし、これを使わなければ解決できないケースというのは存在します。その場合、イベントの仕組みを正確に把握していないと四苦八苦するはずです。まずは、「素直に実装したら困ってしまった例」を見てみましょう。これを、今回解決するお題とします。

次のデモは、デスクトップ環境で動作します。このデモには2つの機能が同居しています。まずひとつは、カウンタ増加の機能。赤いdiv(#counter)をクリックすると、中に書かれた数字が+1されます。そしてもうひとつが、ドラッグ移動の機能。ピンクのdiv(#floater)は、ドラッグで好きな場所に移動することができます。

ざっとコードを見てみましょう。#floaterのドラッグ部分は、mousedownmousemovemouseupで実装し、#counterのカウント増加の部分は、単純なclickで実装しています。

HTML

<div id="floater">
  <div id="counter">0</div>
</div>

JavaScript

// 要素準備

var floater = document.getElementById('floater');
var counter = document.getElementById('counter');

// ドラッグ中かを記憶するフラグ

var whileDrag = false;

// クリックされたらカウント増加

counter.addEventListener('click', function() {
  var current = parseInt(counter.textContent, 10); // 中の数字
  counter.textContent = current + 1; // +1 して突っ込む
});

// mousedownでドラッグ開始

floater.addEventListener('mousedown', function() {
  whileDrag = true;
});

// mousemoveでマウス位置にfloaterを追従させる

document.addEventListener('mousemove', function(e) {
  if(!whileDrag) { return; }
  var x = e.pageX;
  var y = e.pageY;
  // floaterは絶対配置。left, topを更新してつまんだ位置へ移動。
  floater.style.left = (x - 30) + 'px';
  floater.style.top = (y - 30) + 'px';
});

// mouseupでドラッグ完了

floater.addEventListener('mouseup', function() {
  whileDrag = false;
});

このお題での問題点

一見問題なさそうなこのデモですが、よくよく操作してみると、困ったことがひとつあることに気付きます。それは、ただドラッグ移動しただけでも、カウンタ増加が必ず発生してしまうということです。ピンクの部分をつまんでドラッグすればこのようなことは起こりませんが、赤い部分をドラッグすると発生します。

#counter上でマウスのボタンを押し、そのまま動かし、ボタンを離すと、#counterがドラッグされたということになりますが、#floater#counterを内包しているため、同時に#floaterがドラッグされたことにもなります。つまり、#counter上でドラッグすれば、#coutnerでも#floaterでも「ドラッグされた」ことになります。

ここで「ドラッグ」と言っているのは、mousedownしてmousemoveし、最後にmouseupされる一連の動作のことです。では、同一の要素上でmousedownし、mouseupしたらどうなるでしょう? これは「クリック」と呼ばれる動作となります。#counterについて考えてみると、要素上でmousedownが発生し、次にmousemoveが発生しているものの、その後にまたmouseupが発生しています。ブラウザは、これを#counterでクリックイベントが発生したと捉えるようです。

つまり、#counter上でドラッグすれば、#counterは必ずクリックされたことにもなるというわけです。2つの機能をそれぞれ作っているだけなのですが、ドラッグ時にカウント増加をさせないためには、何かしらの工夫が必要になります。

コラム:クリックとは

当初、「クリックの定義って何だ? ドラッグしたらマウスの位置がずれてるんだから、クリックにならないんじゃないの?」と筆者は思いました。しかし、DOMのspecを見たところ、p要素上でマウスをドラッグしてテキストを選択するような動作が行われた場合、mousedownmouseupも、その要素上で起こったのであれば、ブラウザはクリックイベントを発生させるという旨の例が書かれていました。本稿の例も、ドラッグして要素が移動しているものの、mousedownmouseupも同一の#counter上で発生しているので、クリックが発生しているということになるようです。

イベントの伝播のしかたを理解する

前途した問題は、発生したイベントがどのように伝わっていくかという仕組みを細かく把握していれば、解決することができます。そのためにはまず、「バブリング」について理解する必要があります。至極単純なデモで、イベントの基本的な動作を確認してみましょう。

HTML

<div id="div1">
  #div1
  <div id="div2">
    #div2
    <div id="div3">#div3</div>
  </div>
</div>

JavaScript

var div1 = document.getElementById('div1');
var div2 = document.getElementById('div2');
var div3 = document.getElementById('div3');

div1.addEventListener('click', function() {
  alert('Hello! I am #div1.');
});
div2.addEventListener('click', function() {
  alert('Hello! I am #div2.');
});
div3.addEventListener('click', function() {
  alert('Hello! I am #div3.');
});

ここでは、#div1 &gt; #div2 &gt; #div3と、三重の入れ子になったdivにそれぞれクリックイベントを設定し、自身のidをアラートするようにしています。

#div1をクリックすれば「Hello! I am #div1.」とアラートされます。しかし、#div2#div3をクリックした場合は、自身に設定されたアラートが出るだけではありません。

#div2をクリックすれば

  • Hello! I am #div2.
  • Hello! I am #div1.

と2回、アラートされます。

#div3をクリックすれば

  • Hello! I am #div3.
  • Hello! I am #div2.
  • Hello! I am #div1.

と3回、アラートされます。

何かしらのイベントが発生し、そのイベントが複数の要素に関わる場合、ブラウザは、最も深い階層にある要素から順にイベントを評価します。ここで最も深い階層にあるのは#div3です。このため、#div3に設定したクリックのイベントリスナーが実行されました。そして次にこれを囲む#div2、その次は#div2を囲む#div1と、順々にイベントリスナーが実行されたのです。

このデモのように、下位階層の要素から順々に上位階層のイベントが評価されることを、バブリングと言います。泡のようにポコポコと、下から上に上がって実行されるので、そのような名前なのではないかと思われます。この概念については、過去にCodeGridでも取り上げたことがあるので参考にしてみてください。

このように入れ子になった要素において、イベントが順々に評価されることをイベントの伝播と言うことがあります。#div3がクリックされたあとに#div2#div1と、イベントが伝わっていくような動作になるためです。

イベントの伝播を止める

しかし、イベントの伝播をさせたくない場合もあります。今回の例では、#div3がクリックされたのであれば、#div2#div1がクリックされたことにはしないでほしいということです。

そのためにはイベントの伝播を止める必要があるので、イベントオブジェクトのstopPropagationというメソッドを実行します。デモを見てみましょう。このデモのHTMLは、先ほどのデモと全く同じで、異なるのはJavaScript内の、#div3に設定したイベントリスナーのみです。

JavaScript

var div1 = document.getElementById('div1');
var div2 = document.getElementById('div2');
var div3 = document.getElementById('div3');

div1.addEventListener('click', function() {
  alert('Hello! I am #div1.');
});
div2.addEventListener('click', function() {
  alert('Hello! I am #div2.');
});
div3.addEventListener('click', function(e) {
  alert('Hello! I am #div3.');
  e.stopPropagation(); // イベントの伝播を止める
});

このデモにおいて、#div1#div2をクリックした時の挙動はひとつ前のデモと同様ですが、#div3をクリックした場合はちょっと違います。「Hello! I am #div3.」とだけ表示されます。これは、#div3のクリックイベントリスナー内で、e.stopPropagation()が実行されているためです。e.stopPropagation()が実行された時点でイベントの伝播は止まるため、#div2#div1に設定されたイベントリスナーは実行されなくなります。これがイベントの伝播を止める方法です。

ここまでのまとめ

次回以降、「ボタンをクリックするとカウンタ増加するが、ドラッグ移動した場合にはカウンタ増加をさせたくない」というお題を、addEventListenerの第三引数を利用して解決していきます。具体的な解決方法の前に、まず今回は発生したイベントがどのように伝わっていくかという仕組みを把握するために、「バブリング」をおさらいしました。

次回は、addEventListenerの第三引数useCaptureの使い方を見ていきます。stopPropagationとuseCaptureを併用することで、イベントを柔軟に制御することができる様子を解説しましょう。今回のお題の問題解決の手がかりになるはずです。