引言
邮箱、手机验证码输入是许多在线服务和网站常见的安全验证方式之一。这种方式通常用于确保用户在进行敏感操作(例如注册、修改密码、重置密码等)时的身份验证。
最近在做项目刚好有验证码相关的需求, 本着不重复造轮子的原则, 一顿 Google
试图找到一个现成的组件, 奈何找了一圈都没找到满意的, 要么就是交互感觉不太合理、要么就是基本停止维护了的!!
最后没办法就自己造一个了, 这里主要参考了 react-auth-code-input, 而本文则是整个思路开发流程的记录!!
DEMO
演示可查阅: blog/auth-codes- 本文完整源码可查阅: coding/blog/AuthCodes
一、需求描述
开始前我们先梳理下一般验证码输入控件的常规需求有哪些:
- 假设我们验证码有
6
位, 则我们需要有6
个输入框, 每个输入框只允许输入一位数字(这里假设验证码都是数字组成) - 在输入验证码过程中, 可连续进行输入、删除等操作
- 支持黏贴复制的内容
- ...
二、布局
在开始前我们先来完成基本的布局, 如下代码所示:
- 声明状态
codes
用于存储每个验证码, 也就是每个输入框的值, 这里我将codes
设置为一个数组, 方便后面修改每个位置的验证码 - 假设我们验证码长度为
6
位, 所以这里我为codes
默认值了一个长度为6
的数组, 数组每个初始值为空字符串 - 然后我们通过
codes.map
渲染出所有输入框 - 最后我们还声明了
inputsRef
来存储所有输入框的DOM
节点, 我们后面需要通过它来调用原生DOM
的API
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
事件的处理函数:
- 两个参数,
index
和event
, 正如命名所示,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
来监听键盘按下事件, 从而判断用户是否按下 删除键
, 如果按下 删除键
则按照需求逻辑进行编码即可, 具体代码如下:
- 函数接收两个参数
index
和event
,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
等等) - ...