react解决input 限制中文输入长度问题(中文计算为2个字符)

10,420 阅读4分钟

前言:

在做字数限制功能时,我们需要实时获取用户输入的值判断它的字数是否已经超过允许最大值,但是像input 本身提供的maxLength 在计算用户输入长度时将中文也是作为1个字符长度来计算,但是在其他端上中文是用两个byte计算的,这样对接的时候就会出现允许输入超过最大输入长度的情况。

总体思路:

  1. 不能直接使用input 的maxLength, 因为它会将中文计算为一个字符长度。
  2. 为了实现将中文计算为2个字符长度+输入框中最多只能有maxLength 个字符的效果,我们需要实时监听用户输入的长度,其中中文计算为2个字符,非中文计算为1个字符。
  3. 当用户输入长度超过允许输入最大值时,截取用户输入的前最大允许输入值个字符;

分析:

1. 判断是否输入中文

  • 当用户使用拼音输入法输入时,我们会发现 onChange/onInput 取得的值是拼音值,但是很明显,我们需要计算的是用户输入的中文值的长度,而不是拼音值的长度。所以这里需要解决使用拼音输入法时会取得拼音值的问题。

    通过查阅资料,我们可得知在输入中文(包括语音识别时)会先后触发compositionstart、compositionend事件,类似于keydown和keyup的组合。
    compositionstart: 文本合成系统如 input method editor(即输入法编辑器)开始新的输入合成时会触发 compositionstart 事件。例如,当用户使用拼音输入法开始输入汉字或者使用语音输入时,这个事件就会被触发
    compositionend:当文本段落的组成完成或取消时, compositionend 事件将被触发 (具有特殊字符的触发, 需要一系列键和其他输入, 如语音识别或移动中的字词建议)。例如,当用户使用拼音输入法输入汉字或者使用语音输入完毕或者取消时,这个事件就会被触发。

    因此我们可以声明一个标记flag,在compositionstartcompositionend两个事件过程之间的时候flag值为false,在input onChange事件中通过flagisInChinese的值来判断当前输入的状态:
    • 如果当前在输入非中文的情况下,直接截取前maxLen个长度的字符;
    • 如果当前在输入中文的情况下,等到选词完成触发onCompositionEnd,通过e.target.value拿到值,再截取前maxLen个长度的字符 image.png image.png

2. 触发onCompositionEnd

  • 但是这里出现了一个问题,onCompositionEnd事件没有在输入拼音完成后触发。

    按照MDN范例, 使用原生 input可以触发onCompositionEnd,发现onCompositionEnd 事件是在onChange触发后才会出现的,因为我们劫持了input 的value属性,所以我们要setValue后才会触发onChange, 进而触发onCompositionEnd
    触发顺序:onCompositionStart -> onChange -> onCompositionEnd

    image.png

3. 移动光标位置

  • 到上面为止就可以正常使用限制输入长度,其中中文计算为2个字符的功能了,但是在验证中,发现了一个新的问题:当我们从在输入框中间插入新的输入,并且当前输入长度===最大允许输入长度时,光标会自动跳到最右边。那么我们需要解决的就是调整输入框光标的位置;

    通过阅读文档了解到,我们可以利用input的selectionStart属性获取当前光标的位置,以及利用setSelectionRange方法,这个方法是用于设定<input> <textarea> 元素中当前选中文本的起始和结束位置。当起始位置和结束位置相同时,就能实现移动光标的效果。
    • 我们首先在结束中文输入后,并且在更新值之前获取元素的光标位置oldInputSelectionPos

    • 然后在更新值之后,去获取当前光标位置currentInputSelectionPos,比较两个值,如果旧位置<=当前位置,则设置为旧位置,否则设置为当前位置。注意,这里需要确定视图更新后,采取获取currentInputSelectionPos。这里我使用了process.nextTick保证视图更新后才执行块代码

    image.png

完整代码:

import React, { useRef, useState, useEffect } from 'react';

interface IValidateItem {
  validateFn: (value: string) => boolean;
  errMsg: string;
}

interface ILimitByteInputProps {
  autoFocus?: boolean;
  validatorRules?: IValidateItem[];
  defaultValue?: string;
  maxLength?: number;
  inputStyle?: React.CSSProperties;
  readOnly?: boolean;
  countChineseAsTwoBytes?: boolean;
  handleCompleteInput?: (value?: string) => void;
  handleValidateError?: (errMsg?: string) => void;
  handleValidateSuccess?: (value: string) => void;
  limitInputPattern?: (value: string) => void;
}

export default function LimitByteInput(props: ILimitByteInputProps) {
  const {
    autoFocus = false,
    validatorRules,
    defaultValue = '',
    maxLength = 50,
    inputStyle = {},
    readOnly = false,
    handleCompleteInput,
    handleValidateError,
    handleValidateSuccess,
    limitInputPattern,
  } = props;

  const isInChinese = useRef(false);
  const inputRef = useRef<HTMLInputElement>(null);
  const [value, setValue] = useState(defaultValue || '');

  useEffect(() => {
    setValue(defaultValue);
  }, [defaultValue]);

  useEffect(() => {
    if (autoFocus && inputRef.current) {
      inputRef.current.focus();
    }
  }, []);

  function handleOnChange(e: any) {
    let input = e.target.value;
    // 在输入非中文的情况下,截取允许最大输入
    if (!isInChinese.current) {
      setLimitLenInput(input);
      return;
    }
    // 需要触发onChange才会触发onCompositionEnd
    setValue(input);
  }

  // 限制输入长度
  function setLimitLenInputValue(input: string) {
    const inputValueArr = [];
    let inputValueArrIndex = 0;
    for (let i = 0; i < input.length; i++) {
      const char = input.charAt(i);
      // 判断是否为中文
      const isChineseChar = /[\u4e00-\u9fa5]/.test(char);
      if (isChineseChar) {
        inputValueArr[inputValueArrIndex++] = '';
      }
      inputValueArr[inputValueArrIndex++] = char;
    }
    // 截取用户输入前maxLength 个字符数
    const result = inputValueArr.slice(0, maxLength).join('');
    setValue(result);
  }

  // 设置光标的新位置: 如果旧位置<=当前位置,则设置为旧位置, 否则为当前位置
  function setLimitLenInputSelectionPos(oldInputSelectionPos: any) {
    // 需要等dom更新后才去获取当前输入框中光标的位置,这里使用微任务process.nextTick在一次事件循环中完成执行块代码
    process.nextTick(() => {
      const currentInputSelectionPos = inputRef.current!.selectionStart;
      if (!oldInputSelectionPos || !currentInputSelectionPos) {
        return;
      }
      if (oldInputSelectionPos <= currentInputSelectionPos) {
        inputRef.current!.setSelectionRange(oldInputSelectionPos, oldInputSelectionPos);
        return;
      }
    });
  }

  // 输入非中文或者中文完成拼音输入后触发
  function setLimitLenInput(input: string) {
    const oldInputSelectionPos = inputRef.current!.selectionStart;
    setLimitLenInputValue(input);
    setLimitLenInputSelectionPos(oldInputSelectionPos);
  }

  function onBlur() {
    onCompleteInput(value);
  }

  function onKeyDown(e: any) {
    if (e.keyCode === 13) {
      onCompleteInput(value);
    }
  }

  function validateValue(value: string) {
    if (validatorRules && validatorRules.length > 0) {
      for (let index = 0; index < validatorRules.length; index++) {
        const item = validatorRules[index];
        if (!item.validateFn(value)) {
          return item.errMsg;
        }
      }
    }
    return '';
  }

  function onCompleteInput(value: string) {
    handleCompleteInput && handleCompleteInput(value);
    const errMsg = validateValue(value);
    if (errMsg) {
      handleValidateError && handleValidateError(errMsg);
      return;
    }
    handleValidateSuccess && handleValidateSuccess(value);
  }

  // 当用户使用拼音输入法开始输入汉字触发
  function handleCompositionStart() {
    isInChinese.current = true;
  }

  // 当用户使用拼音输入法输入汉字或者使用语音输入完毕或者取消时触发
  function handleCompositionEnd(e: any) {
    isInChinese.current = false;
    const input = e.target.value;
    setLimitLenInput(input);
  }

  return (
    <input
      spellCheck={false}
      ref={inputRef}
      onChange={handleOnChange}
      onBlur={onBlur}
      onKeyDown={onKeyDown}
      value={value}
      maxLength={maxLength}
      onCompositionStart={handleCompositionStart}
      onCompositionEnd={handleCompositionEnd}
      style={inputStyle}
      readOnly={readOnly}
    />
  );
}