React实现6位OTP输入框

2,966 阅读2分钟

今天pm提了一个需求,需要实现一个6位数字的OTP输入框,每个输入框都是一个单独的格子,于是我打开了vscode...

开始的思路是,用一个隐藏的input来存入实际被react受控的数据,然后再用6个独立的input或者div显示6个格子

大致的结构如下

import React from "react";

const PIN_LENGTH = 6;

export default function InputOTP() {
  return (
    <div>
      <input type="number" pattern="\d*" maxLength={PIN_LENGTH} />
      {Array.from({ length: PIN_LENGTH }).map((_, index) => {
        return <input key={index} />;
      })}
    </div>
  );
}

这里遇到一个小坑,当只设置type='number' 和pattern=“\d{6}”时,键盘仍然显示符号,需要设置为pattern=“\d*”才只会显示纯数字键盘

接着我们来处理样式和数据,先给元素添加一些类名,并使用value来存储输入的数据:

export default function InputOTP() {
  const [value, setValue] = React.useState("");
  
  return (
    <div className={"container"}>
      <input
        className={"hiddenInput"}
        type="number"
        pattern="\d*"
        maxLength={PIN_LENGTH}
      />
      {Array.from({ length: PIN_LENGTH }).map((_, index) => {
        const focus =
          index === value.length ||
          (index === PIN_LENGTH - 1 && value.length === PIN_LENGTH);
        return (
          <input
            className={`pinInput ${focus ? "fucos" : ""}`}
            key={index}
            value={value[index] || ""}
            readOnly={true}
          />
        );
      })}
    </div>
  );
}

样式如下:

.container {
  display: flex;
  width: 100%;
  flex-wrap: nowrap;
  justify-content: center;
}
.hiddenInput {
  width: 0;
  height: 0;
  outline: "none";
  padding: 0;
  border-width: 0;
  box-shadow: "none";
  position: "absolute";
}

.pinInput {
  box-sizing: border-box;
  padding: 0;
  outline: none;
  background-color: transparent;
  width: 36px;
  height: 36px;
  margin: 10px 10px 20px;
  text-align: center;
  border: 1px solid rgb(189, 189, 189);
  border-radius: 3px;
  font-size: 25px;
  font-weight: 500;
}

.fucos {
  border-color: orangered;
  border-width: 2px;
}

可以看到初步的效果:

image-20210926181322635.png

但是现在我们不能输入任何东西,因为input的readOnly被置为true了,我们现在要处理六个小input的点击事件,当点击时我们让隐藏的input聚焦,然后监听隐藏的input的onChange事件,把变化的值存入value,这就是受控组件。还需要处理一些比如左右箭头、空格等特殊按键,清除其默认行为.

最终简易版的代码如下:

const PIN_LENGTH = 6;
const KEYCODE = Object.freeze({
  LEFT_ARROW: 37,
  RIGHT_ARROW: 39,
  END: 35,
  HOME: 36,
  SPACE: 32,
});

export default function InputOTP() {
  const [value, setValue] = React.useState("");
  const inputRef = React.useRef();

  function handleClick(e) {
    e.preventDefault();
    if (inputRef.current) {
      inputRef.current.focus();
    }
  }

  function handleChange(e) {
    const val = e.target.value || "";
    setValue(val);
  }

  // 处理一些键盘特殊按键,清除默认行为
  function handleOnKeyDown(e) {
    switch (e.keyCode) {
      case KEYCODE.LEFT_ARROW:
      case KEYCODE.RIGHT_ARROW:
      case KEYCODE.HOME:
      case KEYCODE.END:
      case KEYCODE.SPACE:
        e.preventDefault();
        break;
      default:
        break;
    }
  }

  return (
    <div className={"container"}>
      <input
        ref={inputRef}
        className={"hiddenInput"}
        type="number"
        pattern="\d*"
        onChange={handleChange}
        onKeyDown={handleOnKeyDown}
        maxLength={PIN_LENGTH}
      />
      {Array.from({ length: PIN_LENGTH }).map((_, index) => {
        const focus =
          index === value.length ||
          (index === PIN_LENGTH - 1 && value.length === PIN_LENGTH);
        return (
          <input
            className={`pinInput ${focus ? "fucos" : ""}`}
            key={index}
            value={value[index] || ""}
            onClick={handleClick}
            readOnly={true}
          />
        );
      })}
    </div>
  );
}

效果如下:

pininput.gif

本以为大功告成,谁知道在测试的时候,PM说为了方便用户,能不能实现粘贴功能?谁让我们是卑微的切图仔呢,不敢反抗,只能默默地又打开了vscode...

如果我们要实现复制粘贴的功能,好像之前的设计就行不通了,因为外面的6个小input仅仅只是为了展示数据用的,是只读的,真正控制数据的是隐藏的input,然而我们又不能将小input的长按事件传递给隐藏的input,如果将小input的readOnly设为false似乎也行不通...

既然这样,我们为什么还需要通过隐藏的input来进行数据控制呢,我们直接使用六个input进行数据控制不就行了吗,这样最关键的问题就是每个input的聚焦时机。我们在组件中存放一个index,表示当前聚焦的input下标,后续所有的操作都基于这个index

最终代码如下:

export default function InputOTP() {
  const [value, setValue] = React.useState("");
  // 用来存放6个input的引用
  const inputsRef = React.useRef([]);
  // 当前聚焦的input的下标
  const curFocusIndexRef = React.useRef(0);

  // 校验value是否有效,仅仅存在数字才有效
  const isInputValueValid = React.useCallback((value) => {
    return /^\d+$/.test(value);
  }, []);

  // 聚焦指定下标的input
  const focusInput = React.useCallback((i) => {
    const inputs = inputsRef.current;
    if (i >= inputs.length) return;
    const input = inputs[i];
    if (!input) return;
    input.focus();
    curFocusIndexRef.current = i;
  }, []);

  // 聚焦后一个input
  const focusNextInput = React.useCallback(() => {
    const curFoncusIndex = curFocusIndexRef.current;
    const nextIndex =
      curFoncusIndex + 1 >= pinLength ? pinLength - 1 : curFoncusIndex + 1;
    focusInput(nextIndex);
  }, [focusInput]);

  // 聚焦前一个input
  const focusPrevInput = React.useCallback(() => {
    const curFoncusIndex = curFocusIndexRef.current;
    let prevIndex;
    if (curFoncusIndex === pinLength - 1 && value.length === pinLength) {
      prevIndex = pinLength - 1;
    } else {
      prevIndex = curFoncusIndex - 1 <= 0 ? 0 : curFoncusIndex - 1;
    }
    focusInput(prevIndex);
  }, [focusInput, value]);

  // 处理删除按钮
  const handleOnDelete = React.useCallback(() => {
    const curIndex = curFocusIndexRef.current;
    if (curIndex === 0) {
      if (!value) return;
      setValue("");
    } else if (curIndex === pinLength - 1 && value.length === pinLength) {
      setValue(value.slice(0, curIndex));
    } else {
      setValue(value.slice(0, value.length - 1));
    }
    focusPrevInput();
  }, [focusPrevInput, value]);

  const handleOnKeyDown = React.useCallback(
    (e) => {
      switch (e.keyCode) {
        case KEYCODE.LEFT_ARROW:
        case KEYCODE.RIGHT_ARROW:
        case KEYCODE.HOME:
        case KEYCODE.END:
        case KEYCODE.SPACE:
          e.preventDefault();
          break;
        // 当点击删除按钮
        case KEYCODE.BACK_SPACE:
          handleOnDelete();
          break;
        default:
          break;
      }
    },
    [handleOnDelete]
  );

  // 点击input时,重新聚焦当前的input,弹出键盘
  const handleClick = React.useCallback(() => {
    focusInput(curFocusIndexRef.current);
  }, [focusInput]);

  const handleChange = React.useCallback(
    (e) => {
      const val = e.target.value || "";
      if (!isInputValueValid(val)) return;
      if (val.length === 1) {
        focusNextInput();
        setValue(`${value}${val}`);
      }
    },
    [focusNextInput, isInputValueValid, value]
  );

  const handlePaste = React.useCallback(
    (e) => {
      // 一定要清除默认行为
      e.preventDefault();
      const val = e.clipboardData.getData("text/plain").slice(0, pinLength);
      if (!isInputValueValid(val)) return;
      const len = val.length;
      const index = len === pinLength ? pinLength - 1 : len;
      // 如果之前存在输入,这里直接覆盖,也可以实现不覆盖的,也很简单
      setValue(val);
      focusInput(index);
    },
    [focusInput, isInputValueValid]
  );

  return (
    <div className={"container"}>
      {Array.from({ length: pinLength }).map((_, index) => {
        const focus = index === curFocusIndexRef.current;
        return (
          <input
            key={index}
            ref={(ref) => (inputsRef.current[index] = ref)}
            className={`pinInput ${focus ? "focus" : ""}`}
            maxLength={1}
            type="number"
            pattern="\d*"
            autoComplete="false"
            value={value[index] || ""}
            onClick={handleClick}
            onChange={handleChange}
            onPaste={handlePaste}
            onKeyDown={handleOnKeyDown}
          />
        );
      })}
    </div>
  );
}

这里还要注意一个小细节,因为input不是只读的了,这样输入的时候会存在光标,因为该需求不需要光标,所以我们只需要在给input添加一些css就好了

color: transparent;
caret-color: transparent;
text-shadow: 0 0 0 #000;

大功告成,看一下效果:

PINinput1.gif