概要
テストなどにおいて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つのリスナーが実行し終わった後コールスタックが一旦空になってから次のリスナーが実行される」が、
click()
メソッドではコールスタックが空にならないまますべてのリスナーが実行されるから - 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 queue と microtask 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
で「イベントがバブルアップするかどうか」やクリック座標など、イベントに関する細かい設定をすることができる。
参考
- Tasks, microtasks, queues and schedules - jakearchibald.com
- Event.isTrusted - Web APIs | MDN
- EventTarget.dispatchEvent() - Web APIs | MDN
- MouseEvent - Web APIs | MDN