在 React 中从头开始创建密码组件
介绍
你有没有想过,应用程序或网页上的一次性密码界面是如何工作的?这些界面包含多个输入元素,每个元素只接受一个字符。所有这些元素的行为都像一个元素,并且与普通的输入元素类似。我们可以称之为密码组件,因为一次性密码本身就是一个密码。
在本篇博文中,我们将讨论如何创建这个密码组件。我们将了解该组件的工作原理以及相关的各种用例。稍后,我们将从组件的实现开始。
事不宜迟,让我们开始吧。
什么是密码组件?
Passcode 组件是一组输入框。该组件中的每个字符都有其对应的输入元素,我们将其统称为 Passcode 组件。Passcode 组件如下所示:

如今,大多数移动和 Web 应用程序都在使用它。它是所有验证流程中都会遵循的常见 UI 实践。但由于它不是一个简单的输入元素,而是一组输入元素,因此实现起来比较棘手。需要管理多种用例场景,例如处理按键事件、粘贴体验、确保焦点按预期变化等等。
现在让我们先了解一下密码组件的用例。事不宜迟,让我们开始吧。
涉及 Passcode 组件的案例
以下是我们在构建密码组件时要涵盖的基本场景:
- 所有输入框只能接受一个字符
- 一旦按下一个字符,焦点就会转移到下一个输入元素
- 当退格时焦点应该从右侧的输入元素转移到左侧的输入元素
我们还将介绍一个高级场景:粘贴经验。它涉及以下子案例:
- 检查用户权限
- 当粘贴值的长度等于输入元素的数量时,焦点应该转到最后一个输入元素。
- 当粘贴值的长度小于元素的总数时,焦点应该转移到包含粘贴值的最后一个字符的输入元素。
- 当粘贴值的长度大于输入元素的总数时,焦点仍然应该放在最后一个输入元素上
- 如果用户尝试从开始和结束之间的输入元素粘贴值,则粘贴的值将被部分填充到输入元素的末尾。
设置项目
-
我们将使用create-react-app 的 TypeScript 模板来初始化我们的项目。运行以下命令来创建项目。请确保在占位符中指定项目名称:
npx create-react-app <name-of-the-project> --template typescript
-
项目初始化后,我们在其中添加几个文件夹和文件。
├── src │ ├── App.css │ ├── App.test.tsx │ ├── App.tsx │ ├── components │ │ └── Passcode.tsx │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── setupTests.ts │ └── utils │ └── index.ts
-
我们在其中创建两个新文件夹:
components
utils
。我们还创建了所有这些文件夹中提到的文件。稍后我们将了解每个文件的含义。 -
创建这些文件夹后,请确保通过运行以下命令启动您的项目:
yarn start
构建密码组件
要构建密码组件,首先我们需要了解它的工作原理。请参考下图来理解它的工作原理。

请考虑上图。上面显示的每个框都引用了密码组件中的每个输入元素。如果您在第一个输入框中输入一个值,则会发生两个操作:
- 值状态变量已更新,并且
- 焦点状态得到更新。
我们利用各种事件处理程序(例如onChange
、onKeyDown
、onKeyUp
等)来管理多个输入元素之间的焦点以及值状态变量的变化。下一节我们将详细探讨这些事件处理程序是如何编排的。
此时,焦点索引和值属性均已更新。这将触发重新渲染,从而将下一个焦点输入元素渲染到密码组件中。
用外行人的话来说,这就是我们的组件的工作方式。
现在我们已经介绍了基础知识,我们可以继续解决上面提到的每个用例
用例实现
我们将从创建项目的脚手架开始。请继续操作:
-
首先,我们将创建一个密码组件。该组件充当容器组件。它将保存数据并渲染每个输入组件。
const Passcode = () => { const [arrayValue, setArrayValue] = useState<(string | number)[]>(['', '', '','']); const [currentFocusedIndex, setCurrentFocusedIndex] = useState(0); return ( <> <div> currentFocusedIndex: {currentFocusedIndex}</div> {arrayValue.map((value: string | number, index: number)=> ( <input key={`index-${index}`} type="text" value={String(value)} /> ))} </> ); }
-
创建的密码组件有两个状态变量:
arrayValue
和currentFocusedIndex
。arrayValue
包含密码组件的实际值。如果任何底层输入元素的值发生变化,则此状态变量也会更新。我们还有currentFocusedIndex
一个 ,包含需要聚焦的输入元素的当前索引。每当用户点击输入元素或在输入元素中输入内容时,该状态变量都会更新。
情况 1:所有输入框都应只接受一个字符
现在让我们开始第一个用例。要实现这个用例,我们只需要将maxLength
其值设置为1
。这样我们就可以只接受一个字符。
const Passcode = () => {
const [arrayValue, setArrayValue] = useState<(string | number)[]>(['', '', '','']);
const [currentFocusedIndex, setCurrentFocusedIndex] = useState(0);
return (
<>
<div> currentFocusedIndex: {currentFocusedIndex}</div>
{arrayValue.map((value: string | number, index: number)=> (
<input
key={`index-${index}`}
maxLength={1}
type="text"
value={String(value)}
/>
))}
</>
);
}
这是一个可选步骤,但如果您想控制用户填写此组件时显示的键盘类型,并且只允许输入数字,则只需添加inputMode
属性并将其设置为数字即可。这将确保用户通过移动设备屏幕输入时显示数字虚拟键盘:
<input
key={`index-${index}`}
inputMode="numeric"
pattern="\d{1}"
maxLength={1}
type="text"
value={String(value)}
/>
情况 2:一旦输入一个字符,焦点应该转移到下一个输入框
到目前为止,这将是最关键的用例,因为它定义了该组件的基本交互。为了解决这个问题,我们需要考虑以下几点:
- 为了控制每个输入元素的焦点,我们需要对输入元素进行实际的控制。为此,我们需要将 ref 属性传递给每个输入元素。
- 我们将在不同的事件处理程序中分离焦点任务和更新 arrayValue 属性的任务:
onChange
- 将处理 arrayValue 状态变量的更新部分onKeyup
- 将负责更新焦点索引状态变量。onKeyDown
- 将负责防止输入除数字之外的任何其他键。onFocus
- 将确保我们在点击时更新当前元素的焦点。
首先为ref
每个输入元素添加属性。为此,我们应该创建一个数组作为引用。该数组将存储每个输入元素的引用。
-
为此,我们将数组声明为参考值,如下所示:
const inputRefs = useRef<Array<HTMLInputElement> | []>([]);
-
接下来,我们通过执行以下操作确保将每个元素的引用添加到数组中:
<input key={`index-${index}`} ref={(el) => el && (inputRefs.current[index] = el)} // here inputMode="numeric" maxLength={1} type="text" value={String(value)} />
我们使用ref
回调机制来获取当前元素。您可以点击ref
此处了解更多关于回调函数的信息。接下来,我们将当前元素赋值给inputRefs
数组的第 i 个索引。
这样,我们将每个输入元素的引用存储在一个数组中ref
。现在让我们仔细看看如何使用这些引用。
密码组件的每个输入元素仅接受一个数字字符。我们只需限制密码组件,使其不接受任何非数字值即可。在某些情况下,它也可以接受字母数字代码值,但这目前超出了本文的讨论范围。在整个组件中,我们尝试通过在每个输入元素中仅输入数字来限制用户。我们可以做的只是阻止按键事件在特定按键时执行默认行为。以下是我们将要执行的操作:
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const keyCode = e.key;
if (!(keyCode >= 0 && keyCode <= 9)) {
e.preventDefault();
}
};
上述代码确保实现了默认行为,即阻止所有非数字值的按键输入。我们使用 来判断按下的键是否为数字键e.key
。键盘事件key
属性返回用户按下的键的值。由于我们想要允许此行为,因此不会e.preventDefault()
对非数字值执行此操作。因此,此方法有助于我们将输入限制为仅限数字值。
接下来,我们了解如何更新状态变量。当事件发生arrayValue
时,我们会更新它。以下是更新此状态的方法:onChange
const onChange = (e: BaseSyntheticEvent, index: number) => {
setArrayValue((preValue: (string | number)[]) => {
const newArray = [...preValue];
if (parseInt(e.target.value)) {
newArray[index] = parseInt(e.target.value);
} else {
newArray[index] = e.target.value;
}
return newArray;
});
};
这里我们确认onChange
事件提供的值是否为数字。如果是,则将其赋值给克隆变量newArray
。
接下来,我们看一下onKeyUp
事件处理程序。在此事件处理程序中,我们更新状态变量currentFocusedIndex
。以下代码可帮助您更新此状态变量:
const onKeyUp = (e: KeyboardEvent<HTMLInputElement>, index: number) => {
if (parseInt(e.key) && index < arrayValue.length - 1) {
setCurrentFocusedIndex(index + 1);
if (inputRefs && inputRefs.current && index === currentFocusedIndex) {
inputRefs.current[index + 1].focus();
}
}
};
这里我们做的第一个更新是将currentFocusedIndex
值设置为index+1
。接下来,我们借助focus
函数将下一个输入元素设置为焦点状态。这样,下一个元素就被聚焦了,并且代码现在可以为下一个输入元素再次处理同一组事件了。
最后,我们有onFocus
事件处理程序。此处理程序的目的是更新currentFocusedIndex
通过点击事件手动聚焦的输入元素的索引。
const onFocus = (e: BaseSyntheticEvent, index: number) => {
setCurrentFocusedIndex(index);
};
我们将所有这些处理程序缝合在一起,这就是我们的密码组件的样子:
const Passcode = () => {
const [arrayValue, setArrayValue] = useState<(string | number)[]>([
"",
"",
"",
""
]);
const [currentFocusedIndex, setCurrentFocusedIndex] = useState(0);
const inputRefs = useRef<Array<HTMLInputElement> | []>([]);
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const keyCode = e.key;
if (!(keyCode >= 0 && keyCode <= 9)) {
e.preventDefault();
}
};
const onChange = (e: BaseSyntheticEvent, index: number) => {
setArrayValue((preValue: (string | number)[]) => {
const newArray = [...preValue];
if (parseInt(e.target.value)) {
newArray[index] = parseInt(e.target.value);
} else {
newArray[index] = e.target.value;
}
return newArray;
});
};
const onKeyUp = (e: KeyboardEvent<HTMLInputElement>, index: number) => {
if (parseInt(e.key) && index <= arrayValue.length - 2) {
setCurrentFocusedIndex(index + 1);
if (inputRefs && inputRefs.current && index === currentFocusedIndex) {
inputRefs.current[index + 1].focus();
}
}
};
const onFocus = (e: BaseSyntheticEvent, index: number) => {
setCurrentFocusedIndex(index);
e.target.focus();
};
return (
<>
<div> currentFocusedIndex: {currentFocusedIndex}</div>
<div>{arrayValue}</div>
{arrayValue.map((value: string | number, index: number) => (
<input
key={`index-${index}`}
ref={(el) => el && (inputRefs.current[index] = el)}
inputMode="numeric"
pattern="\d{1}"
maxLength={1}
type="text"
value={String(value)}
onChange={(e) => onChange(e, index)}
onKeyUp={(e) => onKeyUp(e, index)}
onKeyDown={(e) => onKeyDown(e)}
onFocus={(e) => onFocus(e, index)}
/>
))}
</>
);
};
情况 3:退格时焦点应从右向左移动
这是一个非常常见的用例,用户希望清除通过按键输入的每个值backspace
。为了实现这一点,我们需要确保更新currentFocusedIndex
inonKeyUp
事件。我们还需要允许按键操作Backspace
,为此,我们在onKeyDown
事件中添加了一个条件。
更新onKeyUp
和onKeyDown
事件处理程序如下:
const onKeyUp = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Backspace") {
if (index === 0) {
setCurrentFocusedIndex(0);
} else {
setCurrentFocusedIndex(index - 1);
if (
inputRefs &&
inputRefs.current &&
index === currentForcusedIndex
) {
inputRefs.current[index - 1].focus();
}
}
} else {
if (
(parseInt(e.key)) &&
index <= array.length - 2
) {
setCurrentFocusedIndex(index + 1);
if (
inputRefs &&
inputRefs.current &&
index === currentForcusedIndex
) {
inputRefs.current[index + 1].focus();
}
}
}
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const keyCode = e.key;
if (
!(keyCode >= 0 && keyCode <= 9) &&
keyCode !== "Backspace"
) {
e.preventDefault();
}
};
这里我们添加了一个代码块,用于处理currentFocusedIndex
按下 Backspace 键时输入元素的焦点。首先,如果当前索引为零,我们也将 更新为零;currentFocusedIndex
否则,我们将其设置为 ,index-1
即焦点设置为前一个元素。我们还通过调用前一个元素的 focus 函数来确保焦点位于前一个元素上。在onKeyDown
事件处理程序中,我们添加了一个条件,即当为时不执行preventDefault
该函数。这确保了 Backspace 键可以被按下并执行其默认行为。keyCode
Backspace
案例四:粘贴经验
此用例有多个子用例。
a. Check for user permissions
b. Focus should go to the last input box when the pasted value length is equal to the number of input boxes
c. The focus should shift to the input box that contains the last character of the pasted value when pasted value's length is less than the total number of input elements
d. The focus should be on the last input box when the pasted value length is greater than the total number of boxes
e. If a user tries to paste the value from an input box that is in between the start and end then, the pasted value is partially filled till the end of the input boxes
现在让我们开始实现上述子用例。该Pasting Experiences
场景完全在 useEffect 钩子内部执行。我们为该paste
事件添加了一个事件监听器。
我们的代码如下所示:
useEffect(() => {
document.addEventListener("paste", async () => {
// Handle all sub-usecases here
});
}, [])
为了允许粘贴值,我们需要允许按下CRTL + V
组合键,以便用户可以通过键盘粘贴值。我们需要在onKeyDown
事件处理程序中添加以下条件以允许此组合键:
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const keyCode = parseInt(e.key);
if (
!(keyCode >= 0 && keyCode <= 9) &&
e.key !== "Backspace" &&
!(e.metaKey && e.key === "v")
) {
e.preventDefault();
}
};
案例a:检查用户权限
为了检查粘贴权限,我们使用 Navigator 界面的 permission 只读属性。您可以在此处阅读有关此属性的更多信息。我们使用查询函数来获取当前权限。在我们的场景中,我们需要获取权限clipboard-read
。我们使用以下代码来实现相同的目的:
useEffect(() => {
document.addEventListener("paste", async () => {
// Handle all sub-usecases here
const pastePermission = await navigator.permissions.query({
name: "clipboard-read" as PermissionName
});
if (pastePermission.state === "denied") {
throw new Error("Not allowed to read clipboard");
}
});
}, []);
通过此代码,我们确保在粘贴数据之前征求用户的许可。权限对话框如下所示:
接下来,我们在粘贴数字值的同时管理密码组件的焦点。对于 b、c、d 和 e 的情况,我们将一次性管理它们,因为它们都与焦点管理相关。在开始粘贴经验和焦点管理之前,我们应该确保剪贴板中的所有值都是数字。但在此之前,我们应该从剪贴板中读取内容:
const clipboardContent = await navigator.clipboard.readText();
接下来,我们将剪贴板中的所有内容转换为数字:
try {
let newArray: Array<number | string> = clipboardContent.split("");
newArray = newArray.map((num) => Number(num));
} catch (err) {
console.error(err);
}
为了打造更好的粘贴体验,我们更新了arrayValue
剪贴板的内容。我们更新了arrayValue
以下内容currentFocusedIndex
:
-
如果
currentFocusedIndex > 0
从除第一个和最后一个输入元素之外的任何输入元素开始粘贴,则我们需要将 arrayValue 从焦点输入元素填充到最后一个输入元素。- 我们首先计算从当前焦点索引到最后一个输入元素之间可用的位置/输入元素的数量。我们将这个变量称为 remainingPlaces。
- 现在我们知道我们需要填充 arrayValue 的这么多位置,我们将粘贴数组从 0 切片到剩余的位置。
- 现在我们创建一个新的数组,它是以下内容的合并:arrayValue 从 0 到 currentFocusedIndex 切片,然后是部分填充的数组,即切片粘贴的数组。
- 其代码如下所示:
const lastIndex = arrayValue.length - 1; if (currentFocusedIndex > 0) { const remainingPlaces = lastIndex - currentFocusedIndex; const partialArray = newArray.slice(0, remainingPlaces + 1); setArrayValue([ ...arrayValue.slice(0, currentFocusedIndex), ...partialArray ]); }
- 如果是
currentFocusedIndex = 0
,那么我们确实设置 arrayValue 数组,如下所示:
setArrayValue([ ...newArray, ...arrayValue.slice(newArray.length - 1, lastIndex) ]);
更新arrayValue
字段后,就该更新了。我们根据以下条件currentFocusedIndex
更新和输入元素:currentFocusedIndex
- 如果粘贴数组的长度小于 arrayValue 的长度,并且当前焦点索引是第一个输入元素,那么我们更新包含粘贴数组的最后一个元素的输入元素的焦点。
- 否则我们更新最后一个输入元素的焦点:
if (newArray.length < arrayValue.length && currentFocusedIndex === 0) {
setCurrentFocusedIndex(newArray.length - 1);
inputRefs.current[newArray.length - 1].focus();
} else {
setCurrentFocusedIndex(arrayValue.length - 1);
inputRefs.current[arrayValue.length - 1].focus();
}
我们将所有这些变化缝合到 useEffect 钩子中,如下所示:
useEffect(() => {
document.addEventListener("paste", async () => {
// Handle all sub-usecases here
const pastePermission = await navigator.permissions.query({
name: "clipboard-read" as PermissionName
});
if (pastePermission.state === "denied") {
throw new Error("Not allowed to read clipboard");
}
const clipboardContent = await navigator.clipboard.readText();
try {
let newArray: Array<number | string> = clipboardContent.split("");
newArray = newArray.map((num) => Number(num));
const lastIndex = arrayValue.length - 1;
if (currentFocusedIndex > 0) {
const remainingPlaces = lastIndex - currentFocusedIndex;
const partialArray = newArray.slice(0, remainingPlaces + 1);
setArrayValue([
...arrayValue.slice(0, currentFocusedIndex),
...partialArray
]);
} else {
setArrayValue([
...newArray,
...arrayValue.slice(newArray.length - 1, lastIndex)
]);
}
if (newArray.length < arrayValue.length && currentFocusedIndex === 0) {
setCurrentFocusedIndex(newArray.length - 1);
inputRefs.current[newArray.length - 1].focus();
} else {
setCurrentFocusedIndex(arrayValue.length - 1);
inputRefs.current[arrayValue.length - 1].focus();
}
} catch (err) {
console.error(err);
}
});
return () => {
document.removeEventListener("paste", () =>
console.log("Removed paste listner")
);
};
}, [arrayValue, currentFocusedIndex]);
我们已经完成了密码组件的实现。密码组件如下所示:
const Passcode = () => {
const [arrayValue, setArrayValue] = useState<(string | number)[]>([
"",
"",
"",
""
]);
const [currentFocusedIndex, setCurrentFocusedIndex] = useState(0);
const inputRefs = useRef<Array<HTMLInputElement> | []>([]);
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const keyCode = parseInt(e.key);
if (
!(keyCode >= 0 && keyCode <= 9) &&
e.key !== "Backspace" &&
!(e.metaKey && e.key === "v")
) {
e.preventDefault();
}
};
const onChange = (e: BaseSyntheticEvent, index: number) => {
setArrayValue((preValue: (string | number)[]) => {
const newArray = [...preValue];
if (parseInt(e.target.value)) {
newArray[index] = parseInt(e.target.value);
} else {
newArray[index] = e.target.value;
}
return newArray;
});
};
const onKeyUp = (e: KeyboardEvent<HTMLInputElement>, index: number) => {
if (e.key === "Backspace") {
if (index === 0) {
setCurrentFocusedIndex(0);
} else {
setCurrentFocusedIndex(index - 1);
if (inputRefs && inputRefs.current && index === currentFocusedIndex) {
inputRefs.current[index - 1].focus();
}
}
} else {
if (parseInt(e.key) && index < arrayValue.length - 1) {
setCurrentFocusedIndex(index + 1);
if (inputRefs && inputRefs.current && index === currentFocusedIndex) {
inputRefs.current[index + 1].focus();
}
}
}
};
const onFocus = (e: BaseSyntheticEvent, index: number) => {
setCurrentFocusedIndex(index);
// e.target.focus();
};
useEffect(() => {
document.addEventListener("paste", async () => {
// Handle all sub-usecases here
const pastePermission = await navigator.permissions.query({
name: "clipboard-read" as PermissionName
});
if (pastePermission.state === "denied") {
throw new Error("Not allowed to read clipboard");
}
const clipboardContent = await navigator.clipboard.readText();
try {
let newArray: Array<number | string> = clipboardContent.split("");
newArray = newArray.map((num) => Number(num));
const lastIndex = arrayValue.length - 1;
if (currentFocusedIndex > 0) {
const remainingPlaces = lastIndex - currentFocusedIndex;
const partialArray = newArray.slice(0, remainingPlaces + 1);
setArrayValue([
...arrayValue.slice(0, currentFocusedIndex),
...partialArray
]);
} else {
setArrayValue([
...newArray,
...arrayValue.slice(newArray.length - 1, lastIndex)
]);
}
if (newArray.length < arrayValue.length && currentFocusedIndex === 0) {
setCurrentFocusedIndex(newArray.length - 1);
inputRefs.current[newArray.length - 1].focus();
} else {
setCurrentFocusedIndex(arrayValue.length - 1);
inputRefs.current[arrayValue.length - 1].focus();
}
} catch (err) {
console.error(err);
}
});
return () => {
document.removeEventListener("paste", () =>
console.log("Removed paste listner")
);
};
}, [arrayValue, currentFocusedIndex]);
return (
<>
<div> currentFocusedIndex: {currentFocusedIndex}</div>
<div>{arrayValue}</div>
{arrayValue.map((value: string | number, index: number) => (
<input
key={`index-${index}`}
ref={(el) => el && (inputRefs.current[index] = el)}
inputMode="numeric"
maxLength={1}
type="text"
value={String(value)}
onChange={(e) => onChange(e, index)}
onKeyUp={(e) => onKeyUp(e, index)}
onKeyDown={(e) => onKeyDown(e)}
onFocus={(e) => onFocus(e, index)}
/>
))}
</>
);
};
最后,您可以像下面这样将此组件导入到您的 App.tsx 文件中:
import "./styles.css";
import Passcode from "./components/Passcode";
export default function App() {
return (
<div className="App">
<h1>Passcode component</h1>
<Passcode />
</div>
);
}
以下是我们的密码组件的工作示例:
概括
总而言之,我们学习了如何构建密码组件,并解决了构建此类组件所需的一些基本情况。
如果您想使用此组件,可以查看我创建的库:react-headless-passcode。它是一个无头库,允许您将数组值传递给usePasscode
钩子,钩子可以处理上述所有复杂场景以及其他场景。使用无头库,您可以更加专注于构建密码 UI 并根据自己的喜好进行样式设置。所有控制权都交给了开发人员。react-headless-passcode 的作用是为您提供启动和运行密码组件所需的所有逻辑。
感谢您的阅读!
鏂囩珷鏉ユ簮锛�https://dev.to/keyurparalkar/create-a-passcode-component-from-scratch-in-react-4l88