likes
comments
collection
share

探索React中文输入下的惊人问题,你绝对不能错过!

作者站长头像
站长
· 阅读数 21

前言

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

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

探索React中文输入下的惊人问题,你绝对不能错过!

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

探索React中文输入下的惊人问题,你绝对不能错过!

可问题来了,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事件, 此事件触发于字符被输入到一段文字的时候,如在用户开始输入拼音到确定结束的过程都会触发该事件。

所以说compositionstartcompositionend都只会会被触发一次,而compositionupdate则是有可能多次触发。

基本解决思路 非受控组件

可以利用CompositionStart作为一个信号,如果z正在输入中文,change事件中的代码就先不要运行,等compositionend触发时,接着的change事件才可以运行其中的代码。

示例代码如下:

首先Input组件如下

 <input
        value={innerValue}
        onCompositionStart={handleCompositionStart}
        onCompositionEnd={handleCompositionEnd}
        onChange={handleChange}
      />

然后我们看下value属性的formatDisplayValue是什么

// 用来记录此时是否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事件就不要触发,所以我们接着把事件的代码补上

 // 开始输入中文的时候把 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事件触发,代码如下:

  const [value, setValue]= useState(props.value)
  const onChange = (value, e) => {
    if (!('value' in props)) {
      setValue(value);
    }
  };
  <input onChange={onChange}>

好像探讨结束了?no!

最后一个边界case,很烦人,比如我们还是最开始的案例,输了san

探索React中文输入下的惊人问题,你绝对不能错过!

此时,如果我们按回车会触发键盘的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完整代码如下(收藏代码吧吧,很少有库把这个问题处理的很好的):

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);
        }
      }
    },
  };
}