Safie Engineers' Blog!

Safieのエンジニアが書くブログです

JavaScriptでトグルボタンのclickイベントが2重発火する原理と解決策

はじめに

こんにちは。セーフィー株式会社で内定者インターンをしている戌亥です。汎用的なコンポーネントの開発をしている中で起こったトグルボタンを実装バグの紹介とその原理、解決法についてご説明しますので、ぜひ参考にしてください。

背景

インターンのReactを用いた個人開発課題の中で汎用的なコンポーネントを先にある程度作ってしまおうと考えました。その際にラベルボタン方式でトグルボタンを実装しようとしたのですが、clickイベントが2度実行されてしまうバグが発生しました。React側の問題かと思って調べてみるとlabel要素自体の仕様が関わっていることがわかったので、色々検証を行いました。

ラベルボタン

トグルボタンを実装する方法にはJavaScriptで実装する場合とHTML/CSSのみで実装する場合の2パターンがあります。

JavaScriptで実装する場合は、ボタンにする要素がクリックされたときに<要素>.classList.toggleなどでclassをつけたり外したりすることでONかOFFかの状態を保存できます。

HTML/CSSで実装する場合は、label要素を非表示化したcheckboxに紐づけることでONかOFFかの状態を保存できます。

本来label要素はforで指定したidを持つ要素と連動しますが、forで指定していない上にlabel要素の中にcheckboxなどの紐づけられる要素がある場合、それに紐づきます。このようにlabel要素で囲んで実装するボタンを個人的にラベルボタンと呼んでいます。ラベルボタンのメリットはforやidを指定しなくていいので、複製する際などにidをわざわざ振らなくてもいいところです。

<style>
    input[type="checkbox"] {
        display: none;
    }
    label > span {
        padding: 10px 20px;
        margin: 5px;
        background-color: #357c9c;
        color: white;
        border: 2px solid #ed7d31;
        cursor: pointer;
        display: inline-block;
    }
    label:has(:checked) > span {
        background-color: #ed7d31;
    }
</style>
<label>
    <input type="checkbox">
  <span>ラベルボタン</span>
</label>

ソースコード1:ラベルボタンの例

起こったバグ

実際のコードは割愛しますが、先ほどのサンプルコードのような要素をReactコンポーネントで作成し、安易にonClickにイベントを紐づけました。その結果、ボタンをクリックしたときに紐づけた関数が2回実行されるということが起きました。

まずReact環境に起因するのかどうかを判断するために、純粋なhtmlファイルをエクスプローラーで開いただけの環境にしました。そこでaddEventListenerを用いてclickイベントを紐づけたところ、同じ挙動が発生したため、Reactに起因することではないことがわかりました。

またラベルボタンではなくidでlabel要素を紐づけて行ったところ、1回のみの実行となったため、ラベルボタンの時のみに起こることがわかりました。

再現できるソースコード2ではボタンのlabel要素のclickイベントに「Hello, world.」とコンソール出力するだけの設定をしています。図1はラベルボタンをクリックしただけの状態ですが、2回出力されていることがわかります。対して図2はidに紐づける方法ですが、1回のみの出力となっていることがわかります。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <style>
            input, input[type="checkbox"] {
                display: none;
            }
            label > span {
                padding: 10px 20px;
                margin: 5px;
                background-color: #357c9c;
                color: white;
                border: 2px solid #ed7d31;
                cursor: pointer;
                display: inline-block;
            }
            label:has(:checked) > span {
                background-color: #ed7d31;
            }
            input[type="checkbox"]:checked + label > span {
                background-color: #ed7d31;
            }
        </style>
    </head>
    <body>
        <div id="test1-1">
            <label id="test1-2">
                <input type="checkbox" id="test1-3">
                <span>ラベルボタン</span>
            </label>
        </div>
        <div id="test2-1">
            <input type="checkbox" id="test2-2">
            <label for="test2-2" id="test2-3">
                <span>紐づけボタン</span>
            </label>
        </div>
        <script>
            function hw() {
                console.log('Hello, world.');
            }
            window.addEventListener('load', function() {
                const labels = document.querySelectorAll('label');
                [...labels].map((label)=>{
                    label.addEventListener('click', hw, false);
                });

            }, false);  
        </script>
    </body>
</html>

ソースコード2:テスト環境

図1:ラベルボタンのクリック時の実行結果

図2:紐づけボタン(idとforで紐づけたトグルボタン)のクリック時の実行結果

label要素の挙動について正確な仕様を調べたところ、「label要素がクリックされたとき、その中に含まれるフォームコントロールに対してclickイベントを発火する」[1]ということがわかりました。このフォームコントロールとはlabelable elementと呼ばれるbutton,input,meter,textarea等といったlabel要素に紐づけられる要素のことです。

検証

次にソースコード3のhtmlファイルでイベントの流れを調べていきます。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <style>
      input, input[type="checkbox"] {
        display: none;
      }
      label > span {
        padding: 10px 20px;
        margin: 5px;
        background-color: #357c9c;
        color: white;
        border: 2px solid #ed7d31;
        cursor: pointer;
        display: inline-block;
      }
      label:has(:checked) > span {
        background-color: #ed7d31;
      }
      input[type="checkbox"]:checked + label > span {
        background-color: #ed7d31;
      }
    </style>
  </head>
  <body>
    <div id="test1-1">
      <label id="test1-2">
        <input type="checkbox" id="test1-3">
        <span>ラベルボタン</span>
      </label>
    </div>
    <div id="test2-1">
      <input type="checkbox" id="test2-2">
      <label for="test2-2" id="test2-3">
        <span>紐づけボタン</span>
      </label>
    </div>
    <script>
      function returnPhase(phase) {
        switch (phase) {
          case 1:
            return 'Capturing';
          case 2:
            return 'Target';
          case 3:
            return 'Bubbling';
        }
      }
      function sample(event) {
        const ev = {
          type: event.type,
          evented: event.target,
          handled: event.currentTarget,
          phase: returnPhase(event.eventPhase),
        }
        console.log(ev);
      }
      window.addEventListener('load', function(){
        const targets = document.querySelectorAll('body *');
        [...targets].map((target)=>{ 
          target.addEventListener(
            'click', {
              handleEvent: sample // イベントに紐づけさせる関数
            }, false // captureフェーズで発火させる場合はtrue
          );
        });
      }, false);
                
    </script>
  </body>
</html>

ソースコード3:検証用環境

こちらが今回検証するラベルボタンの部分

<div id="test1-1">
  <label id="test1-2">
    <input type="checkbox" id="test1-3">
    <span>ラベルボタン</span>
  </label>
</div>

ソースコード4:ラベルボタンの部分

<div id="test2-1">
  <input type="checkbox" id="test2-2">
  <label for="test2-2" id="test2-3">
    <span>紐づけボタン</span>
  </label>
</div>

ソースコード5:比較用の紐づけボタンの部分

図3:ラベルボタンのDOM要素イメージ図

こちらは今回イベントに紐づけるためのサンプルとして作った関数(sample)です。

function sample(event) {
  const ev = {
    type: event.type, // イベントの種類
    evented: event.target, // イベントの自体の発火元
    handled: event.currentTarget, // 伝播された現在の発火元
    phase: returnPhase(event.eventPhase), // どこのフェーズで発火したか
  }
  console.log(ev);
}

ソースコード6:sample関数

図4:実装したレイアウト(まだクリックしていない状況)

バグが起こっていた原理

イベントの処理の伝達にはCaptureフェーズ、Targetフェーズ、Bubblingフェーズが存在します。全体の流れとしてはラベルボタンをクリックするとまず、Captureフェーズ(赤矢印)でwindow→document→body→div→label→spanとターゲットまで伝達が上がってきます。次にTargetフェーズ(緑枠)でspanタグに到達します。その後、Bubblingフェーズ(青矢印)で伝達が下りていきます。

図5:CaptureフェーズからBubblingフェーズ

デフォルトだとaddEventLisetnerの第三引数がfalseになっているのでBubblingフェーズでイベントが実行されます。そのため子要素から親要素へと処理が行われていくようになります。

図6:CaptureフェーズからBubblingフェーズの詳細

sample関数を紐づけているdiv要素以下に注目すると、span要素がクリックされ、Captureフェーズでspan要素まで到達します。①Targetフェーズでspan要素がイベント発火します。②Bubblingフェーズでlabel要素がイベント発火します。③Bubblingフェーズでdiv要素がイベント発火します。

図7:label要素によるcheckboxのクリック後の流れ

label要素でclickイベントが発火したため、子要素で一番最初に見つかるlabelable elementのcheckbox要素がクリックされます。このクリックにおいても①Targetフェーズでcheckbox要素がイベント発火します。②Bubblingフェーズでlabel要素がイベント発火します。③Bubblingフェーズでdiv要素がイベント発火します。これによってlabel要素と、それより親の要素は2回イベント発火することになります。

図8:ラベルボタンをクリックした図

図9:紐づけボタンをクリックした図

図8のコンソール画面を見ると、説明の通りまずspan要素のバブリングが行われています。その後checkboxがクリックされ、checkboxからバブリングが起きていることがわかります。

図9のコンソール画面を見るとspan要素がクリックされた後、先ほどと同じようにTargetフェーズでspan要素がイベント発火し、Bubblingフェーズでlabel要素、div要素とイベント発火しています。こちらはforで指定したcheckboxがクリックされ、Targetフェーズでcheckbox要素が、Bubblingフェーズで親要素であるdiv要素がイベント発火しています。

label要素はクリックされたときに、子要素またはforで指定したcheckbox等をクリックする仕様がありました。つまりcheckboxのバブリング上にlabel要素があるときは、label要素のクリックとcheckboxのクリックの2回イベント発火します。ラベルボタンはcheckboxのバブリング上にあるから2回発火し、紐づけボタンはcheckboxの兄弟要素であってバブリング上にはないために1回のみの発火になっていました。

解決法

clickイベントが2回発火する原因はバブリング上にlabel要素自身があったためなので、主に取れる手法は

①label要素をバブリング上から外す

②バブリングが伝わらないようにする

③clickイベントではなくchangeイベントを使う

の3つのうちのどれかで解決できます。

①バブリング上から外す場合、紐づけボタンのようにlabel要素の親子関係に対象のlabelable elementを置かずにidとforで紐づけます。

②バブリングが伝わらないようにする場合、これはいくつか方法があります。

(1)stopPropagationを使う

(2)preventDefaultを使う

(3)targetを参照する

②(1)ソースコード7のようにsample関数を書き変えてevent.stopPropagation()を実行することで、イベントの伝播をそこで止めることができます。これは他の紐づけ関数に対しても伝播を止めてしまうことになるため、あまり推奨されていません。図10はstopPropagationを実装した後のラベルボタンをクリックしたときの図です。図6の①と図7の①だけ実行されていることがわかります。伝播は中断してもlabel要素の挙動によるcheckboxのクリックは中断されないようです。またこの場合はcheckboxにclickイベントを設定する必要があります。

function sample(event) {
    event.stopPropagation();
  const ev = {
    type: event.type, // イベントの種類
    evented: event.target, // イベントの自体の発火元
    handled: event.currentTarget, // 伝播された現在の発火元
    phase: returnPhase(event.eventPhase), // どこのフェーズで発火したか
  }
  console.log(ev);
}

ソースコード7:event.stopPrpagationの実装例

図10:②(1)実装後のラベルボタンをクリックした図

②(2)ソースコード8のようにsample関数を書き換えてevent.preventDefault()を実行することで、label要素のクリックされたら紐づいたlabelable elementをクリックするという本来の挙動を止めることができます。それによってlabelボタンのclickイベントは発火しますが、checkboxはクリックされないため新たにclickイベントが発生しなくなり、2重発火を防ぐことができます。しかしトグルボタンとして利用する場合は、label要素がcheckboxがクリックしないため切り替えることができなくなります。

function sample(event) {
    event.preventDefault();
  const ev = {
    type: event.type, // イベントの種類
    evented: event.target, // イベントの自体の発火元
    handled: event.currentTarget, // 伝播された現在の発火元
    phase: returnPhase(event.eventPhase), // どこのフェーズで発火したか
  }
  console.log(ev);
}

ソースコード8:event.preventDefaultの実装例

図11:②(2)実装後のラベルボタンをクリックした図

②(3)event.targetはバブリングの位置に関わらず、イベントのトリガーとなった要素を参照します。checkboxがクリックされた場合はevent.targetにcheckboxが格納されているため、checkboxのクリックによるバブリング中か、ラベルボタンのクリックによるバブリング中かが判別できます。どちらかのときだけ実行するとしておくことで2重実行を防ぐことができます。またこの場合はlabel要素ではなく、checkboxにclickイベントを設定する方がどの場合でも使えるのでお勧めです。

function sample(event) {
    if (event.target !== event.currentTarget) return;
  const ev = {
    type: event.type, // イベントの種類
    evented: event.target, // イベントの自体の発火元
    handled: event.currentTarget, // 伝播された現在の発火元
    phase: returnPhase(event.eventPhase), // どこのフェーズで発火したか
  }
  console.log(ev);
}

ソースコード8:event.preventDefaultの実装例

図12:②(3)実装後のラベルボタンをクリックした図

③changeイベントを使う場合、label要素自身のchangeイベントは存在しないので、必ずcheckbox等のchangeイベントがバブリングした場合のみイベントが発火します。checkboxはクリックされると状態がON・OFF切り替わるので、changeイベントにすることで同一イベントの2重発火を避けることができます。

まとめ

label要素でforを指定せず、checkbox等を囲ってボタンを作るとclickイベントが2重発火します。

2重発火する原因はcheckbox等がlabel要素の親子要素にあり、ボタンのクリック時のバブリングで1回、label要素の仕様によるcheckboxのクリック時のバブリングで1回の計2回label要素でclickイベントが発火するためでした。

対策としてはイベントのトリガーになった要素を参照したり、clickイベントからchangeイベントに変えたりするとボタンに紐づいた処理の2重実行を避けることができます。

トグルボタンの実装からJavaScriptのイベントに関して掘り下げた記事でした。ぜひ参考にしてください。

最後に

セーフィーではエンジニアを積極的に募集しています。どのような職種があるのか気になる方はこちらをご覧ください!

safie.co.jp

皆様のご応募、心よりお待ちしております! 最後までお読みいただき、ありがとうございました。

© Safie Inc.