【JavaScript】マウスによるクリックと element.click() ではリスナーの実行のされ方が違った【非同期処理】


概要

テストなどにおいてJavaScriptから擬似的に要素をクリックするために、click() メソッドや dispatchEvent() メソッドを使用したことがあるかもしれない。これらは実は非同期処理に関して"本当のマウスクリック"とは異なる振る舞いをすることがある。これを理解するには task、microtask などの JavaScript の非同期処理の実行モデルについて知る必要がある。

ちなみに、完全にユーザーの操作をシミュレートしたい場合は puppeteer のような直接ブラウザを操作するツールを使うといいだろう。

よく知られている違い

本題の前に、本当のクリックと click メソッドの分かりやすい違いを2つあげる。

違い1: mousedown/mouseup イベントを伴うかどうか

マウスで要素をクリックすれば mousedown/mouseup イベントが発生するが、click() では発生しない。あくまで click イベントだけが発生するようになっている。

違い2: event.isTrusted

イベントオブジェクトの isTrusted プロパティはユーザーによる操作の場合 true 、JavaScriptからイベントを発生させる場合 false となる。

el.addEventListener("click", e => {
  if (e.isTrusted) {
    console.log("本当にユーザーがクリックした")
  }
})

非同期処理に関する違い

まず、下の例で違いを確認しよう。1つ目のボタンには2つのクリックイベントリスナーが設定されており、それぞれログを出力する。2つ目のボタンをクリックすると1つ目のボタンに対して click() メソッドが実行される。

See the Pen click vs .click() by righteous (@righteous_github) on CodePen.

ブラウザによっては異なる結果になるかもしれないが、仕様に則った正しい動作としては以下のようになる。

// 直接クリック
0
1
3
2

// .click()
0
3
1
2

結論から言うと、この違いの原因は、

  1. 複数のイベントリスナーが存在するとき、通常のクリックでは「1つのリスナーが実行し終わった後コールスタックが一旦空になってから次のリスナーが実行される」が、click() メソッドではコールスタックが空にならないまますべてのリスナーが実行されるから
  2. microtask はコールスタックが空になるたびにすべて実行されるから

である。以降この2点についてそれぞれ説明する。

コールスタック

コールスタックとは、「現在関数 f1 から呼び出された関数 f2 から呼び出された関数 f3 を実行中だ」という風に現在どの関数を実行中で、どうやってここまできたかという情報を格納するものである。コールスタックはその名の通りスタックと呼ばれるデータ構造に格納されている。例えば下のように関数 f が内部で関数 g を呼び出すとしよう。

function f() {
  const a = 1
  const b = a * 2
  g()
}

function g() {
  const c = 3
  console.log(c)
}

f()

関数が呼び出されるとそれがスタックに push され、関数の実行が終了すると pop される。最初に f が呼び出されると f が push される。f の中で g が呼び出されるとさらに g が push される。g の実行が終了するとスタックから pop され、やがて f も pop され、最後には空になる。

ブラウザの開発者ツールで実際のコールスタックの変化を確認することができる。たとえば下のスクリーンショットは Google Chrome で www.google.com を開いたときの状態を Performance タブで記録したものである。右側のつららのようなものがコールスタックの時間変化を表している(上の図とは上下逆になっていることに注意)。

イベントリスナーとコールスタック

クリックイベントに対して2つのイベントリスナーが存在するとしよう。ユーザーがクリックした場合は、下の図の左のように、1つ目のリスナーが実行されたあと一旦コールスタックが空になり、そして2つ目のリスナーが実行される。一方、click() メソッドの場合は、そのメソッドを呼び出した関数 (or 環境)がまずコールスタックに存在し、その上で2つのリスナーが実行されるため、途中でコールスタックが空にならない

Microtask

JavaScript には、実行予定の非同期処理を蓄えておく task queuemicrotask queue (キュー=待ち行列) というものがある。通常の非同期処理のコールバックは task と呼ばれ、task queue に蓄えられる。例えば setTimeout(callback, time)callback である。microtask に該当するのは Promise のコールバックと MutationObserver のコールバックである。

microtask は task の軽量版である。microtask queue にある microtask はコールスタックが空になると即実行される。一方、task は前の task が完全に実行し終わらないと開始されない。

ここで最初の例に戻ろう。1つ目のイベントリスナーは 0 を出力したあと、microtask と task をそれぞれ queue に入れている。

btn1.addEventListener("click", () => {
  log(0)
  Promise.resolve(1).then(log)  // <- microtask
  setTimeout(() => log(2), 0)   // <- task
})

btn1.addEventListener("click", () => {
  log(3)
})

通常のクリックでは、1つ目のイベントリスナーが終了した直後にコールスタックが空になるため microtask が実行され 1 が出力される。そして2つ目のイベントリスナーが実行され 3 が出力される。このタイミングでイベント処理のタスクが終了するため、次のタスクが実行され、2 が出力される。

click メソッドでは2つ目のリスナーが終わるまでコールスタックが空にならないため、0 3 と出力されたあと microtask が実行され、1 が出力される。

補足: click()dispatchEvent() の違い

click()dispatchEvent(new MouseEvent("click", options)) とほぼ同じ振る舞いであると思われる1。ただし、 後者は options で「イベントがバブルアップするかどうか」やクリック座標など、イベントに関する細かい設定をすることができる。

参考


  1. 仕様の中でclick()ここdispatchEvent()ここで定義されている。慎重に読んだわけではないが、大きな違いは見られなかった。