在不少项目中需要针对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能基本实现目标需求,但期待原生更好的实现和大家更好更流畅的方案。