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