基于命令模式使用 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 ( <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
|
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(
<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(""); const [content, setContent] = useState<Array<string>>([]); 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; }
|