DOMDOMタイムス#2: pointer-eventsがnoneなのにevent.targetになって、autoなのにならない要素???

2023/06/19に公開

不定期でDOMやその周辺についてメモを残すDOMDOMタイムス!👶
不定期と謳いつつも先週に続いて、なんと今週も更新です。このまま週刊連載いけるのでしょうか?

今日はmousedown/mouseup/clickのイベントターゲットおよびpointer-eventsの挙動の観察を通じて、イベントのコールバック実行タイミングなどについて見ていきます。

突然のクイズ

body上にid='hoge'のdiv要素だけが配置されているページがあったとします。
そのページを開いて、ブラウザのコンソールで下記を順に実行します。

const hoge = document.querySelector('#hoge')

window.addEventListener(
    "mousedown",
    (e) => {
        console.log("mousedown:target:", e.target);
        console.log(
            "mousedown:pointer-events:",
            getComputedStyle(hoge).pointerEvents
        );
    },
    true
);
window.addEventListener(
    "mouseup",
    (e) => {
        console.log("mouseup:target:", e.target);
        console.log(
            "mouseup:pointer-events:",
            getComputedStyle(hoge).pointerEvents
        );
    },
    true
);
window.addEventListener(
    "click",
    (e) => {
        console.log("click:target:", e.target);
        console.log(
            "click:pointer-events:",
            getComputedStyle(hoge).pointerEvents
        );
    },
    true
);

要するに、mousedown/mouseup/clickイベントに対して、下記2つを確認するコールバックをキャプチャリングフェーズに登録したわけです。

  • イベントのターゲット
  • body上で唯一の要素であるdivのpointer-events値(computed)

さて、このときこんなふうに出力されました。

mousedown:target: 
<div id="hoge">hoge</div>
mousedown:pointer-events: none 

mouseup:target: 
<body>…</body>
mouseup:pointer-events: auto 

click:target: 
<body>…</body>
click:pointer-events: auto 

mousedown->mouseup->clickの順にイベントが発火するのは仕様通りです。
しかし、これはいくつかの点で変な感じがします

  1. mousedownについて、pointer-events:noneの要素がtargetに入っている
  2. windowに対してキャプチャリングフェーズで仕掛けたコールバックが実行される時点、つまり相当早い時点でnoneに変わっている
  3. mouseupについて、pointer-events:autoの要素がtargetに入っていない
  4. clickについて、pointer-events:autoの要素がtargetに入っていない

このような状況をどのようにしたら再現できるでしょうか。

答え

ここに実際にそのようなサイトを作ってみました。
コンソールに表示されるようスクリプトで仕込んでいるのでご確認下さい👶

サイドバーをずらしてソースコードをみてもらえれば分かりますが、答えはこれです。

#hoge:active {
  pointer-events: none;
}

CSSでこれをやるだけで、先ほどの4点が再現されます。ここからはなぜそんなことになるのかを見ていきます。

a. mousedownについて、pointer-events:noneの要素がtargetに入っている
b. windowに対してキャプチャリングフェーズで仕掛けたコールバックが実行される時点、つまり相当早い時点でnoneに変わっている
c. mouseupについて、pointer-events:autoの要素がtargetに入っていない
d. clickについて、pointer-events:autoの要素がtargetに入っていない

CSS擬似クラスによるスタイル変化の反映とイベントコールバックの実行の順序関係(a,b,c)

CSSの擬似クラスの中にはイベントと似たようなものがあります。
例えば:hovermouseenter:focusfocus、そして今回の:activeclickなどなど。
このとき、CSS擬似クラスとイベントコールバックが共に同じ要素に作用したら、どちらが先に反映されるのでしょうか。

実は、これは標準仕様としては定められていない部分です
W3CによるCSS 2の"dynamic pseudo class"には、下記のようにあります(editor's draftでもそれ以外でも同じでした)。

CSS does not define which elements may be in the above states, or how the states are entered and left. Scripting may change whether elements react to user events or not, and different devices and UAs may have different ways of pointing to, or activating elements.
https://drafts.csswg.org/css2/#dynamic-pseudo-classes

なお、whatwgのドキュメントではこの点に関する記述を見つけられませんでした👶誰か知っていたら教えてください。

ただ、実際のところは多くのブラウザで、:hoverによる変更はmouseenterコールバック実行よりも前の時点ですでに反映されているようです
それは下記のページで実験できます(https://stackoverflow.com/questions/21614449/what-happens-first-css-or-js-events)。サイドバーをずらしてソースコードを見ながらやってみてください!

mouseenterのコールバックは実行される時に要素の文字色を取得して表示しますが、chromeなど多くのブラウザで:hoverが効いたあとの色(青色)が表示されるはずです。(そうじゃないブラウザがあったらぜひ教えてください!!!👶)

そして今回使用した:activeもまた、mousedownコールバック実行よりも前の時点ですでに反映されます
先ほどのページで擬似クラスとイベントをそれぞれ変更したバージョンが下記です。サイドバーをずらしてソースコードを見ながらやってみてください!

なお、これらのことから「擬似クラスによる変更はイベントコールバック実行よりも前の段階で反映されるのだ」と一般的に言うことができるのかは不明です。けっきょく標準仕様があるわけではないため、ブラウザの内部実装を見るでもしない限りこれら2ケース以外でどうなっているかは分からないと言わざるを得ません。
でも、まあ十中八九そうだろうなと個人的には思っています。先ほど貼ったstackoverflowにもありましたが、けっきょくコールバック内でgetComputedStyle()を実行したらスタイル計算は行われるので、やはりCSSの反映はスクリプトより早く来るようにするのが都合が良さそうだなと。

とりあえずここでは今回の:activeによるスタイル変更が、mousedownコールバック実行よりも前の時点で反映されることがポイントです。

さて、そうなるとa,bに説明がつきます。内部的に次の順番で話が進んでいたわけです。

  1. マウスのボタンを押す
  2. mousedownイベントが発火する(コールバックはまだ実行されていません)
  3. :active適用によるCSSスタイル変更が反映される→pointer-eventsの値がnoneになる
  4. mousedownコールバックの実行
  5. マウスのボタンを戻す
  6. mouseupイベントが発火する(コールバックはまだ実行されていません)
  7. :active解除によるCSSスタイル変更が反映される→pointer-eventsの値がautoになる
  8. mouseupコールバックの実行

この順番なので、要するに下のようになります。

  • 4番目の「mousedownコールバックの実行」時点のコールバックでイベントのターゲットとして表示されるのは、実際にイベントが発火した時点でのターゲットであるdiv要素(id=hoge)である一方、pointer-eventsの値はコールバック実行時にリアルタイムで計算されて、CSSがすでに反映されているからnoneになる
  • 8番目の「mouseupコールバックの実行」時点のコールバックでイベントのターゲットとして表示されるのは、実際にイベントが発火した時点でのターゲットであるbody要素である一方、pointer-eventsの値はコールバック実行時にリアルタイムで計算されて、CSSがすでに反映されているからautoになる
    (ターゲットがbodyになるのは、6番目の「mouseupイベント発火」時点でのdiv要素(id=hoge)のpointer-eventsがnoneだからですね!)

補足: イベント発火とコールバック実行は同期的に連続しているわけではない

上のステップ1,2,3について、2が挟まってくるのは実はそんなに意外な話でもないよなと個人的には思います。
これは直接関係がある話だとは断言できないのであくまで補足にとどめますが、そもそもイベント発火とコールバック実行は同期的に連続した処理ではありません。
イベントが発火したとき、コールバックとして登録されている関数はあくまでもイベントループで言うところのタスクキューに積まれるだけであり、タスクキューの順番がきたら実行されるのみです。

実は、このことはwhatwgが仕様として定めていたりします。

Calling a callback is often done by a dedicated task.
https://html.spec.whatwg.org/multipage/webappapis.html#definitions-3

実際にたしかめたい方は、例えばsetInterval()で大量にタスクを登録するようなページを用意して、そこでclickイベントを発火させてみれば分かります。コールバックは大量のタスクが終わってからようやく実行されるはずです。
こんなページを用意してやってみるのもありかもしれません。

<div id="fuga">fuga</div>
<script>
  window.addEventListener(
    "click",
    () => {
      console.log("click");
    },
    true
  );
  for (let i = 0; i < 5000; i++) {
    setTimeout(() => {
      console.log("task", i);
    }, 1);
  }
</script>

(試したい方はこちらでどうぞ。重いので注意です!)
https://codesandbox.io/s/da-liang-notasukuwoji-ndekaraclickwoqi-kositemiru-zenn-hy884c?file=/index.html

なお、これは補足の補足になりますがスクリプトでElement.prototype.click()をした場合は同期的にコールバックが実行されます
こんな2つを試してみればわかります。

// スクリプトによるクリックのコールバックは同期的

const el = document.createElement('div');

console.log(1);
el.addEventListener('click', ev => console.log(2));
el.click();
console.log(3);

// 1
// 2
// 3
// ユーザーによる手動クリックのコールバックは非同期的

const el = document.createElement('div');
document.body.appendChild(el);
el.innerText = 'click me'

console.log(1);
el.addEventListener('click', ev => console.log(2));
sleep(5000) // この間に手動でクリック
console.log(3);

function sleep(ms) {
  var start = new Date().getTime(), expire = start + ms;
  while (new Date().getTime() < expire) { }
  return;
}

// 1
// 3
// 2

この検証スクリプトは下記ブログを参考にさせていただきました👶
https://memowomome.hatenablog.com/entry/js_async_viz#addEventListenerに渡したコールバックの同期的実行

clickのターゲットはmousedownとmouseupのターゲットで決まる(d??)

さて、最後にclickのターゲットがbodyになっている件について説明を与える必要があります。
ここでは2つの可能性があり、どちらなのかは検証できていません。ただ、いずれの場合も筋は通っていると思うので一旦は両方を提示しておきます。

まず、mouseupのイベントが発火した後、CSSが効くまでの間にclickのイベントも発火しているという説です。
下記の6,7の間にclickイベントの発火が起きているという説です。その場合はmouseupのイベントターゲットがbodyになるのと同じ理由、つまりpointer-eventsがその時はnoneだったからという理由で説明されます。

  1. マウスのボタンを押す
  2. mousedownイベントが発火する(コールバックはまだ実行されていません)
  3. :active適用によるCSSスタイル変更が反映される→pointer-eventsの値がnoneになる
  4. mousedownコールバックの実行
  5. マウスのボタンを戻す
  6. mouseupイベントが発火する(コールバックはまだ実行されていません)
  7. :active解除によるCSSスタイル変更が反映される→pointer-eventsの値がautoになる
  8. mouseupコールバックの実行

ただ、これは正直確証がなく、そこまで細かい情報も調査すれども手に入れられませんでした。誰か知っている方がいたら教えてください👶

一方、そうではなかったとしても実は説明がつきます。
つまり、8のあとにclickイベントが発火するような遅さでも、説明はつくのです。
これが最後のポイントであるclickのターゲットの決まり方です。

実はclickイベントのターゲットは、mouseupとmousedownのイベントのターゲットが異なる場合、それぞれのターゲットの最も近い共通祖先になります
これはW3Cの仕様で定められています👶

Each implementation will determine the appropriate hysteresis tolerance, but in general SHOULD fire click and dblclick events when the event target of the associated mousedown and mouseup events is the same element with no mouseout or mouseleave events intervening, and SHOULD fire click and dblclick events on the nearest common inclusive ancestor when the associated mousedown and mouseup event targets are different.
https://www.w3.org/TR/DOM-Level-3-Events/#events-mouseevent-event-order

また、MDNも少し違う形で近いことを言っています。ただ、この書かれ方だと今回のようなケースはどうなんだろうと迷ってしまいますね!

If the button is pressed on one element and the pointer is moved outside the element before the button is released, the event is fired on the most specific ancestor element that contained both elements.
https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event

ちなみに先ほどのW3Cのページでは「一連のマウス関連イベントの流れの途中で、対象要素が消えた場合にはどうする」といったことも定められており、けっこう面白いです。

いずれにせよ、このような事情から上記のステップの最後にclickのイベントが発火していてもターゲットがbodyになると言えます。mousedownのターゲットであるdivとmouseupのターゲットであるbodyの共通祖先としてのbodyになるわけです

感想

ちょっとしたところに細かい話がつまっている。DOMは面白い👶

GitHubで編集を提案

Discussion