论如何基于 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 等等)
  • ...

十三、参考

相关推荐
雪碧聊技术1 分钟前
01-Ajax入门与axios使用、URL知识
前端·javascript·ajax·url·axios库
adminIvan6 分钟前
Element plus使用menu时候如何在折叠时候隐藏掉组件自带的小箭头
前端·javascript·vue.js
会发光的猪。25 分钟前
【 ElementUI 组件Steps 步骤条使用新手详细教程】
前端·javascript·vue.js·elementui·前端框架
我家媳妇儿萌哒哒25 分钟前
el-table合并单元格之后,再进行隔行换色的且覆盖表格行鼠标移入的背景色的实现
前端·javascript·elementui
baiduguoyun41 分钟前
react的import 导入语句中的特殊符号
前端·react.js
前端青山42 分钟前
webpack指南
开发语言·前端·javascript·webpack·前端框架
NiNg_1_2341 小时前
ECharts实现数据可视化入门详解
前端·信息可视化·echarts
励志前端小黑哥2 小时前
有了Miniconda,再也不用担心nodejs、python、go的版本问题了
前端·python
喵叔哟2 小时前
重构代码之取消临时字段
java·前端·重构
还是大剑师兰特2 小时前
D3的竞品有哪些,D3的优势,D3和echarts的对比
前端·javascript·echarts