React input 中文輸入法兼容

在不少項目中需要針對input的輸入數據做相應的處理,比如講用戶數據的數據轉換成對應的大寫字母,那麼問題就來了。當用戶拿起手機,點中你的input組件後開始輸入時,會發現IOS的手機在中文輸入下會發生重複輸入的情況。

舉例來說,當用戶在中文輸入法下輸入ABC這幾個字母是,input的實際的value值(如果你把input的onChange事件打印出來或是設置成value的話),不是ABC而是AABABC,也就是說用戶輸入的會有緩存,而這在Android手機上沒有出現。

那如何處理這個問題並使得針對IOS也能兼容呢?默認的流程都一樣,預先google一下,會發現主流的方法是讓我們監聽compositionEvent,這是做什麼的呢,可以從字面意思來理解,composition有組成和部分的意思,那麼這個事件的觸發其實就是用戶切換了非英語的輸入法模式後,數據由部分字母比如拼音組合而成時會觸發的事件。

按照MDN上的簡單介紹(確實夠簡單的了)和React官網的事件介紹,compositionEvent在react中共有三個具體的事件:onCompositionEnd onCompositionStart onCompositionUpdate。

接着在本地試了下,發現每次輸入中文都會觸發onCompositionStart和onCompositionUpdate,不過它們都沒有傳遞相關的參數,所以說打印console.log(arguments)都是空的。


方案1:追加中文輸入緩存的末尾字符

針對以上搜集及整理,針對input的兼容可以這樣來實現:


class example extends React.Component{
    constructor(props){
        super(props);
        this.state = {
            value: ''
        }

        this.inputLock = false; // 標記是否處於composition的狀態
    }

    // input事件調用
    onChange(e){
        let value = e.target.value;

        if(this.inputLock){

            // 處於中文輸入狀態,只添加最後一位字符
            this.state.value += value.substr(value.length - 1).toUpperCase();
            this.inputLock = false; // 由於每次輸入中文字符都會觸發onCompositionStart,所以在這裏將其設置爲false,以備下次觸發
        }else {

            // 非中文輸入,則正常操作,這裏的需求是將字符轉換成大寫
            this.state.value = value.toUpperCase();
        }
    }

    render(){
        return (
            <input 
                value={this.state.value}
                onChange={this.onChange.bind(this)}
                onCompositionStart={()=>{this.inputLock = true}}
            />
        )
    }
}

方案1中只用到了onCompositionStart,主要發現在IOS中無法觸發onCompositionEnd事件(還沒經過全面測試,瞭解的同學請賜教),雖然能夠解決中午輸入問題,但是存在一個很明顯的bug,就是用戶每次只能在後面添加內容,如果用戶將指針移到文本中間,則無法在中間添加字符,會發現仍舊在字符串末尾添加,而且當用戶點擊刪除鍵,會發現又自動添加了中文緩存中的內容,所以方案1在實際中還無法使用。


方案2:onKeyDown

class example extends React.Component{
    constructor(props){
        super(props);
        this.state = {
            value: ''
        }

        this.inputLock = false; // 標記是否處於composition的狀態
    }

     onKeyDown(e){

        const {value} = this.state;
        let length = value.length;
        let cursorPos = this.inputRef.selectionEnd; // 獲取當前指針的位置

        // 正常字符按鍵
        if(e.keyCode >= 48 && e.keyCode <= 90){

            if( cursorPos < length){

                this.state.value = [
                    value.substr(0, cursorPos), 
                    String.fromCharCode(e.keyCode),
                    value.substr(cursorPos), 
                ].join('');

            }else {
                value += String.fromCharCode(e.keyCode);

            }

            this.forceUpdate();


        }

        // 回退backspace
        if(e.keyCode === 8){

            if(length){
                value = value.substr(0, length - 1);
                this.forceUpdate();
            }


        }


    }

    render(){
        return (
            <input 
                ref={ref=>this.inputRef=ref}
                value={this.state.value}
                onKeyDown={this.onKeyDown.bind(this)}
                onCompositionStart={()=>{this.inputLock = true}}
            />
        )
    }

爲了解決方案1中的問題,方案2中去掉onChange方案,該用onKeyDown或類似的onKeyPress來重寫回退鍵的邏輯,通過添加input reference 引用來獲取要插入的文本位置,實現後的結果已經可以正常輸入不會有影響,唯一的瑕疵是每次在前面的文本中插入了一個字符,則光標未在插入字符的後面停留,而是直接回到了字符串末尾。


方案3:input reset光標復原

方案2中存在的主要問題是每次插入後,input的輸入光標自動回到文本的末尾,這是一個受控input的組件的通病。因爲每次input的value由state更新後都會reset,然後react diff完後調用dom的相應操作更新,這裏面是一個異步且無法控制的過程。現在比較一種勉強的做法就是通過設置setTimeout等一段時間後實現光標的設置,也就是在dom渲染完了以後,這可能跟不同瀏覽器的渲染性能有關。筆者測試下來最低的更新時間約爲30ms,同時爲了代碼清晰,又對input的類做了重新的組件封裝,以下是代碼Input.js:

/**
 * Input:兼容IOS中文輸入,只支持大寫字母和數字輸入
 */

import React from 'react';

class Input extends React.Component{
    constructor(props){
        super(props);

        this.state = {
            value: ''
        }

    }

    /** 這裏使用了的onKeyDown事件,因爲onChange,
     * 或類似的onInput等事件它會將IOS中文輸入法的緩存字符都放進去,這就導致這樣一個結果:
     * 有趣的是,百度的輸入法就沒有這個問題,Android也沒有,看來很多App針對自家定製化鍵盤還是非常有必要的。
     */
    onKeyDown(e) {

        const {value} = this.state;

        let cursorPos = this.inputRef.selectionEnd
        let length = value.length;
        let valueString = ''; // 最終合成的字符串
        let setCursor = false; // 判斷是否要設置光標,如果默認在末尾添加的話就爲false
        let cursorStep = 1; // 添加字符:1,刪除字符:-1

        // 正常字符按鍵
        if (e.keyCode >= 48 && e.keyCode <= 90) {

            if (cursorPos < length) {

                valueString = [
                    value.substr(0, cursorPos),
                    String.fromCharCode(e.keyCode),
                    value.substr(cursorPos),
                ].join('')

                setCursor = true;

            } else {

                valueString = value + String.fromCharCode(e.keyCode);

            }

        }

        // 回退backspace
        if (e.keyCode === 8) {

            if (length && cursorPos === length) {
                valueString = value.substr(0, length - 1);
            }else {
                valueString = [
                    value.substr(0, cursorPos - 1),
                    value.substr(cursorPos),
                ].join('')
                setCursor = true;
                cursorStep = -1;
            }

        }

        this.setState({
            value: valueString,
        }, () => {

            if(setCursor){

                // 設置光標回覆位置,input 指針在設置value後reset至行尾。dom操作異步,因此光標設置需預估一個時間(30ms),否則失效
                setTimeout(() => {
                    this.inputRef.setSelectionRange(cursorPos + cursorStep, cursorPos + cursorStep)
                }, 30)
            }

            this.props.updateParentState &&
                this.props.updateParentState(this.state);
        })

    }

    render(){

        const { innerRef, ...restOfProps } = this.props;


        return (
            <input
                {...restOfProps}
                ref={ref => {this.inputRef = ref;innerRef(ref)}}
                value={this.state.value}
                onKeyDown={this.onKeyDown.bind(this)}
            />
        )
    }
}

module.exports = Input;

方案3中基本上解決了所有的問題,字符的增加,插入,刪除,選中光標後的刪除等。唯一的遺憾是因爲設置了setTimeout,所以會看到光標在行末尾的閃爍,希望未來有一個徹底的解決方案。


方案4:放棄onKeyDown,使用onInput

方案3還是有光標的閃爍,對於強迫症來說還是不能忍,而且用戶體驗上也大打折扣,人家還以爲你這系統出問題了呢。所以在經過不斷的嘗試後,發現IOS的input當設置完onInput後竟然可以將原本隱藏的中文拼音緩存直接寫到input框中,這樣就提供了一種新的可能:

/**
 * Input:兼容IOS中文輸入,只支持大寫字母和數字輸入
 */

import React from 'react';
import PropTypes from 'prop-types';

class Input extends React.Component{
    constructor(props){
        super(props);

        this.state = {
            value: ''
        }

        this.inputLock = false;

    }

    onKeyDown(e) {

        switch(e.keyCode){
            case 13: 
                this.setState({
                    value: this.inputRef.value.toUpperCase()
                })
                break;
        }

    }

    onInput(){

        this.setState(
            prevState => ({ value: this.inputRef.value}),
            () => {
                this.props.updateParentState &&
                    this.props.updateParentState(this.state);
            }
        );

    }

    render(){

        const { innerRef, updateParentState, ...restOfProps } = this.props;


        return (
            <input
                {...restOfProps}
                ref={ref => {this.inputRef = ref;innerRef(ref)}}
                value={this.state.value}
                onKeyDown={this.onKeyDown.bind(this)}
                onInput={this.onInput.bind(this)}
                onCompositionStart={()=>{this.inputLock=true}}
                onCompositionEnd={()=>{
                    this.inputLock=false;
                    this.setState({
                        value: this.inputRef.value.toUpperCase()
                    })
                }}

            />
        )
    }
}

Input.propTypes = {
    innerRef: PropTypes.func,
    updateParentState: PropTypes.func,
}

Input.defaultProps = {
    innerRef: ()=>{},
    updateParentState: ()=>{},
}

module.exports = Input;

Input組件的目標是在無論中英文的輸入法中,都能輸入大寫的字母或數字,而實現的方法則是:
1. 針對中文輸入法:onInput事件配合compositionEvent,用戶點擊確定時(也就是onCompositionEnd)將文本統一轉換,不然在onInput中設置大寫會導致光標的回滾;
2. 針對英文輸入法,默認如果不操作的話會輸出第一個字母大寫後面小寫的字符串,例如Abcdefg。在哪個時機實現大寫的轉換呢?答案是通過監聽用戶輸入return鍵(keyCode=13)來做大寫的轉換。

總的來說,方案4能基本實現目標需求,但期待原生更好的實現和大家更好更流暢的方案。


相關鏈接

  1. https://github.com/paypal/downshift/issues/217
  2. https://stackoverflow.com/questions/38385936/change-the-cursor-position-in-a-textarea-with-react
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章