组件仍在补充, 欢迎加群交流哦,微信: a2298613245
headless ui
✕
组件还在补充中,希望能够帮助你有一个亮点项目放入简历!
全局方案
按钮 Button
图标 Icon
布局 Grid
间距 Space
输入框 Input
弹窗 Modal
弹出框 Popover
消息 Toast
警告 Alert
单选框 Radio
复选框 Checkbox
标签 Tag
其它组件
必读指南
如何自定义 Input
CSS / Less / Sass Input
Tailwind Input
Input 完整案例
传统案例
创意案例

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

input

前言

Input 组件大家都用过,是吧,但是你有没有想过这样一个场景,如下图,我正在搜索数据

同时组件上注册了 onChange 事件,然后边输入,底下会显示你搜索相关的内容,

input

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

input

可问题来了,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 />
前言
解决方案
Composition 事件
受控和非受控组件
受控组件
非受控组件
基本解决思路
你以为就解决了? NO!
还没解决?
好像探讨结束了?no!
最后
附录
安装 input
引入和使用 input