iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

イベント伝播の仕組みとイベントバブリングの有効活用方法について

初めに


こんにちは、今回で2回目のブログ更新となります。

株式会社iimonでエンジニアをしている林です。

普段は、弊社プロダクトの拡張アプリの開発をしています。

本日はイベント伝播の仕組みについて解説していきたいと思います。

イベント伝播とは

ウェブブラウザ内でイベントが発生し、それが要素から要素へと伝播するプロセスを指します。

例えばユーザーがマウスをクリックしたりキーボードを押したりするなどのイベントが発生時、イベント伝播のプロセスを通じて、DOMツリー上の要素に伝えられます。

イベント伝播は、次の3つのフェーズで行われます。

  1. キャプチャリング(Capturing)フェーズ
    • イベントオブジェクトが、Windowからターゲットの親に伝播します
  2. ターゲット(Target)フェーズ:
    • イベントオブジェクトは、イベントが発生した要素に到達します。このフェーズでは、イベントが要素に処理されます。
  3. バブリング(Bubbling)フェーズ:
    • イベントは、ターゲット要素から親要素、さらにはDOMツリーのルート要素へと逆向きに伝播します。

      イベントバブリング(Event Bubbling)とは

イベント伝播のバブリングフェイズの事を言います。

特定の要素で発生したイベントがその要素の親要素、

さらに上位の親要素に伝播していく動作をします。

この伝播の様子が、水面に泡が上がっていく様子に似ていることから、

「イベントバブリング」と呼ばれるようになりました。

このフェイズを利用してEvent発火を効率化する事が多い為、ぜひ覚えて頂ければと思います。

実際に動きを見てみましょう。

ディレクトリの作成と移動

mkdir ivent-bubbling && cd $_

ファイルの作成

touch {bubbling.html,bubbling.js}

[bubbling.html]

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .form-content {
      margin: 10px;
      border: 1px solid blue;
    }

    li {
      cursor: pointer;
    }
  </style>
</head>
<body>
  <div>
    <form class="form-content" id="myForm">FORM
      <div class="form-content" id="myDiv">DIV
        <p class="form-content" id="myP">P</p>
        <p class="form-content" id="myP2-stopPropagation">P2 stopPropagation</p>
        <p class="form-content" id="myP3-stopPropagation">P3 stopPropagation</p>
        <p class="form-content" id="myP4-stopImmediatePropagation">P4 stopImmediatePropagation</p>
      </div>
    </form>
  </div>
  <script src="bubbling.js"></script>
</body>
</html>

[bubbling.js]

const myForm = document.getElementById('myForm')
const myDiv = document.getElementById('myDiv')
const myP = document.getElementById('myP')
myForm.addEventListener('click', () => alert('form'));
myDiv.addEventListener('click', () => alert('div'));
myP.addEventListener('click', () => alert('p'));

const myP2StopPropagation = document.getElementById('myP2-stopPropagation')
myP2StopPropagation.addEventListener('click', (e) => {
  alert('p2')
  e.stopPropagation()  
});

const myP3StopPropagation = document.getElementById('myP3-stopPropagation')
myP3StopPropagation.addEventListener('click', (e) => {
  alert('p3')
  e.stopPropagation()  
});
myP3StopPropagation.addEventListener('click', (e) => {
  alert('p3-2')
});

const myP4stopImmediatePropagation = document.getElementById('myP4-stopImmediatePropagation')
myP4stopImmediatePropagation.addEventListener('click', (e) => {
  alert('p4')
  e.stopImmediatePropagation()  
});
myP4stopImmediatePropagation.addEventListener('click', (e) => {
  alert('p4-2')
});

以下のようなhtmlの画面になると思います。

試しにPをクリックすると

  1. クリックした<p>
  2. その親要素の<div>
  3. 更に親要素の<form>

と親要素にイベント伝播して行っていることが分かると思います。

例えば<p><div><form>いずれかがクリックされたらイベントを発火させたい場合はそれぞれにクリックイベントを仕込むのは冗長で<form>に仕込むのが一番簡潔な書き方になります。

(例外的にバブルしないfocus イベントなどはありますが、それは例外でありほとんどのイベントはバブルします。)

バブリングを止めたい場合

stopPropagation、stopImmediatePropagationメソッドを使うことにより伝播を止めることが可能です。

  • stopPropagation()→イベントフローの現在のノードに後続するノードで、イベントリスナーが処理されないようにします。このメソッドは、現在のノードのイベントリスナーには影響しません。

  • stopImmediatePropagation()→イベントフローの現在のノードおよび後続するノードで、イベントリスナーが処理されないようにします。このメソッドはすぐに有効になり、現在のノードのイベントリスナーに影響します。

[bubbling.html] 実際の動きは以下になります。

  • myP2StopPropagationをクリック

    • alert('p2')が発火するのみでstopPropagationメソッドにより、イベントバブリングが止まっている事がわかります。
      myP2StopPropagation.addEventListener('click', (e) => {
        alert('p2')
        e.stopPropagation()  
      });
    
  • myP3StopPropagationをクリック

    • 同じくstopPropagationメソッドによりイベントバブリングが止まっています。ただ、現在のノードのイベントリスナーには影響しない為、同じエレメントに仕込まれている2つのクリックイベントalert('p3')alert('p3-2')どちらも発火していることがわかります。
      const myP3StopPropagation = document.getElementById('myP3-stopPropagation')
      myP3StopPropagation.addEventListener('click', (e) => {
        alert('p3')
        e.stopPropagation()  
      });
      myP3StopPropagation.addEventListener('click', (e) => {
        alert('p3-2')
      });
    
  • myP4stopImmediatePropagationをクリック

    • こちらはstopImmediatePropagationメソッドにより、イベントバブリングと更に同じ階層のもう一つのイベントの発火も止まっている事がわかります。
const myP4stopImmediatePropagation = document.getElementById('myP4-stopImmediatePropagation')
myP4stopImmediatePropagation.addEventListener('click', (e) => {
  alert('p4')
  e.stopImmediatePropagation()  
});
myP4stopImmediatePropagation.addEventListener('click', (e) => {
  alert('p4-2')
});

他に方法が無く、仕方の無い場合以外はバブリングを止めることはしないで下さい。

基本的にバブリングを防がなくてはならないケースというのは殆どなく、他の方法で解決できる可能性があります。

使用例

上記の内容の具体的な使用例を紹介したいと思います。

サイトからイベントバブリングを使い、値を取得する拡張アプリを作成します。

ファイル作成

touch {otherSite.html,otherSite.js,myExtendedApps.js,manifest.json}

[otherSite.html]

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="pagination">
    <button class="page">1</button>
    <button class="page">2</button>
    <button class="page">3</button>
    <button class="page">4</button>
    <button class="page">5</button>
    <button class="delete">delete</button>
  </div>
  <div id="content"></div>
  <script src="otherSite.js"></script>
</body>
</html>

[otherSite.js]

let pageIndex = 0
const contents = [
  ['Content 1-1', 'Content 1-2', 'Content 1-3', 'Content 1-4', 'Content 1-5'],
  ['Content 2-1', 'Content 2-2', 'Content 2-3', 'Content 2-4', 'Content 2-5'],
  ['Content 3-1', 'Content 3-2', 'Content 3-3', 'Content 3-4', 'Content 3-5'],
  ['Content 4-1', 'Content 4-2', 'Content 4-3', 'Content 4-4', 'Content 4-5'], 
  ['Content 5-1', 'Content 5-2', 'Content 5-5', 'Content 5-4', 'Content 5-5']
];
// 指定されたページを表示し、他のページを非表示にする
const contentContainer = document.getElementById('content');
const showPage = (pageIndex) => {
  const contentHTML = contents[pageIndex].map(content => `<p class="content">${content}</p>`).join('');
  contentContainer.innerHTML = contentHTML;
}

// バブリングを使い、pagenationを設定。
const pagination = document.getElementById('pagination');
pagination.addEventListener('click', (e) => {
  const target = e.target;
  if (!target.classList.contains('page')) return
    pageIndex = Array.from(pagination.children).indexOf(target);
    showPage(pageIndex);
});

const deleteButton = document.querySelector('.delete');
deleteButton.addEventListener('click', (e) => {
  // 現在のページの先頭のコンテンツを削除
  contents[pageIndex].shift();
  // ページを再表示
  showPage(pageIndex);
});

// 最初のページを表示
showPage(0);

[manifest.json]

  {
      "name": "bubbling拡張機能",
      "description": "bubbling-tutorialの為の拡張機能です",
      "version": "1.0.0",
      "manifest_version": 3,
      "content_scripts": [{
        "matches": [
          "http://localhost:5500/*",
          "http://127.0.0.1:5500/*"
      ],
        "js": ["myExtendedApps.js"]
      }]
    }

otherSiteと命名されたファイルは他社サイトだと仮定して、そのサイトの値を取得する拡張機能アプリを簡易的に実装します。

拡張アプリの作り方はの詳しい説明は別記事がありますのでそちらをご覧下さい。

Chrome拡張機能画面から作成したディレクトリを読み込んで追加してください。

(コードを更新する度に拡張アプリの更新も必要です) ローカルサーバーでotherSite.htmlファイルをport5500で立ち上げて下さい。 何を使っても良いのですが、私は今回vscode拡張機能であるLive Serverを使用します。

otherSiteでは以下のような簡単なページネーションが実装されています。 ここでページネーションで切り替わる度に値を取得したいとしましょう。 http://localhost:5500/otherSite.html その場合、イベントバブリングを使用しない場合は以下のような実装になります。

[myExtendedApps.js]

const pagination = document.querySelectorAll('.page')
pagination.forEach((page) => {
  page.addEventListener("click", () => {
    const contents = document.querySelectorAll('.content')
    contents.forEach((content) => console.log(content.textContent))
  });
})

ボタンをループで回して一つ一つにクリックイベントを仕込んでいます。

この場合だとボタンが5個あれば5個、10個あれば10個、、、

ボタンがある分だけイベントを仕込まなくてはならないのでとても冗長です。

また、下の画像のようにボタンが全て描画されてないページネーションの方が一般的でSPA仕様の場合は再レンダリングされない為、全てのボタンにイベントを仕込めません。 こうゆう場合イベントバブリングを利用すればとても簡潔に実装が可能です。

以下のように親要素にクリックイベントを仕込むだけで問題なく、contentクラスを持つエレメントが取得されている事が分かります。

<div id="pagination">の子要素がクリックされるだけでイベントが発火する為、再レンダリングされない場合でも対応が可能です。

[myExtendedApps.js]

const pagination = document.getElementById('pagination')
pagination.addEventListener('click', () => {
  const contents = document.querySelectorAll('.content')
  contents.forEach((content) => console.log(content.textContent))
})

イベント伝播を止めたい場合

現状はdeleteボタンを押した際もイベントバブリングにより、コンテンツを取得してコンソールに出力されています。

仮にdeleteボタンを押した時にはイベントの伝播を止めたい場合、

以下のdeleteボタンにe.stopPropagation()イベントを仕込んで止める事ができます。

[myExtendedApps.js]

const pagination = document.getElementById('pagination')
pagination.addEventListener('click', () => {
  const contents = document.querySelectorAll('.content')
  contents.forEach((content) => console.log(content.textContent))
})

const deleteBtn = document.querySelector('.delete')
deleteBtn.addEventListener('click', (e) => {       
  e.stopPropagation()                              
})                                                 

ただ、上記でも記載した通りバブリングを防がなくてはならないケースというのは殆どなく、

他の方法で解決できる可能性があります。

今回の例で言えば、paginationにのクリックイベントにif(e.target.classList.contains('delete')) return として、クリックされたtargetが deleteボタンだったらコンソールにコンテンツを出さないようにする事が可能です。

[myExtendedApps.js]

const pagination = document.getElementById('pagination')
pagination.addEventListener('click', (e) => {
  if(e.target.classList.contains('delete')) return             
  const contents = document.querySelectorAll('.content')
  contents.forEach((content) => console.log(content.textContent))
})

これは簡単な例でしか無いですが、このようにイベントバブリングを止めなくても、

対応出来るケースが殆どだと思われます。

まとめ


今回ブラウザのイベントの伝播の仕組みを詳しく調べる事が出来て、とても勉強になりました。

イベント伝播の仕組みとイベントバブリングを知るのと知らないのでは大分書くコードの簡潔さが変わってくるかなって思います。

稀にキャプチャリングフェーズを利用してイベントを仕込む場合もあるとの事なので、その点も調べて、もっとイベント伝播を使いこなせるようになりたいです。

参考資料

W3C 3.1. Event dispatch and DOM event flow

Event.stopPropagation()とEvent.stopImmediatePropagation()メソッドの違い

バブリング と キャプチャリング

Handling Events

イベントの伝搬をキャンセルする