jQueryのonでハンドラを複数登録するのとaddEventListenerで複数登録するのは微妙に挙動が違う

背景

jQueryのonにハンドラを複数登録するのと、addEventListenerで複数登録するのは微妙に挙動が違う。
それについて調べる必要があったのでメモ。

環境

Chrome バージョン 58.0.3029.110 (64-bit)

再現

formのsubmitを押した時のハンドラを複数登録する。

  • 1つ目: alert出すだけ
  • 2つ目: 例外を発生
  • 3つ目: submit本来の挙動を停止(結果として遷移しなくなる)

サンプルコード - addEventListener版

<!DOCTYPE html>
<html lang="ja">
  <head></head>
  <body>
    <form action="" id="form">
      <button type="submit">submit</button>
    </form>
    <script type="text/javascript">
      var form = document.getElementById('form');
      form.addEventListener('submit', function() {
        alert(1);
      });
      form.addEventListener('submit', function() {
        alert(2);
        throw new Error("doooon");
      });
      form.addEventListener('submit', function(e) {
        alert(3);
        e.preventDefault();
        e.stopPropagation();
      });
    </script>
  </body>
</html>

実行結果は以下のようになる。

  1. alert(1)が表示
  2. alert(2)が表示
  3. 例外が発生し、Webコンソールに出力される
  4. alert(3)が表示
  5. 何も遷移しない

つまり各ハンドラは独立して実行される。

サンプルコード - jQuery

バージョンは3.2.1と2.2.4で確認した。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
  </head>
  <body>
    <form action="" id="form">
      <button type="submit">submit</button>
    </form>
    <script type="text/javascript">
      $("#form")
        .on("submit", function () {
          alert(1);
        })
        .on("submit", function () {
          alert(2);
          throw new Error("doooon");
        })
        // このハンドラは呼ばれない
        .on("submit", function (e) {
          alert(3);
          // 本来はreturn falseだけでいいけどテストとして一応書いておく
          e.preventDefault();
          e.stopPropagation();
          return false;
        });
    </script>
  </body>
</html>

実行結果は以下のようになる。

  1. alert(1)が表示
  2. alert(2)が表示
  3. 例外が発生しているが、Webコンソールには何も表示されていない(というか見えない)
  4. 遷移する

つまり3つ目のハンドラは呼ばれていない。

原因

jQueryonで登録するハンドラはそのままaddEventListenerに渡しているわけではなく、内部的にtype毎に1つの関数でラップしている。
このラップした関数が実際のイベント発火時に処理されるわけだが、ここでは登録されたハンドラをループして処理している。
そのため、登録したハンドラが途中で落ちると後続のハンドラは動かない。実際のハンドラ呼び出しでは例外をキャッチしていない。

またハンドラの呼び出しは以下のようになっていて、falseを返すとpreventDefault()stopPropagation()を呼ぶ。

  ret = ( ( jQuery.event.special[ handleObj.origType ] || {} ).handle ||
    handleObj.handler ).apply( matched.elem, args );

  if ( ret !== undefined ) {
    if ( ( event.result = ret ) === false ) {
      event.preventDefault();
      event.stopPropagation();
    }
  }

例外が発生すると当然preventDefaultなどには到達しないので、デフォルトの挙動で動く。よって、上記のサンプルではsubmitがデフォルトの通り遷移した。

ちゃんと動作確認をしないと、期待通りに動いているのか、途中で例外が起きたけどデフォルトの挙動で上手くいっているように見えたか分からない。

おわりに

ChromeのWebコンソールでブレークポイント貼ってCall Stack辿れるの便利。