UI Event Order

我一直以來都對於 DOM 事件的順序抱有一種不確定的感覺,舉例來說,當使用者點滑鼠時,你可以預期到會有mousedownmouseupclick事件,但是它們的順序是什麼呢?可以確定的是 down 一定是第一個,up 一定在 down 之後,那click是在中間還是最後呢?更進一步,點在可以有 focus 的元件上,那focus事件的順序呢?如果已經有其他元件有 focus,那它的blur事件又是在focus的前面還後面呢?除了滑鼠事件外,鍵盤事件又更複雜,除了keydownkeyup之外還有inputchange和 IME 的 composition 事件等,總之最近實在是太在意了,就認真的弄了個測試網頁自己測試,之後又查找了相關的標準規範,這篇就來記錄一下這些事件的順序。

首先就來說滑鼠(指標裝置)相關的事件順序吧,第一個是滑鼠點擊,就是mousedownmouseupclick,是在mouseup後才接著有click事件,其實仔細想了一下也是蠻合理的,要有 up 事件才代表完成了click事件。然後這順序其實是有定義在 UI Events 5.3.3 之中的,其中的最後一個表格就是一個標準的點擊時事件的順序,而且這個表格還包括了mousemovedblclick事件,這個章節中的其他部分則是滑鼠移動經過不同、多層的 DOM node 時,不同 node 上的mouseovermouseout事件的順序。

除了 Mouse Events 之外,其實現在瀏覽器的實作應該是都實作 Pointer Events 了,Pointer Events 涵蓋了各種指標式的控制方式,包括了滑鼠、觸控、觸控筆等,所以可以說 Mouse Events 只能算是子集,相對應的事件名稱基本上就是把mouse換成pointer,例如:pointerdownpointerup,現在主流的瀏覽器也都已經有支援 Pointer Events 了,然後這時候問題就來了,Pointer Event 和 Mouse Events 誰先誰後?根據測試的結果,是 Pointer 先然後才 Mouse,所以更完整的順序是:

pointerdown
mousedown
focus
focusin
pointerup
mouseup
click

至於這個順序是怎麼決定的呢?根據 Pointer Events 的 11 章「Compatibility mapping with mouse events」 所述,瀏覽器開發時可以考慮同時發佈古時候的 Mouse Events,其中 11.2、11.3 就有提到先發佈 Pointer Events 接著再發佈對應的 Mouse Event。

然後還有一個細節是,根據 Pointer Event 4.2.3 The pointerdown event,如果在pointerdown事件裡面呼叫event.preventDefault()取消事件的話,後面的mousedownmouseup就都不會觸發。

我上面的那段事件順序,其實還多列了一個focusin,這其實是一系列的新(相較於 DOM2)事件,包括了:

  • focusin對應focus
  • focusout對應blur
  • mouseenter對應mouseover
  • mouseleave對應mouseout

其實mouseenter/mouseleave是 IE5.5 時微軟先提出的,ppk 也有文章介紹過它解決了什麼問題,jQuery 當年也有支援,後來進入了 W3C 標準,現在是放到 UI Events 裡面,這些事件最大的差異就是它們不會 bubble 到外層,減少了很多問題;focusin/focusout則是剛好相反,它們會 bubble 而focus/blur不會,一樣 ppk 也有文章介紹到,簡單說就是focus/blur不會 bubble 只有 capture(外往內到 event target 的過程),但是 IE 不是用 addEventListener 所以沒有 capture 階段,所以會無法實作出 event delegation,然後只能用當年微軟 IE 特有的focusin/focusout事件,現在也是標準化,收到 DOM 3 Events 裡面;而除了這四組之外,其實 Pointer Events 也有 enter 和 leave,一樣順序是在 over 和 out 的後面,然後這些成對的事件,都是比較早定義的那些先發生,才接後來定義的,和有沒有 bubble 無關。

接著來說 Keyboard Events,這邊就針對 input 節點上的,一個鍵盤按鍵按壓的動作,會產生keydownkeypresskeyup三個基本的事件,通常按鍵盤按鍵就是為了輸入東西,所以會有個也是比較新的input事件,會在keypress之後,input則還有一個成對的beforeinput,如果在beforeinput內叫preventDefault()的話則可以阻止文字的輸入,總之順序如下:

keydown
keypress
beforeinput
input
keyup

change事件則是要在blur時才會有,順序是先changeblur

change
blur
focusout

這個順序是定義在 HTML 的 User Interaction 一章的 6.6.4 Processing model 裡面,明確的寫下要先 commit change 後才blur

如果有用 IME 的話,事情就很不單純了,還會有 Composition Events,順序是在beforeinput前面,剛開始組字會同時有compositionstartcompositionupdate兩個事件,然後沒有keypress,之後的輸入組字則就是只有update

keydown
compositionstart
compositionupdate
beforeinput
input
keyup

Composition Events 現在標準的文件是放在 UI Events 裡面,不過文件中範例的順序和實際測試的結果並不一致,目前的文件草稿中compositionupdate是在beforeinput之後,不過其實現在瀏覽器的順序是在 UI Events 的 Issue 354 提出的變動,只是目前文件還沒有發布更新的內容。

結束組字的時候,不同瀏覽器的行為就有差異了,首先是按下 Enter 完成組字時,Firefox 的話不會有多一個update,直接就是compositionend,然後是在beforeinputinput之間:

keydown
beforeinput
compositionend
input
keyup

Chrome(v123) 的話就會有多一個compositionupdate然後compositionend順序也不一樣,會在input的後面:

keydown
compositionupdate
beforeinput
input
compositionend
keyup

其實 UI Events 文件 5.7.5 內的範例是 Firefox 那種沒有多compositionupdate的順序,這個 5.7 章節內其實還有不少其他情境下的事件順序,像是手寫輸入,取消輸入等等。

如果是還沒完成組字,直接切換輸入法強迫結束組字,事件順序都和按下 Enter 都一樣,如果是組字到一半直接切換視窗,就是標準沒定義到的狀況了,Firefox 的行為比較接近上面的樣子,先完成inputblur

beforeinput
compositionend
input
blur
focusout

Chrome 則是會先產生blur然後接compositionend

change
blur
focusout
compositionend

以上,大概紀錄了我一直以來都有疑惑的使用者行為所產生的事件們的順序,最後附上我用的程式碼(超單純):

const events = [
  "keydown",
  "keypress",
  "keyup",
  "change",
  "compositionstart",
  "compositionupdate",
  "compositionend",
  "beforeinput",
  "input",
  "click",
  "auxclick",
  "contextmenu",
  "mousedown",
  "mouseup",
  "focus",
  "focusin",
  "focusout",
  "blur",
  "pointerdown",
  "pointerup",
  "paste",
];

const elem1 = document.getElementById("target-1");
const elem2 = document.getElementById("target-2");

events.forEach((event) => {
  elem1.addEventListener(
    `${event}`,
    () => {
      console.log(event.target.id, event);
    },
    false
  );
  elem2.addEventListener(
    `${event}`,
    () => {
      console.log(event.target.id, event);
    },
    false
  );
});