:::tip 最近在著手騰訊文檔的輸入體驗優化,在其中有一個不起眼的小需求引起了我的註意,並順便研究了一些事件監聽機制相結合的特點,特此記錄一下填坑過程。 ::: 模擬游標跟隨 大部分的主流輸入法都有這樣一個特性,在輸入中文時,可以通過左右方向鍵控制游標,移動至輸入區中任意兩個字元之間的位置,用戶接 ...
:::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 的能力邊界,使其變得更強大,更好用,這正是我們作為前端開發人員需要努力的方向。