:::tip
最近在著手騰訊檔案的輸入體驗優化,在其中有一個不起眼的小需求引起了我的注意,並順便研究了一些事件監聽機制相結合的特點,特此記錄一下填坑過程。
:::
大部分的主流輸入法都有這樣一個特性,在輸入中文時,可以通過左右方向鍵控制遊標,移動至輸入區中任意兩個字元之間的位置,使用者接下來的字元輸入將在遊標處直接插入。
由於騰訊檔案的渲染的畫布是完全自主實現的,為了在體驗上與普通可編輯畫布保持一致,我們需要自己來模擬這一遊標的移動行為。
首先,我們需要確定的是輸入法中的模擬遊標進行更新的時機。經試驗,使用者在進行中文輸入時,若使用了方向鍵移動遊標,將會觸發遊標的移動行為。因此,首先要解決的是使用合適的事件監聽來捕獲這一行為,從而進行更新。既然是對輸入框的行為進行模擬,自然而然的,我們首先想到的是輸入框觸發的監聽器。
在瀏覽器對鍵盤的輸入規範中,將鍵盤輸入分為了直接輸入與間接輸入兩種。直接輸入將會觸發輸入框的 onInput
事件 (IE9 之前不支援該事件,只能用 onKeyUp
等鍵盤事件作為降級選擇)。而對於間接輸入,規範將事件監聽分為了 onCompositionStart
, onCompositionUpdate
, onCompositionEnd
三個部分。
而間接輸入的同時,中間態的寫入也會導致輸入框內容的變化,從而也會觸發 onInput
事件。因此在間接輸入中,事件的觸發次序為:onCompositionStart
, onCompositionUpdate
, onInput
, onCompositionEnd
。
需要注意的是,若輸入完成時,輸入框的內容沒有發生變化,則 onChange
事件與 onCompositionEnd
事件都將不會被觸發。
中文輸入法在鍵入選詞的過程屬於間接輸入情況,此時中間文字不會直接落盤在輸入框內。而通過回車等按鍵退出中文輸入選詞後,中文文字將會落盤到輸入框,此時屬於直接輸入情況。
而我們需要關注的遊標事件顯然是在間接輸入中獲取到的。在輸入法選詞遊標左右移動時,由於內容不變,此時並不會觸發 onInput
事件,但是會觸發一次 onCompositionUpdate
事件,我們可以通過這個事件來判斷遊標位置,重置畫布的遊標位置。但最終我們並未使用這個事件做判斷器,原因在下面會講到。
解決了了遊標的重置時機,接下來就該解決遊標的位置判定了。由於 DOM 標準中並沒有直接獲取遊標位置的方法,因此這一塊也需要我們自主實現。我的思路是,通過選取遊標到輸入起始位置的字串,判斷選中的字串長度,即可知道遊標當前位置相對於起始位置的偏移量,從而確定遊標位置。
對於普通的 input 輸入框來說起始比較簡單,輸入框提供了 inputElement.selectionStart
屬性作為當前遊標位置距離輸入起始點的偏移量,我們直接使用就可以了。但是對於 contentEditable=true
的 div 節點來說是沒有這一屬性的,我們得另想辦法。
根據之前寫 E2E 測試得來的靈感,我們可以模擬建立一個從當前遊標位置到輸入起始位置的選區,通過判斷該選區的字串長度即遊標所在位置的偏移量。通過 window.getSelection()
方法能夠得到 Selection 物件,這是一個表示當前文字選區的物件,由於我們正處在輸入狀態中,因此該選區位置就在當前的輸入框中,從而能獲取到上面所需的偏移量。
const selection = window.getSelection();
// 確定輸入框在輸入態,存在選區
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
return range.endOffset;
}
獲取完遊標位置,還需要在我們的畫布上重新設定回去。設定的思路其實是類似的,通過使用document.createRange
方法新建一個選區範圍,其起始位置設定為需要移動的目標位置,然後移除選區,即可使遊標落在目標位置了。
之前說到在遊標移動時的確會觸發一次onCompositionUpdate
事件。但是,onCompositionUpdate
事件是一個高頻的操作,每一次間接輸入時都會觸發,這會導致遊標不斷地重置位置,帶來不必要的效能損失。
並且,onCompositionUpdate
事件的入參只有更新的中間字串值,只能用來判斷輸入中間字串是否發生變化。移動遊標行為本身並不會導致字串發生改變,但反過來,使字串不發生改變的操作一定是移動遊標操作這一說法並不成立。因此,儘管移動遊標會觸發該事件,但我們仍然沒有有效的手段去判斷是輸入法中的遊標移動導致的事件觸發。
那麼,之前用很大篇幅講過遊標變動的本質實際上是選區變化,那麼,輸入法觸發的遊標移動會不會給輸入框發出選區變更通知呢?很不幸,目前絕大多數的輸入法都是不支援的。並且由於遊標移動被視為輸入法內部的行為,因此在輸入框中游標所進行的移動,不會有事件主動丟擲。因此,輸入框中的選區變更事件 onSelectionChange
事件也無法被觸發。
既然輸入框中的事件監聽無法準確判斷遊標的移動,我們只能退而求其次,從更低層次的邏輯,通過監聽鍵盤的按鍵輸入來嘗試還原這一行為了。優化思路是這樣的,觸發遊標跟隨的時機規則為:使用者輸入時,若使用了左方向鍵移動遊標,將會開啟遊標跟隨的能力,隨著輸入不斷更新的遊標位置,直到遊標再次被移動到末尾位置結束。由於中文輸入時按下左方向鍵的行為是一個低頻操作,這樣一來,大部分的輸入操作都不需要執行判斷並重置遊標,提高普通輸入下的效能表現。
附上最終的判斷邏輯吧:
那麼,如何獲取並判斷使用者輸入時的按鍵資訊呢?當然是使用更第一層級的事件介面 KeyboardEvent 了。
KeyboardEvent 在低層級下提示使用者與一個鍵盤按鍵的互動是什麼,不涉及這個互動的上下文含義。一般來說當你需要處理文字輸入的時候,應當使用上節所說的輸入框監聽事件代替。例如當用戶使用其他方式輸入文字時,如平板電腦的手寫系統等,鍵盤事件可能不會觸發。
KeyboardEvent 物件描述了使用者與鍵盤的互動。 每個事件都描述了使用者與一個按鍵(或一個按鍵和修飾鍵的組合)的單個互動;事件型別 keydown,keypress 與 keyup 用於識別不同的鍵盤活動型別。
鍵盤輸入事件的設計思路與間接輸入的勾點類似,瀏覽器中對於鍵盤輸入同樣分為 onKeyDown
, onKeyPress
, onKeyUp
三個階段的事件觸發,分別對應按鍵不同的行為觸發時機。(注:onKeyPress
事件高度依賴裝置支援,所以儘量不要使用該勾點)
這三個事件都傳入了 KeyboardEvent 入參,幫助我們瞭解當前執行該事件時觸發的按鍵資訊。MDN 上該入參具有如下屬性支援:
在檔案規範中,我們可以發現許多對問題的解決十分有用的新屬性,例如 event.isComposing
屬性用於判斷當前是否會觸發 onCompositionUpdate
事件,event.code
用於判斷與鍵盤佈局與輸入狀態無關的當前按鍵輸入,獲取中文輸入中的按鍵輕而易舉。我們可以利用這兩個狀態幫助我們完成按鍵監聽與事件觸發。
之前說過, KeyboardEvent 是一個十分依賴軟硬體支援的事件,不僅需要瀏覽器的能力支援,與輸入法甚至鍵盤型別都有關係。經試驗後發現,這些新屬性在許多瀏覽器與輸入法的組合中都無法通過onKeyDown
正確獲取,在 Windows 下部分中文輸入法甚至都無法支援 event.key
屬性。為了達到最大的相容性,在兜底的方法下,僅能用 event.keyCode
這種已經被 deprecated 的方法來勉強替代使用了。
兜底方案的使用問題就此解決了嗎?並沒有。中文拼音的輸入中間字元是系統無法識別的。在 Windows 桌面應用程式對鍵盤輸入規範中,我們發現 Windows 將所有未識別的裝置輸入都設定為 VK_PROCESSKEY 229
,瀏覽器的 event.keyCode
複用了這一規範,因此在中文輸入過程中,無論按下什麼按鍵,返回的 event.keyCode
永遠是 229。
網上對於該問題的解決方案都是建議使用 onKeyUp
代替 onKeyDown
。但首先,這不滿足對於一個要求實時體現輸入的遊標移動操作要求。第二,使用 onKeyUp
會有更多的問題,在 Windows 下進行中文輸入時,由於不同的輸入法回撥 onKeyUp
的實現不同,該事件可能會被觸發一次或兩次,要麼全為 229,要麼一次為 229,另一次為正確的 key(對,說的就是你,搜狗)。為了避免我們去不斷去填五花八門的第三方輸入法實現的坑,兜底方案採用了當檢測到輸入了未識別的按鍵時,也啟用遊標跟隨能力。
一套操作下來,這套中文輸入法下游標跟隨的功能算是完美實現了。回顧一下我們解決這個問題所趟過的坑,實際上也反映著瀏覽器 JS DOM 標準在不斷進化,不斷補足歷史遺留的坑點。當然,它還遠遠稱不上完美,仍然存在大量的能力缺失,如我們在這個問題中遇到的判斷遊標偏移量的解決方案,本質上還是一種 hack。而擴充套件 JS 的能力邊界,使其變得更強大,更好用,這正是我們作為前端開發人員需要努力的方向。