基于命令模式使用 React 实现中文自动前进输入框(OTP)

基于命令模式使用 React 实现中文自动前进输入框(OTP)

最近写的项目中有一个需要自动换格子、自动聚焦并且让父组件获取输入值的需求。这个需求让我很头大,因为在 RN 中想要控制 DOM 绝非一件容易的事情。

难点分析

  • RN 控制 dom 相对麻烦

  • 输入的是中文,需要处理输入法或复制粘贴的问题

  • 内容需要和父子组件同步

  • 格子数量不定长

  • 每个 input 相对复杂

解决方案

React 的单向数据流与组件化的模式一定程度上可以提高我们系统的可维护性,而另一方面这种设计也会导致我们的父子组件通信相对麻烦。在此处如果我们想要使用ref来维护一个 input 数组这显然是不现实的,因为我们不能在 for 循环中定义react hooks。而且我的每个 Input 都相对比较复杂,所以我选择每个 input 封装一个组件,然后在组件中使用ref来控制vDom。那么我们如何通信呢?自然就是传参咯。那么子组件如何优雅的通知父组件自身输入的改变呢?经过思索我想到了一种设计模式:命令模式通过命令模式+状态函数我们可以优雅的将子组件的变化通知父组件。下面我们先来尝试实现一下最基本的功能,自动跳转与聚焦。

自动聚焦

首先我们需要先思考,一个输入框可能有几种状态,其实无非两种输入删除。但是我们是一组输入框,在其中来回跳,所以我们还需要一个回退状态。

1
2
3
4
5
6
7
8
9
10
11
// 定义命令格式
interface BaseCommand {
name: string;
value?: unknown;
}

interface CheckInputCommand extends BaseCommand {
value?: string | undefined;
additionalValue?: string;
callarIndex: number;
}
1
2
// 父组件中定义命令状态
const [command, setCommand] = useState<CheckInputCommand>();

这样,确定了状态,我们还需要确定一件事情,我们的子组件要接受哪些参数呢?

首先,设置命令的状态函数一定是必须的,另外我们还需要传递一个下标来让组件知道它的序号以及一个boolean来表示是否聚焦,代码如下:

1
2
3
4
5
6
7
8
9
10
11
// 定义子组件
type propsType = {
setCommand: Dispatch<SetStateAction<CheckInputCommand | undefined>>;
index: number;
focus: boolean;
};
function InputCheck({
setCommand,
index,
focus,
}: propsType): React.JSX.Element {}

并且我们在父组件中应该如是调用子组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 父组件
// 聚焦元素的下标,默认第一个元素聚焦
const [foucsElement, setFoucsElement] = useState(0);
return (
// 此处省略n个字
<View>
{arr.map((item, keyIndex) => {
return (
<InputCheck
key={`${index}-${keyIndex}`}
setCommand={setCommand}
index={keyIndex}
// 下标与聚焦元素的下标相同则聚焦
focus={foucsElement === keyIndex}
/>
);
})}
</View>
);

通过上述的代码,我们已经能够得知我们如何来让一个元素聚焦了:如果一个元素的下标与我们希望聚焦的元素下标相同则让他聚焦。我们知道在 react 中,如果 state 发生了变化则会引起发生变化的节点rerender,并且如果组件的 props 发生了变化会引起reaction的,这一系列反应让我们可以通过父节点的state来控制子组件。

那么在子组件我们应该如何处理这种变化呢?

那自然是useEffect啊!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 子组件
const [char, setChar] = useState("");
const inputRef = useRef<any>();
// 处理聚焦
useEffect(() => {
if (focus) {
inputRef.current.focus();
inputRef.current.setNativeProps({selection: {start: 1, end: 1}});
}
}, [focus, setInputValue]);
return (
<TextInput
value={char}
onChange={e => {
TextInputHandler(e.nativeEvent.text);
}}
ref={inputRef}
/>
);

好的,这样我们就可以处理父组件传递的focus参数并且自动聚焦了。而且通过上面的代码我们也能发现我们返回的TextInput是一个受控组件哎~那我们又是如何处理输入的呢?

详情还请看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/** 发送指令的函数 */
const sendCommand = useCallback(
(name: string, commandValue?: string, additionalValue?: string) => {
const command: CheckInputCommand = {
name: name,
value: commandValue,
callarIndex: index,
additionalValue: additionalValue,
};
setCommand(command);
},
[index, setCommand]
);

/** 真正处理输入的逻辑 */
const setInputValue = useCallback(
(str: string) => {
setChar(displayChar);
// 发送命令
sendCommand("input", commandStr, displayChar);
},
[sendCommand]
);

/** 处理输入 */
const TextInputHandler = (inputChar: string) => {
setInputValue(inputChar);
};

这样当我们输入的时候,子组件就已经能够发送命令通知父组件自己的变化了,这时我们就还需要父组件去监听子组件以及做出相应的反应咯:

1
2
3
4
5
6
7
8
9
10
11
12
// 父组件
/** 监听command变化 */
useEffect(() => {
/** 接收子组件发送的命令 */
if (command && command.name === "input") {
if (command.callarIndex === format.tunes.length - 1) {
return;
}
setFoucsElement(command.callarIndex + 1);
setChars(command.value || "");
}
}, [command, format.tunes, format.tunes.length]);

这样我们就可以自动的前进并聚焦啦!

处理中文

但是考虑到我们输入的是中文,所以我们必须要对输入进行一定的处理。首先是中文有输入法的问题,这样我们就需要判断是否是中文,然后只对中文做出相应

1
2
3
4
5
6
7
8
9
10
// 中文匹配正则
const reg = /^[\u4E00-\u9FA5]+$/;

/** 检查是否是中文 */
const verifyCharIsChinese = (char: string) => {
if (reg.test(char)) {
return true;
}
return false;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 子组件
/** 真正处理输入的逻辑 */
const setInputValue = useCallback({
if (!verifyCharIsChinese(str)) {
return;
}
// 截取中文
let displayChar = str.substring(0, 1);
let commandStr = str.substring(1);
setChar(displayChar);
// 发送命令
sendCommand("input", commandStr, displayChar);
},
[sendCommand],
);

另外因为中文会存在一次性输入多个字符的特点,所以我们就需要将输入的字符传递给父组件的问题。像上面的代码,我们已经看到我们已经截取了输入,然后发送到父组件,我们又该如何让父组件将多出的字符传递给剩下的子组件呢?我想到的方法是:useContext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 父组件
export const StrContext: Context<string> = createContext("");

fn(){ //省略函数定义
const [content, setContent] = useState<Array<string>>([]); // 保存真正的内容

if (command && command.name === "input") {
useEffect(() => {
if (command.callarIndex === format.tunes.length - 1) {
return;
}
setFoucsElement(command.callarIndex + 1);
setChars(command.value || "");
}
}
return(
// ...此处省略n-m个字
<StrContext.Provider value={chars}>
<View>
{arr.map((item, keyIndex) => {
return (
<InputCheck
key={`${index}-${keyIndex}`}
setCommand={setCommand}
index={keyIndex}
// 下标与聚焦元素的下标相同则聚焦
focus={foucsElement === keyIndex}
/>
);
})}
</View>
</StrContext>
)
}

同时我们需要让得到焦点的子组件去处理我们的上下文,即输入与重新发送指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
const value = useContext(StrContext);

/** 自动移动字符 */
useEffect(() => {
if (focus) {
inputRef.current.focus();
inputRef.current.setNativeProps({ selection: { start: 1, end: 1 } });
// 在上文的处理聚焦的函数中添加判断是否有全局字符串的方法
if (value) {
setInputValue(value);
}
}
}, [focus, setInputValue, value]);

处理删除与后退

如果光前进却不能后退那不是没过河的子嘛。看到如何处理前进那么如何处理后退应该也很明确了,但是需要注意,删除与后退并不一样,

  • 删除:输入为空

  • 后退:输入已经为空并且继续删除

我们先来实现删除,删除非常简单,在处理输入的时候判断是否为空即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 子组件
/** 真正处理输入的逻辑 */
const setInputValue = useCallback(
(str: string) => {
// 输入为空则发送删除命令
if (!str) {
setChar("");
sendCommand("delete");
return;
}
if (!verifyCharIsChinese(str)) {
return;
}
let displayChar = str.substring(0, 1);
let commandStr = str.substring(1);
setChar(displayChar);
// 发送命令
sendCommand("input", commandStr, displayChar);
},
[sendCommand]
);

父组件该如何处理我这里就掠过咯~

处理后退要稍微复杂一些,首先我们需要在子组件中新增一个监听,监听我们的键盘输入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 子组件
/** 监听键盘事件,处理退格事件 */
const backSpaceHandler = (keyName: string) => {
if (keyName === "Backspace" && !char) {
sendCommand("back");
}
};

return (
<TextInput
style={TuneStyle}
value={char}
onChange={(e) => {
TextInputHandler(e.nativeEvent.text);
}}
onKeyPress={(e) => {
backSpaceHandler(e.nativeEvent.key);
}}
ref={inputRef}
/>
);

然后父组件也需要处理退格事件:

1
2
3
4
5
6
7
// 父组件
else if (command && command.name === "back") {
if (command.callarIndex === 0) {
return;
}
setFoucsElement(command.callarIndex - 1);
}

内容同步

哎呀,内容同步这种事情读者朋友们自己个性化发挥就好,毕竟我在命令模式中携带了挺多参数呢,请大家根据各自的需求随意发挥就好。

完整代码

父组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
export const StrContext: Context<string> = createContext("");

export default function fn(): React.JSX.Element {
const [command, setCommand] = useState<CheckInputCommand>();
const [foucsElement, setFoucsElement] = useState(0);
const [chars, setChars] = useState(""); // 多出的文字,自动填充到下一个block
const [content, setContent] = useState<Array<string>>([]); // 保存真正的内容
/** 监听command变化 */
useEffect(() => {
/** 接收子组件发送的命令 */
if (command && command.name === "input") {
// 添加到内容
if (command.callarIndex >= 0) {
setContent(e => {
e[command.callarIndex] = addContentChar(
command.additionalValue || "",
command.callarIndex,
);
return [...e];
});
}
if (command.callarIndex === format.tunes.length - 1) {
return;
}
setFoucsElement(command.callarIndex + 1);
setChars(command.value || "");
} else if (command && command.name === "delete") {
setContent(e => {
e[command.callarIndex] = "";
return [...e];
});
} else if (command && command.name === "back") {
if (command.callarIndex === 0) {
return;
}
setFoucsElement(command.callarIndex - 1);
}
}, [command]);
return (
<StrContext.Provider key={index} value={chars}>
<View style={fillPoemStyle.inlineContainer} key={index}>
{arr.map((item, keyIndex) => {
return (
<InputCheck
tune={item.tune}
rhythm={item.rhythm}
key={`${index}-${keyIndex}`}
setCommand={setCommand}
index={item.index as number}
focus={foucsElement === item.index}
rhymeWord={rhymeWord}
/>
);
})}
</View>
</StrContext.Provider>
}


子组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
type propsType = {
setCommand: Dispatch<SetStateAction<CheckInputCommand | undefined>>;
index: number;
focus: boolean;
};

function InputCheck({
setCommand,
index,
focus,
}: propsType): React.JSX.Element {
const [char, setChar] = useState("");
const inputRef = useRef<any>();
const value = useContext(StrContext);
const sendCommand = useCallback(
(name: string, commandValue?: string, additionalValue?: string) => {
const command: CheckInputCommand = {
name: name,
value: commandValue,
callarIndex: index,
additionalValue: additionalValue,
};
setCommand(command);
},
[index, setCommand],
);
/** 真正处理输入的逻辑 */
const setInputValue = useCallback(
(str: string) => {
if (!str) {
setChar("");
sendCommand("delete");
return;
}
if (!verifyCharIsChinese(str)) {
return;
}
let displayChar = str.substring(0, 1);
let commandStr = str.substring(1);
setChar(displayChar);
// 发送命令
sendCommand("input", commandStr, displayChar);
},
[sendCommand],
);
/** 自动移动字符 */
useEffect(() => {
if (focus) {
inputRef.current.focus();
inputRef.current.setNativeProps({selection: {start: 1, end: 1}});
if (value) {
setInputValue(value);
}
}
}, [focus, setInputValue, value]);
/** 处理输入 */
const TextInputHandler = (inputChar: string) => {
setInputValue(inputChar);
};
/** 监听键盘事件,处理退格事件 */
const backSpaceHandler = (keyName: string) => {
if (keyName === "Backspace" && !char) {
sendCommand("back");
}
};
return (
<TextInput
style={TuneStyle}
value={char}
onChange={e => {
TextInputHandler(e.nativeEvent.text);
}}
onKeyPress={e => {
backSpaceHandler(e.nativeEvent.key);
}}
ref={inputRef}
/>
);
}

export default InputCheck;

types

1
2
3
4
5
6
7
8
9
10
11
// 定义命令格式
interface BaseCommand {
name: string;
value?: unknown;
}

interface CheckInputCommand extends BaseCommand {
value?: string | undefined;
additionalValue?: string;
callarIndex: number;
}

基于命令模式使用 React 实现中文自动前进输入框(OTP)
2024/01/16/technology/react/rn_opt/
作者
charlesix59
发布于
2024年1月16日
许可协议