探索 React 中文输入下的惊人问题,你绝对不能错过!Input 组件难点解析

前言
Input 组件大家都用过,是吧,但是你有没有想过这样一个场景,如下图,我正在搜索数据
同时组件上注册了 onChange 事件,然后边输入,底下会显示你搜索相关的内容,

但是有一个问题,输入中文的时候,比如你输入三国的 三 字,要先输入 san 然后才出现汉字 三

可问题来了,onChange 事件监听的是 san,只要 onChange 事件触发,就会开始向后端请求数据,那么输入 san 意味着,在 s, sa 和 san 的时候,分别发了三次请求。
其实我们根本不想这样,我们想的是等中文显示在输入框,也就是输入完 三 这个字的时候才搜索。加上请求后端接口本身就是异步的,意味着 s, sa 和 san 三个请求发出去,返回的顺序也不一定,所以这是一个很严重的问题。
异步问题我们暂且不谈,一般用 debounce 去解决,这种方式不是治本的,我建议最好上 rxjs(或者自己封装一个能取消之前请求操作的工具函数)来从根上解决这个问题。
解决方案
这种问题在英文输入法是没有任务问题的,所以参考国外的组件库是不行的,拿国内来说
- 阿里的
ant design、字节的semi design是完全不管这个问题的,所以你在远程搜索的时候,交互很糟糕 - 腾讯的
tdesign处理了此问题,但在非chrome浏览器下,会触发两次onChange,也就是中文输入会执行两次远程搜索,我觉得这个体验也不好,但起码用户侧能轻松解决,就是对比两次搜索值是否相同,不同才去搜索 - 字节
arco design完美解决这个问题
现在我们把这个问题的解决思路先描述一下。这里涉及到两个事件 Compositionstart 和 Compositionend 事件。
Composition 事件
我们主要借助 Composition 事件中的 compositionstart 和 compositionend 事件来解决这个问题。
compositionstart 事件在用户开始进行非直接输入的时候触发(非直接输入就是上面提到的 san 这个过程),而在非直接输入结束,也即用户点选候选词或者点击 选定 按钮之后,比如按 回车键,会触发 compositionend 事件。
举个例子,还是上面输入三这个字的过程,当你输入 s 的时候,已经打开了中文输入法,此时 compositionstart 事件触发了,当你输入完三并且确认的时候,compositionend事件触发。
还有一个compositionupdate事件, 此事件触发于字符被输入到一段文字的时候,如在用户开始输入拼音到确定结束的过程都会触发该事件。
所以说compositionstart与compositionend都只会会被触发一次,而compositionupdate则是有可能多次触发。
受控和非受控组件
讲后面的知识点之前,我们需要铺垫一个基础知识,就是了解什么是 受控 和 非受控组件。我们就那 input 组件举例。
受控组件
受控组件的值是由 React 来管理的,每次输入触发 onChange 事件。
1function ControlledInput() {
2 const [value, setValue] = useState('');
3
4 return (
5 <input
6 value={value} // React 通过 state 控制值
7 onChange={(e) => setValue(e.target.value)} // 每次输入时更新 state
8 />
9 );
10}特点:
value完全由React 状态驱动。- 任何用户输入都会触发
onChange事件,然后更新state。 - React 再通过
value={state}渲染出新值。
注意:
- 如果设置了
value却 没有onChange,React 会发警告。 - 一旦组件是受控的,就不能再变回非受控(反之亦然)。
非受控组件
受控组件的值是由 DOM 来管理的,使用 defaultValue 初始化,通过 ref 读取值。
1function UncontrolledInput() {
2 const inputRef = useRef();
3
4 const handleSubmit = () => {
5 alert(inputRef.current.value); // 从 DOM 读取值
6 };
7
8 return (
9 <>
10 <input defaultValue="hello" ref={inputRef} />
11 <button onClick={handleSubmit}>提交</button>
12 </>
13 );
14}特点:
- 使用
defaultValue设定初始值。 - 输入变化由
DOM自己维护,React不干涉。 - 用
ref访问当前输入值
但这里因为我们是想自己控制是否触发 onChange 事件, 在非受控状态是无法做到的,所以我们内部的 input 组件其实最终无论外界是用受控还是非受控的方式,我们都转化为了受控组件。
基本解决思路
以利用CompositionStart作为一个信号,如果正在输入中文,change事件中的代码就先不要运行,等compositionend触发时,接着的change事件才可以运行其中的代码。
示例代码如下:
首先 Input 组件如下:
1<input
2 value={innerValue}
3 onCompositionStart={handleCompositionStart}
4 onCompositionEnd={handleCompositionEnd}
5 onChange={handleChange}
6/>然后我们看下value 属性的innerValue 是什么。
1// 用来记录此时是否Compositionstart事件触发了,如果触发就置为true
2// Compositionend结束就置为false
3const composingRef = useRef(false);
4
5const [composingValue, setComposingValue] = useState<string>('');
6// 如果启动了中文输入法,那么 innerValue 就是 composingValue
7// composingValue 就是中文输入的时候比如 “三国”,你输入从 “s” 到 “sanguo”,此时 innerValue 都是 composingValue
8// 除了中文输入法外,innerValue 都是 value
9const innerValue = composingRef.current ? composingValue : value ?? '';上面可以看到 innerValue 是最终渲染给 input 框的 value,用户一般通过 onChange 事件获取值,所以我们在中文输入的时候,只要不触发 onChange 事件,是不是就好了!
1// 开始输入中文的时候把 composingRef.current 置为 true
2function handleCompositionStart(e: React.CompositionEvent<HTMLInputElement>) {
3 composingRef.current = true;
4 const {
5 currentTarget: { value },
6 } = e;
7 }
8 // 中文输入完毕,把 composingRef.current 置为 false,并把此时输入完的值给 handleChange(handleChange 会触发 onChange)
9 function handleCompositionEnd(e: React.CompositionEvent<HTMLInputElement>) {
10 if (composingRef.current) {
11 composingRef.current = false;
12 handleChange(e);
13 }
14 }
15
16function handleChange(e: React.ChangeEvent<HTMLInputElement> | React.CompositionEvent<HTMLInputElement>) {
17 let { value: newStr } = e.currentTarget;
18 // 当中文输入的时候,不触发 onChange 事件,触发 setComposingValue 只是为了让输入框同步你输入的 text
19 if (composingRef.current) {
20 setComposingValue(newStr);
21 } else {
22 // 完成中文输入时同步一次 composingValue
23 setComposingValue(newStr);
24 // 中文输入完毕,此时触发 onChange
25 onChange(newStr, { e });
26 }
27 }你以为就解决了? NO!
其他浏览器不会有问题,但谷歌浏览器却不行。这里要注意的是谷歌浏览器跟其他浏览器的执行顺序不同:
- 谷歌浏览器:
compositionstart->onChange->compositionend - 其他浏览器:
compositionstart->compositionend->onChange
所以上述代码运行在谷歌浏览器的话,会有什么问题呢?一开始中文输入我们就将 composingRef.current 设置为 true,最后一步 compositionend 方法我们才将 composingRef.current 恢复为 false,而 onChange 已经执行完了, 按这个逻辑中文输入法打字都改不了 input 的 value 值。
所以有的同学就会说,那么就专门对谷歌浏览器做一次处理就好了,例如判断是否为谷歌浏览器,在 compositionend 方法最后再执行一次 onChange 方法
还没解决?
我们之前说了,要把 input 的受控和非受控都内部都转为 受控的形式,所以外部不传 value 的时候,我们依然要在内部转化为 value 属性的值。怎么做呢,其实就是拦截 onChange 事件,我们用自己的 setState 来更新 value 属性的值。
1const [value, setValue]= useState(props.value)
2const onChange = (value, e) => {
3 if (!('value' in props)) {
4 setValue(value);
5 }
6 props.onChange?.(value, e);
7};
8<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 完整代码如下(收藏代码吧吧,很少有库把这个问题处理的很好的):
1import { ChangeEventHandler, CompositionEventHandler, KeyboardEventHandler, useRef, useState } from 'react';
2import { InputProps, TextAreaProps } from '../interface';
3
4interface useCompositionProps {
5 value: string;
6 maxLength: number;
7 onChange: InputProps['onChange'];
8 onKeyDown: InputProps['onKeyDown'] | TextAreaProps['onKeyDown'];
9 onPressEnter: InputProps['onPressEnter'];
10 normalizeHandler?: (type: InputProps['normalizeTrigger'][number]) => InputProps['normalize'];
11}
12
13/**
14* Handle input text like Chinese
15* chrome: compositionstart -> onChange -> compositionend
16* other browser: compositionstart -> compositionend -> onChange
17*/
18export function useComposition({ value, maxLength, onChange, onKeyDown, onPressEnter, normalizeHandler }: useCompositionProps): {
19 compositionValue: string;
20 triggerValueChange: typeof onChange;
21 handleCompositionStart: CompositionEventHandler<HTMLInputElement | HTMLTextAreaElement>;
22 handleCompositionEnd: CompositionEventHandler<HTMLInputElement | HTMLTextAreaElement>;
23 handleValueChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>;
24 handleKeyDown: KeyboardEventHandler<HTMLInputElement | HTMLTextAreaElement>;
25} {
26 const refIsComposition = useRef(false);
27 const [compositionValue, setCompositionValue] = useState('');
28
29 const triggerValueChange: typeof onChange = (newValue, e) => {
30 if (
31 onChange &&
32 // Prevents onchange from being triggered twice
33 newValue !== value &&
34 (maxLength === undefined || newValue.length <= maxLength)
35 ) {
36 onChange(newValue, e);
37 }
38 };
39
40 return {
41 compositionValue,
42 triggerValueChange,
43 handleCompositionStart: (e: any) => {
44 refIsComposition.current = true;
45 },
46 handleCompositionEnd: (e: any) => {
47 setCompositionValue(undefined);
48 triggerValueChange(e.target.value, e);
49 },
50 handleValueChange: (e: any) => {
51 const newValue = e.target.value;
52 if (!refIsComposition.current) {
53 // if e.type is compositionend event, the following content will trigger
54 compositionValue && setCompositionValue(undefined);
55 triggerValueChange(newValue, e);
56 } else {
57 refIsComposition.current = false;
58 setCompositionValue(newValue);
59 }
60 },
61 handleKeyDown: (e: any) => {
62 const keyCode = e.key;
63 if (!refIsComposition.current) {
64 onKeyDown?.(e);
65 if (keyCode === 'Enter') {
66 onPressEnter?.(e);
67 normalizeHandler && triggerValueChange(normalizeHandler('onPressEnter')(e.target.value), e);
68 }
69 }
70 },
71 };
72}附录
安装 input
1npm i @t-headless-ui/react
2yarn i @t-headless-ui/react
3pnpm i @t-headless-ui/react引入和使用 input
1import { InputComponent } from '@t-headless-ui/react';
2
3// 使用
4<InputComponent />