论如何基于 React 封装一个验证码输入控件 🔥🔥🔥

引言

邮箱、手机验证码输入是许多在线服务和网站常见的安全验证方式之一。这种方式通常用于确保用户在进行敏感操作(例如注册、修改密码、重置密码等)时的身份验证。

最近在做项目刚好有验证码相关的需求, 本着不重复造轮子的原则, 一顿 Google 试图找到一个现成的组件, 奈何找了一圈都没找到满意的, 要么就是交互感觉不太合理、要么就是基本停止维护了的!!

最后没办法就自己造一个了, 这里主要参考了 react-auth-code-input, 而本文则是整个思路开发流程的记录!!

一、需求描述

开始前我们先梳理下一般验证码输入控件的常规需求有哪些:

  1. 假设我们验证码有 6 位, 则我们需要有 6 个输入框, 每个输入框只允许输入一位数字(这里假设验证码都是数字组成)
  2. 在输入验证码过程中, 可连续进行输入、删除等操作
  3. 支持黏贴复制的内容
  4. ...

二、布局

在开始前我们先来完成基本的布局, 如下代码所示:

  • 声明状态 codes 用于存储每个验证码, 也就是每个输入框的值, 这里我将 codes 设置为一个数组, 方便后面修改每个位置的验证码
  • 假设我们验证码长度为 6 位, 所以这里我为 codes 默认值了一个长度为 6 的数组, 数组每个初始值为空字符串
  • 然后我们通过 codes.map 渲染出所有输入框
  • 最后我们还声明了 inputsRef 来存储所有输入框的 DOM 节点, 我们后面需要通过它来调用原生 DOMAPI
js 复制代码
import React, { useState, useRef } from 'react';
import scss from './com.module.scss';

export default () => {
  const [codes, setCodes] = useState(Array.from({ length: 6 }, () => ''));
  const inputsRef = useRef([]);

  return (
    <div>
      {codes.map((value, index) => (
        <input
          type="text"
          key={index}
          value={value}
          maxLength={1}
          className={scss.input}
          ref={(ele) => (inputsRef.current[index] = ele)}
        />
      ))}
    </div>
  );
};

这里我们对输入框设置了一些基本的样式

scss 复制代码
.input {
  width: 40px;
  margin: 10px;

  font-size: 18px;
  line-height: 40px;
  text-align: center;

  border-radius: 4px;
  border: 1px solid #d9d9d9;
}

到此页面的基本效果如下:

三、动态绑定(处理 onChange 事件)

上文只是完成了基本的布局, 并且输入框 value 和状态 codes 内的值绑定在了一起, 这里输入框输入值会发现并没有生效, 那是因为状态 codes 没有被修改!

下面我们为输入框设置 onChange 事件, 在输入框输入值时动态的修改状态 codes 中对应位置的值!!

下面是 onChange 事件的处理函数:

  • 两个参数, indexevent, 正如命名所示, index 对应输入框索位置, event 则是输入对应的 change 事件对象, 通过它来获取输入值
  • 特别说明, 本文验证码都是数字, 所以在函数内部还需要针对输入内容进行校验, 只允许输入数字 0~9
  • 函数内还有一个特殊处理逻辑, 就是当我们输入有效值后, 需要将鼠标光标聚焦到下一个输入框, 如此用户就可以连续进行输入了, 至于实现方法很简单, 这里直接调用 inputsRef 中对应输入框 DOM 节点的 focus 方法即可
  • 最后我们调用, setCodes 修改状态 codes, 这样输入框的值才能动态的修改
js 复制代码
const handleChange = useCallback((index, event) => {
  const currentValue = event.target.value.match(/[0-9]{1}/)
    ? event.target.value
    : '';

  // 如果输入有效值, 则自动聚焦到下一个输入框
  if (currentValue) {
    inputsRef.current[index + 1]?.focus();
  }

  setCodes((pre) => {
    const newData = [...pre];
    newData[index] = currentValue;
    return newData;
  });
}, []);

最为为每个输入框绑定 onChange 事件, 主要这里使用了 bind 来绑定 index:

diff 复制代码
<div>
  {codes.map((value, index) => (
    <input
      ...
+     onChange={handleChange.bind(null, index)}
      ref={(ele) => (inputsRef.current[index] = ele)}
    />
  ))}
</div>

最后效果如下: 输入验证码, 光标自动跳转到下一个输入框

四、删除处理

上文我们完成验证码的输入, 但是在输入过程中, 难免会输入错误的数字, 所以就需要实现删除验证码的能力, 需求如下:

  • 当我们按下 删除键
  • 如果当前输入框有值, 则删除当前输入框中的内容
  • 如果当前输入框没有值, 则删除上一个输入框内容, 并且聚焦到上一个输入框

需求其实已经很明确了, 我只需要通过 onKeyDown 来监听键盘按下事件, 从而判断用户是否按下 删除键, 如果按下 删除键 则按照需求逻辑进行编码即可, 具体代码如下:

  • 函数接收两个参数 indexevent, index 表示当前光标所在的输入框索引位置, event 则是事件对象
  • 通过 event.key 的值来确定是否按下 删除键(Backspace), 如果不是, 则不进行任何处理
  • 剩下就按需求来, 如果当前输入框有值则清除当前输入框内容
  • 如果当前输入框没值, 则清除上一个输入框内容, 并且将光标移到上一个输入框中, 这里还需要考虑下边界情况, 如果当前输入框已经是第一个了, 就无需进行任何处理
js 复制代码
const handleDelete = useCallback((index, event) => {
  const { key } = event;

  // 是否按下删除键, 否提前结束
  if (key !== 'Backspace') {
    return;
  }

  // 1. 如果当前输入框有值, 则删除当前输入框内容
  if (codes[index]) {
    setCodes((pre) => {
      const newData = [...pre];
      newData[index] = '';
      return newData;
    });
  } else if (index > 0) {
    // 2. 如果当前输入框没有值(考虑下边界的情况 index === 0): 则删除上一个输入框内容, 并且光标聚焦到上一个输入框
    setCodes((pre) => {
      const newData = [...pre];
      newData[index - 1] = '';
      return newData;
    });
    inputsRef.current[index - 1].focus();
  }
}, [codes]);

最后为每个输入框绑定 onKeyDown 事件, 主要这里使用了 bind 来绑定 index:

diff 复制代码
<div>
  {codes.map((value, index) => (
    <input
      ...
+     onKeyDown={handleDelete.bind(null, index)}
      onChange={handleChange.bind(null, index)}
      ref={(ele) => (inputsRef.current[index] = ele)}
    />
  ))}
</div>

最后效果如下: 输入验证码后, 按下删除键, 能够连续删除验证码内容

五、粘贴处理

在大部分情况下, 我们都是直接复制验证码然后直接黏贴使用, 所以我们接下来来实现的功能就是:

  • 允许在任意输入框黏贴数据
  • 自动将剪切板的数字回填到输入框中
  • 这里不做过多的处理, 不管光标在哪个位置, 都从第一个输入框开始填充数字
  • 注意的是, 这里光标还需要自动聚焦到最后一个输入框内容为空的位置

具体实现代码如下:

  • 通过 event.clipboardData.getData 获取到剪切板内容
  • 过滤掉剪切板中非数值部分内容
  • 生成新状态 codes: 先创建了一长度为 6 的数组, 并使用剪切板的数字就行填充, 不够的用空字符进行填充, 最后使用 setCodes 来修改状态值
  • 光标位置修改, 根据剪切板数字长度来进行计算
js 复制代码
const handlePaste = useCallback((event) => {
  const pastedValue = event.clipboardData.getData('Text'); // 读取剪切板数据
  const pastNum = pastedValue.replace(/[^0-9]/g, ''); // 去除数据中非数字部分, 只保留数字

  // 重新生成 codes: 6 位, 每一位取剪切板对应位置的数字, 没有则置空
  const newData = Array.from(
    { length: 6 },
    (_, index) => pastNum.charAt(index) || '',
  );

  setCodes(newData); // 修改状态 codes

  // 光标要聚焦的输入框的索引, 这里取 pastNum.length 和 5 的最小值即可, 当索引为 5 就表示最后一个输入框了
  const focusIndex = Math.min(pastNum.length, 5);
  inputsRef.current[focusIndex]?.focus();
}, []);

最后为每个输入框绑定 onPaste(黏贴) 事件

diff 复制代码
<input
  ...
  onPaste={handlePaste}
/>

最后效果如下: 光标聚焦在任意输入框, 进行黏贴后, 即可自动用剪切板内的数字来填充输入框

六、第一阶段完成

到此整体功能已经差不多了, 下面是目前为止完整的代码(删除了 CSS 部分)

js 复制代码
import React, { useState, useRef, useCallback } from 'react';

export default () => {
  const [codes, setCodes] = useState(Array.from({ length: 6 }, () => ''));
  const inputsRef = useRef([]);

  const handleChange = useCallback((index, event) => {
    const currentValue = event.target.value.match(/[0-9]{1}/)
      ? event.target.value
      : '';

    // 如果输入有效值, 则自动聚焦到下一个输入框
    if (currentValue) {
      inputsRef.current[index + 1]?.focus();
    }

    setCodes((pre) => {
      const newData = [...pre];
      newData[index] = currentValue;
      return newData;
    });
  }, []);

  const handleDelete = useCallback((index, event) => {
    const { key } = event;

    // 是否按下删除键, 否提前结束
    if (key !== 'Backspace') {
      return;
    }

    // 1. 如果当前输入框有值, 则删除当前输入框内容
    if (codes[index]) {
      setCodes((pre) => {
        const newData = [...pre];
        newData[index] = '';
        return newData;
      });
    } else if (index > 0) {
      // 2. 如果当前输入框没有值(考虑下边界的情况 index === 0): 则删除上一个输入框内容, 并且光标聚焦到上一个输入框
      setCodes((pre) => {
        const newData = [...pre];
        newData[index - 1] = '';
        return newData;
      });
      inputsRef.current[index - 1].focus();
    }
  }, [codes]);

  const handlePaste = useCallback((event) => {
    const pastedValue = event.clipboardData.getData('Text'); // 读取剪切板数据
    const pastNum = pastedValue.replace(/[^0-9]/g, ''); // 去除数据中非数字部分, 只保留数字

    // 重新生成 codes: 6 位, 每一位取剪切板对应位置的数字, 没有则置空
    const newData = Array.from(
      { length: 6 },
      (_, index) => pastNum.charAt(index) || '',
    );

    setCodes(newData); // 修改状态 codes

    // 光标要聚焦的输入框的索引, 这里取 pastNum.length 和 5 的最小值即可, 当索引为 5 就表示最后一个输入框了
    const focusIndex = Math.min(pastNum.length, 5);
    inputsRef.current[focusIndex]?.focus();
  }, []);

  return (
    <div>
      {codes.map((value, index) => (
        <input
          type="text"
          key={index}
          value={value}
          maxLength={1}
          onPaste={handlePaste}
          onKeyDown={handleDelete.bind(null, index)}
          onChange={handleChange.bind(null, index)}
          ref={(ele) => (inputsRef.current[index] = ele)}
        />
      ))}
    </div>
  );
};

基本功能有了, 下面我们对组件进行简单的封装、优化....

七、暴露 onChange 事件

这里我们希望父组件可以通过 onValueChange 来监听到内部状态 codes 的变更, 做法就很简单了:

  • 抽离一个通过方法 resetCodes, 修改状态的地方全部使用 resetCodes 方法
  • resetCodes 方法内部则是调用 setCodes 方法修改 codes 同时调用父组件传进来的 onValueChange 方法
  • resetCodes 支持传一个数组进来, 也可以是一个 index 一个 value; 这么做的原因主要是为了支持不同场景下修改状态 codes 的需求
js 复制代码
// 修改状态 codes
const resetCodes = useCallback((index, value) => {
  setCodes((pre) => {
    let newData = [...pre];

    if (Array.isArray(index)) {
      newData = index;
    }

    if (typeof index === 'number') {
      newData[index] = value;
    }

    onValueChange?.(newData.join(''));

    return newData;
  });
}, [onValueChange]);

最后还需要将代码里调用 setCodes 的地方改为 resetCodes, 这里就不做演示了; 修改完成之后, 我们就可以通过 onValueChange 监听到组件内部 codes 的变更了

js 复制代码
<AuthCode onValueChange={(codes) => console.log(codes)} />

最后效果如下:

八、暴露 onComplete 事件

这里我们还希望在输入完所有验证码后, 能够被组件外部监听到, 这样就可以直接拿到完整的验证码向后端服务发起校验....

其实有了上面的基础, 我们可以直接在 resetCodes 中进行处理: 在修改状态 codes 前判断下所有验证码是否都已经输入, 如果已全部输入则调用父组件的 onComplete 事件

diff 复制代码
// 修改状态 codes
const resetCodes = useCallback((index, value) => {
  setCodes((pre) => {
    let newData = [...pre];

    if (Array.isArray(index)) {
      newData = index;
    }

    if (typeof index === 'number') {
      newData[index] = value;
    }

+   // 处理 onComplete
+   if (newData.every(Boolean) && onComplete) {
+     onComplete(newData.join(''));
+   }

    onValueChange?.(newData.join(''));

    return newData;
  });
+ }, [onValueChange, onComplete]);

接下来我们就可以在验证码全部输入后, 通过 onComplete 监听到

js 复制代码
<AuthCode onComplete={(codes) => console.log(codes)} />

最后效果如下:

九、自动聚焦

这个需求就很简单咯, 就是希望组件在初始化时可以将鼠标光标自动聚焦到第一个输入框, 这样用户就可以直接进行输入, 完成验证码的校验!!!

实现方法就更简单, 直接在 useEffect 中调用第一个输入框的 DOM 节点的原生 focus 方法即可

js 复制代码
useEffect(() => {
  inputsRef.current[0].focus();
}, []);

十、聚焦时选中输入框内容

下面我们希望能够在输入框聚焦情况下, 能够自动选中输入框的内容, 这样的话就可以直接输入内容, 而不是先删除再输入内容!!

实现方法很简单:

  • 通过 onFocus 事件来实现, 监听 Focus(获取焦点) 事件
  • 然后在事件处理函数内调用事件 select 方法来选中输入框的内容
js 复制代码
const handleOnFocus = useCallback((e) => {
  e.target.select();
}, []);

最后效果如下:

十一、暴露外面接口

最后我们希望父组件可以通过 ref 来获取到一些组件内部预设好的方法, 比如自动获取焦点、清空所有输入框内容等等

如下代码使用 forwardRef 配合 useImperativeHandle 完成 ref 的绑定

js 复制代码
export default forwardRef((props, ref) => {
  // ...
  useImperativeHandle(ref, () => ({
    // 获取焦点
    focus: (index = 0) => {
      if (inputsRef.current) {
        inputsRef.current[index].focus();
      }
    },
    // 清空内容
    clear: () => {
      resetCodes(codes.map(() => ''));
    },
  }));
  // ...
}

调用方法如下所示:

js 复制代码
export default () => {
  const ref = useRef();
  return (
    <>
      <Com ref={ref} />
      <Button onClick={() => ref.current?.clear()}>
        清空
      </Button>
    </>
  );
};

最后效果如下:

十二、后续

到此基本差不多了, 剩下更多的可能是组件的封装上的事情, 比如:

  • 允许设置默认值
  • 支持双向绑定
  • 支持设置验证码长度
  • 支持设置验证码规则(纯数字、纯字母、字母数字混合)
  • 支持设置 input 参数(比如 placeholder 等等)
  • ...

十三、参考

相关推荐
weixin_382395235 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__5 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.6 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~7 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华8 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子10 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅10 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni10 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学12 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端