前言
Input组件大家都用过,是吧,但是你有没有想过这样一个场景,如下图,我正在搜索数据
你组件上注册了onChange事件,然后边输入,底下会显示你搜索相关的内容,
但是有一个问题就是,输入中文的时候,你比如打三国的三字,要先输入san然后才出现三
可问题来了,onChange事件监听的是san,已经开始搜索了,其实我们根本不想这样,我们想的是中文的话,等中文显示在输入框,也就是输入完"三"这个字的时候,才搜索。这个咋办
这里最最麻烦的是,当你输入的时候,需要远程搜索后端接口,这会导致非常多无用的搜索,加上请求后端接口本身就是异步的,意味着a,b,c三个请求发出去,返回的顺序也不一定,这种还是挺糟糕的体验的。
异步问题我们暂且不谈,一般用debounce去解决,这种方式不是治本的,我建议最好上rxjs,一个操作符就解决了。
解决方案
这种问题英文输入法是不会出现的,所以参考国外的组件库是不行的,拿国内来说
- ant design、semi design是完全不管这个问题的,所以你在远程搜索的时候,交互很糟糕
- tdesign 处理了此问题,但在非chrome浏览器下,会触发两次onChange,也就是中文输入会执行两次远程搜索,我觉得这个体验也不好,但起码用户侧能轻松解决,就是对比两次搜索值是否相同,不同才去搜索
- 字节arco design 完美解决这个问题
现在我们把这个问题的解决思路先描述一下。这里涉及到两个事件Compositionstart和Compositionend事件。
Compositionstart和Compositionend事件是啥
compositionstart
事件在用户开始进行非直接输入的时候触发,而在非直接输入结束,也即用户点选候选词或者点击「选定」按钮之后,比如按回车键,会触发 compositionend
事件。
举个例子,还是上面输入三这个字的过程,当你输入s的时候,已经打开了中文输入法,此时compositionstart事件触发了,当你输入完三并且确认的时候,compositionend事件触发。
还有一个compositionupdate事件, 此事件触发于字符被输入到一段文字的时候,如在用户开始输入拼音到确定结束的过程都会触发该事件。
所以说compositionstart
与compositionend
都只会会被触发一次,而compositionupdate
则是有可能多次触发。
基本解决思路 非受控组件
可以利用CompositionStart作为一个信号,如果z正在输入中文,change
事件中的代码就先不要运行,等compositionend
触发时,接着的change
事件才可以运行其中的代码。
示例代码如下:
首先Input组件如下
javascript
<input
value={innerValue}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onChange={handleChange}
/>
然后我们看下value属性的formatDisplayValue是什么
javascript
// 用来记录此时是否Compositionstart事件触发了,如果触发就置为true
// Compositionend结束就置为false
const composingRef = useRef(false);
const [composingValue, setComposingValue] = useState<string>('');
// 如果启动了中文输入法,那么innerValue就是composingValue
// composingValue就是中文输入的时候比如"三国",你输入从"s"到"sanguo",此时innerValue都是composingValue
// 除了中文输入法外,innerValue都是value
const innerValue = composingRef.current ? composingValue : value ?? '';
上面可以看到innerValue是最终渲染给input框的value,用户一般通过onChange事件获取值,所以 我们在中文输入的时候,只要不触发onChange事件是不是就好了!
关键啊!最重要的知识点就是Compositionstart事件触发了,代表正在输入中文,那么onChange事件就不要触发,所以我们接着把事件的代码补上
JAVASCRIPT
// 开始输入中文的时候把 composingRef.current 置为true
function handleCompositionStart(e: React.CompositionEvent<HTMLInputElement>) {
composingRef.current = true;
const {
currentTarget: { value },
} = e;
}
// 中文输入完毕,把composingRef.current置为false,并把此时输入完的值给handleChange(handleChange会触发onChange)
function handleCompositionEnd(e: React.CompositionEvent<HTMLInputElement>) {
if (composingRef.current) {
composingRef.current = false;
handleChange(e);
}
}
function handleChange(e: React.ChangeEvent<HTMLInputElement> | React.CompositionEvent<HTMLInputElement>) {
let { value: newStr } = e.currentTarget;
// 当中文输入的时候,不触发onChange事件,触发setComposingValue只是为了让输入框同步你输入的text
if (composingRef.current) {
setComposingValue(newStr);
} else {
// 完成中文输入时同步一次 composingValue
setComposingValue(newStr);
// 中文输入完毕,此时触发onChange
onChange(newStr, { e });
}
}
你以为就解决了?太年轻了,兄嘚!
其他浏览器不会有问题,但谷歌浏览器却不行。这里要注意的是谷歌浏览器跟其他浏览器的执行顺序不同:
谷歌浏览器: compositionstart -> onChange -> compositionend
其他浏览器: compositionstart -> compositionend -> onChange
所以上述代码运行在谷歌浏览器的话,会有什么问题呢?一开始中文输入我们就将 composingRef.current 设置为 true,最后一步 compositionend 方法我们才将 composingRef.current 恢复为 false,而 onChange 已经执行完了, 按这个逻辑中文输入法打字都改不了 input 的 value 值。
所以有的同学就会说,那么就专门对谷歌浏览器做一次处理就好了,例如判断是否为谷歌浏览器,在 compositionend 方法最后再执行一次 onChange 方法
这就解决了吗,no! 非受控属性如何解决?
上面的onChange,我们使用的是react合成事件的onChange,但是用户如果想自己传入value,让input组件变为受控形态,此时我们上面的代码,onChange事件依然会触发,Compositions事件也会触发。
此时我们该如何处理,解决方法是拦截onChange事件,如果用户外界传入了value,我们就只用外面的value,并且不让onChange事件触发,代码如下:
javascript
const [value, setValue]= useState(props.value)
const onChange = (value, e) => {
if (!('value' in props)) {
setValue(value);
}
};
<input onChange={onChange}>
好像探讨结束了?no!
最后一个边界case,很烦人,比如我们还是最开始的案例,输了san
此时,如果我们按回车会触发键盘的Enter事件,因为一般情况input组件都支持Enter事件处理函数作为props传递给input
问题来了,这种外界传入的Enter事件处理函数的目的一般都是比如校验input框的值,比如格式化input框的值,但是此时我们中文输入法里,这个Enter只是想结束输入,而不是想校验input框的值!
所以我们还要劫持onKeyDown事件,处理一下!
最后
文末我会把arco design封装的useComposition函数分享会出来,有英文注释,很简单的英文。
然后我们再解决一个文章开始说的问题,为什么腾讯的Tdesign在中文输入法下,会造成两次onChange,arco design就能解决呢?
其实很简单,答案在以下代码的 32行。也就是我们每次onChange刷新值的时候,要做一个判断,如果input的框里新的值跟旧值一样,那么就不会触发onChange,这就让虽然触发两次onChange,但是由于第二次onChange的值跟第一次一样,所以第二次onChange就被拒绝了。
useComposition完整代码如下(收藏代码吧吧,很少有库把这个问题处理的很好的):
javascript
import { ChangeEventHandler, CompositionEventHandler, KeyboardEventHandler, useRef, useState } from 'react';
import { InputProps, TextAreaProps } from '../interface';
interface useCompositionProps {
value: string;
maxLength: number;
onChange: InputProps['onChange'];
onKeyDown: InputProps['onKeyDown'] | TextAreaProps['onKeyDown'];
onPressEnter: InputProps['onPressEnter'];
normalizeHandler?: (type: InputProps['normalizeTrigger'][number]) => InputProps['normalize'];
}
/**
* Handle input text like Chinese
* chrome: compositionstart -> onChange -> compositionend
* other browser: compositionstart -> compositionend -> onChange
*/
export function useComposition({ value, maxLength, onChange, onKeyDown, onPressEnter, normalizeHandler }: useCompositionProps): {
compositionValue: string;
triggerValueChange: typeof onChange;
handleCompositionStart: CompositionEventHandler<HTMLInputElement | HTMLTextAreaElement>;
handleCompositionEnd: CompositionEventHandler<HTMLInputElement | HTMLTextAreaElement>;
handleValueChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>;
handleKeyDown: KeyboardEventHandler<HTMLInputElement | HTMLTextAreaElement>;
} {
const refIsComposition = useRef(false);
const [compositionValue, setCompositionValue] = useState('');
const triggerValueChange: typeof onChange = (newValue, e) => {
if (
onChange &&
// Prevents onchange from being triggered twice
newValue !== value &&
(maxLength === undefined || newValue.length <= maxLength)
) {
onChange(newValue, e);
}
};
return {
compositionValue,
triggerValueChange,
handleCompositionStart: (e: any) => {
refIsComposition.current = true;
},
handleCompositionEnd: (e: any) => {
setCompositionValue(undefined);
triggerValueChange(e.target.value, e);
},
handleValueChange: (e: any) => {
const newValue = e.target.value;
if (!refIsComposition.current) {
// if e.type is compositionend event, the following content will trigger
compositionValue && setCompositionValue(undefined);
triggerValueChange(newValue, e);
} else {
refIsComposition.current = false;
setCompositionValue(newValue);
}
},
handleKeyDown: (e: any) => {
const keyCode = e.key;
if (!refIsComposition.current) {
onKeyDown?.(e);
if (keyCode === 'Enter') {
onPressEnter?.(e);
normalizeHandler && triggerValueChange(normalizeHandler('onPressEnter')(e.target.value), e);
}
}
},
};
}