在格式化的場景下,React input 的光標的處理辦法

今天要來說的是有關於有數值格式化的場景中,React input 光標的一些異常的表現和對應的處理辦法。故事要從一個 issue 說起,有用戶反映在使用 NumberField 組件輸入時安卓下會出現光標位置異常,導致連續輸入會達不到期望的結果。具體表現是什麼樣的呢?

圖1 安卓下不期望的輸入行爲

可以看到,在安卓手機下每次格式化發生的時候,本來應該一直在最後的光標會錯格一位,導致連續輸入出現問題。而這個問題在 PC Chrome 和 iOS 上都沒有出現,於是可以判定是一個兼容性問題。但這個兼容性問題是如何產生的呢?

分析一下格式化的話的過程,如上面的情況,輸入 18758 時,因爲要做針對卡號的格式化,所以會將原有的值轉變爲 "1875 8",從字符串長度上來看,從 5 位變成了 6 位,那麼如果此時光標位置沒有在值變化時跳到最後一位,則會停留在空格處,看起來就好像錯格了一位,連續輸入時就會有問題。

單從輸入框的光標變化行爲來看,這好像也不算是一種異常的變化,只是不響應值的變化跳到尾部而已。但引申出來的問題是爲什麼在 iOS 和 PC Chrome 下又會跳動到尾部呢。

圖2: 相同的代碼在 PC Chrome 下表現與安卓不同。

於是去網上搜索,輾轉在 React 的 github 中找到這樣一個 issue, Cursor jumps to end of controlled input。在這裏 React 的主要維護者之一的 @sophiebits(spicyj) 給出了一個比較確切的答案

圖3 sophiebits 關於 React controlled input value 變化時光標行爲的解釋

原來因爲 value 的變化具有非常大的不確定性,因此 React 無法使用一種可靠且通用的邏輯去保存光標的位置在一個合適的位置,因此 React 在受控模式下的重新渲染都會時光標移動到最後的位置。這個至少解釋了PC Chrome 和 iOS 下光標跳動到結尾的原因,但安卓下爲什麼沒有表現出同樣的行爲到目前位置我還沒有找到合理的解釋。

那有沒有辦法使安卓上的表現和 iOS 中一致呢?又是一陣翻閱和嘗試,最後發現如果將重新渲染的過程和 input 的 onChange 置於前後兩個 tick 中就可以使安卓中 input 的表現和其他平臺上表現一致,即表現爲光標在重新渲染時跳到最後,示意代碼如下。

import React from 'React';
class Demo extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            value: 'xxx',
        };
    }
    
    handleChange(e) {
        const value = e.target.value;
        // 通過 setTimeout 異步
        // 使 re-render 和 onChange 處於兩個 tick 中
        setTimeout(() => {
            this.setState({
                value,
            });
        });
    }
    
    render() {
        return (
            <input 
                value={this.state.value} 
                onChange={(e) => { this.handleChange(e); }}  
            />
        );
    }
}

這樣終於使得表現的行爲在安卓和 iOS 上表現一致,並且正常輸入的情況下表現得比較符合期望了,然而等等,這樣就可以了嗎?從之前的 React issue 中得出的結論可以看出,無論是如何的修改都會跳至 input 的結尾,這樣如果是從中間修改的話會變成什麼樣?

圖4:中間編輯時又會出現問題

從上面的圖裏可以看出,因爲 React 無論何種修改都會將光標置尾,如果從中間進行修改,那麼表現地又會很不符合用戶預期,沒有辦法做到連續輸入。這回倒是兩端行爲保持一致,都是不期望的狀態。。

但是都不正常也有好處,不需要根據平臺去寫一些 ifelse,可以統一地去做處理。從上面的討論中我們可以知道 React 沒有保存光標的位置是因爲沒有一個通用並且可靠的算法去支撐這一行爲。這是因爲 input 的變化可能是增加空格做格式化,也可能是過濾過些字符,也可能是觸發某些條件直接變成了其他字符等各種無法預測的變化行爲。但是細化到數字格式化這一單一場景時,光標位置的保存邏輯就變得簡單和清晰的多了。

在用戶輸入的過程中,只存在兩種情況,在結尾中追加和在中間修改。在結尾追加的 case 中,例如 18758^ 時,由於一直是在向後追加的狀態,我們只要一直保持光標在最後即可(即默認狀態 1875 8^ ),在中間編輯的 case 下,光標並不處於結尾,如 187^5 8,此時如果在 7 後面追加了一個 8,那麼理想的圖標應該維持在 8 之後(即 1878^ 58),此時就應該保存光標的位置在上次 format 之前的狀態。

邏輯清楚了,接下來就是如何實現的問題了。那麼如何探測和修改光標位置呢?這就涉及了 input 中選區相關的屬性,我們知道我們可以通過一些方式(如鼠標拖拽和長按屏幕等)在 input 中完成對一段話的選區,因此光標的位置其實是由選區的開始點(selectionStart)和結束點(selectionEnd)決定的。那麼其實我們就可以通過讀取,儲存和設置這兩個屬性來達到我們想要實現的目的,實例代碼如下。

class Demo extends React.Component {
    ...
    
    componentDidUpdate(prevProps) {
        const { value } = prevProps;
        const { inputSelection } = this;
        if (inputSelection) {
          // 在 didUpdate 時根據情況恢復光標的位置
          // 如果光標的位置小於值的長度,那麼可以判定屬於中間編輯的情況
          // 此時恢復光標的位置
          if (inputSelection.start < this.formatValue(value).length) {
            const input = this.input;
            input.selectionStart = inputSelection.start;
            input.selectionEnd = inputSelection.end;
            this.inputSelection = null;
          }
        }
    }
    
    handleChange(e) {
        // 在 onChange 時記錄光標的位置
        if (this.input) {
          this.inputSelection = {
            start: this.input.selectionStart,
            end: this.input.selectionEnd,
          };
        }
        ...
    }
    
    render() {
        return (
            <input 
                ref={(c) => { this.input = c; }}
                value={this.state.value} 
                onChange={(e) => { this.handleChange(e); }}  
            />
        );
    }
}

至此,我們終於在追加和中間編輯的情況下都實現了我們想要的效果。這是一個比較小的技術點,但是由於裏面涉及了一些 React 內部的處理邏輯及平臺差異性問題,排查和解決起來並不是那麼容易,希望可以給有類似問題的同學在處理時有所啓發。

文中涉及的各端及瀏覽器信息

  • Android
Mozilla/5.0 (Linux; U; Android 8.1.0; zh-CN; CLT-AL00 Build/HUAWEICLT-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.108 UCBrowser/11.9.4.974 UWS/2.13.1.48 Mobile Safari/537.36 
  • iOS
Mozilla/5.0 (iPhone; CPU iPhone OS 11_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15F79
  • PC Chrome
Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36

文中涉及的組件庫

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章