React Native 表单管理教程 - 构建信用卡表单
表单在各种应用程序中都非常常见。因此,开发者经常致力于简化表单的构建过程。我之前构建过一些自定义解决方案,也使用过所有流行的表单管理库。我认为就开发者体验和自定义程度而言,react-hook-form是最好的。
在网页上使用起来非常简单。你只需创建 HTML 输入元素并注册它们即可。但在 React Native 中就稍微复杂一些。因此,我会尽量详细描述我的每个步骤,以便更清晰地说明我的方法。本教程将创建一个信用卡表单,但它对创建任何类型的表单都很有帮助。我们在这里构建的大多数组件也都可以复用。
您可以在GitHub上找到此组件的完整版本。我还借助 react-native-web 将 React Native 代码移植到了 Web 端。您可以在我的博客上体验一下。
目录
从简单的用户界面开始
在本教程中,我使用了在 Dribbble 上找到的这款简洁设计作为参考。我还使用了我在上一篇文章中构建的TextField组件。以下是使用简单的局部状态变量生成 UI 的组件:CreditCardForm
// CreditCardForm.tsx
import React, { useState } from 'react';
import { StyleSheet, View } from 'react-native';
import Button from './Button';
import TextField from './TextField';
const CreditCardForm: React.FC = () => {
const [name, setName] = useState('');
const [cardNumber, setCardNumber] = useState('');
const [expiration, setExpiration] = useState('');
const [cvv, setCvv] = useState('');
function onSubmit() {
console.log('form submitted');
}
return (
<View>
<TextField
style={styles.textField}
label="Cardholder Name"
value={name}
onChangeText={(text) => setName(text)}
/>
<TextField
style={styles.textField}
label="Card Number"
value={cardNumber}
onChangeText={(text) => setCardNumber(text)}
/>
<View style={styles.row}>
<TextField
style={[
styles.textField,
{
marginRight: 24,
},
]}
label="Expiration Date"
value={expiration}
onChangeText={(text) => setExpiration(text)}
/>
<TextField
style={styles.textField}
label="Security Code"
value={cvv}
onChangeText={(text) => setCvv(text)}
/>
</View>
<Button title="PAY $15.12" onPress={onSubmit} />
</View>
);
};
const styles = StyleSheet.create({
row: {
flex: 1,
flexDirection: 'row',
marginBottom: 36,
},
textField: {
flex: 1,
marginTop: 24,
},
});
export default CreditCardForm;
我只是将表单包含在ScrollView组件中App:
// App.tsx
import React, { useState } from 'react';
import { StyleSheet, Text, ScrollView } from 'react-native';
import CreditCardForm from './components/CreditCardForm';
const App: React.FC = () => {
return (
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.title}>Payment details</Text>
<CreditCardForm />
</ScrollView>
);
};
const styles = StyleSheet.create({
content: {
paddingTop: 96,
paddingHorizontal: 36,
},
title: {
fontFamily: 'Avenir-Heavy',
color: 'black',
fontSize: 32,
marginBottom: 32,
},
});
export default App;
集成 react-hook-form
使用react-hook-form自动化工具相比手动构建表单逻辑具有诸多优势。最显著的优势在于代码更易读、更易于维护且更具可重用性。
那么,让我们开始react-hook-form为我们的项目添加内容吧:
npm install react-hook-form
// or
yarn add react-hook-form
您可以使用TextInput其中的任何组件react-hook-form。它有一个特殊的Controller组件,可以帮助您将输入注册到库中。
这是构建 React Native 表单所需的最小代码块react-hook-form:
// App.tsx
import React from 'react';
import { View, Text, TextInput } from 'react-native';
import { useForm, Controller } from 'react-hook-form';
export default function App() {
const { control, handleSubmit, errors } = useForm();
const onSubmit = (data) => console.log(data);
return (
<View>
<Controller
control={control}
render={({ onChange, onBlur, value }) => (
<TextInput
style={styles.input}
onBlur={onBlur}
onChangeText={(value) => onChange(value)}
value={value}
/>
)}
name="firstName"
rules={{ required: true }}
defaultValue=""
/>
{errors.firstName && <Text>This is required.</Text>}
</View>
);
}
虽然这对于单个输入来说已经足够好了,但更好的做法是创建一个通用的包装输入组件来处理重复性工作,例如使用 `input`Controller和显示错误消息。为此,我将创建一个 `input`组件FormTextField。它需要访问 `input` 方法返回的一些属性useForm。我们可以将这些值作为 prop 从 ` CreditCardForminput` 组件传递到FormTextField`input` 组件,但这会导致每个输入框都重复使用相同的 prop。幸运的是,`input` 组件react-hook-form提供了一个 ` useFormContextgetProperties` 方法,允许你在更深的组件层级访问所有表单属性。
看起来FormTextField会是这样的:
// FormTextField.tsx
import React from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import TextField from './TextField';
type Props = React.ComponentProps<typeof TextField> & {
name: string;
};
const FormTextField: React.FC<Props> = (props) => {
const { name, ...restOfProps } = props;
const { control, errors } = useFormContext();
return (
<Controller
control={control}
render={({ onChange, onBlur, value }) => (
<TextField
// passing everything down to TextField
// to be able to support all TextInput props
{...restOfProps}
errorText={errors[name]?.message}
onBlur={onBlur}
onChangeText={(value) => onChange(value)}
value={value}
/>
)}
name={name}
/>
);
};
export default FormTextField;
现在,是时候将我们的表单组件迁移到新版本了react-hook-form。我们只需将TextFields 替换为新FormTextField组件,将局部状态变量替换为单个表单模型,并将表单包裹在新版本中即可FormProvider。
请注意,为表单创建 TypeScript 类型非常简单。您需要创建一个FormModel包含表单中每个字段的类型。请注意,字段名称应与您传递给 `<type>` 库的名称匹配FormTextField。库将根据该属性更新正确的字段。
修改完成后,新版本将如下所示。您可以在GithubCreditCardForm上查看完整的差异。
// CreditCardForm.tsx
interface FormModel {
holderName: string;
cardNumber: string;
expiration: string;
cvv: string;
}
const CreditCardForm: React.FC = () => {
const formMethods = useForm<FormModel>({
defaultValues: {
holderName: '',
cardNumber: '',
expiration: '',
cvv: '',
},
});
function onSubmit(model: FormModel) {
console.log('form submitted', model);
}
return (
<View>
<FormProvider {...formMethods}>
<FormTextField
style={styles.textField}
name="holderName"
label="Cardholder Name"
/>
<FormTextField
style={styles.textField}
name="cardNumber"
label="Card Number"
/>
<View style={styles.row}>
<FormTextField
style={[
styles.textField,
{
marginRight: 24,
},
]}
name="expiration"
label="Expiration Date"
/>
<FormTextField
style={styles.textField}
name="cvv"
label="Security Code"
keyboardType="number-pad"
/>
</View>
<Button
title="PAY $15.12"
onPress={formMethods.handleSubmit(onSubmit)}
/>
</FormProvider>
</View>
);
};
提高可重复使用性
此时我必须就表单的复用性做出决定。这涉及到最初使用该useForm方法创建表单的位置。我们有两种选择:
- 将表单内部定义成现在的样子。如果您只在单个流程/屏幕中使用信用卡表单,这样做很合理。这样
CreditCardForm您就无需在多个地方重新定义表单并传递它。FormProvider - 在父组件(即使用该表单的组件)中定义表单。这样
CreditCardForm您就可以访问所有方法,并基于表单提供的所有内容构建独立功能。假设您有两个屏幕:一个用于支付产品,另一个用于注册信用卡。按钮在这两种情况下应该看起来不同。react-hook-formCreditCardForm
以下是第二种方案的一个例子。在这个例子中,我们会监听卡号的变化,并据此更新按钮标题:
// App.tsx
const App: React.FC = () => {
+ const formMethods = useForm<FormModel>({
+ // to trigger the validation on the blur event
+ mode: 'onBlur',
+ defaultValues: {
+ holderName: 'Halil Bilir',
+ cardNumber: '',
+ expiration: '',
+ cvv: '',
+ },
+ })
+ const cardNumber = formMethods.watch('cardNumber')
+ const cardType = cardValidator.number(cardNumber).card?.niceType
+
+ function onSubmit(model: FormModel) {
+ Alert.alert('Success')
+ }
+
return (
<ScrollView contentContainerStyle={styles.content}>
- <Text style={styles.title}>Payment details</Text>
- <CreditCardForm />
+ <FormProvider {...formMethods}>
+ <Text style={styles.title}>Payment details</Text>
+ <CreditCardForm />
+ <Button
+ title={cardType ? `PAY $15.12 WITH ${cardType}` : 'PAY $15.12'}
+ onPress={formMethods.handleSubmit(onSubmit)}
+ />
+ </FormProvider>
</ScrollView>
)
}
我选择第二个方案。
验证
react-hook-form让我们只需将参数传递 rules给 ` .` 即可定义验证Controller。首先,让我们将其添加到`.` 中FormTextField:
// FormTextField.tsx
-import { useFormContext, Controller } from 'react-hook-form'
+import { useFormContext, Controller, RegisterOptions } from 'react-hook-form'
import TextField from './TextField'
type Props = React.ComponentProps<typeof TextField> & {
name: string
+ rules: RegisterOptions
}
const FormTextField: React.FC<Props> = (props) => {
- const { name, ...restOfProps } = props
+ const { name, rules, ...restOfProps } = props
const { control, errors } = useFormContext()
return (
@@ -25,6 +26,7 @@ const FormTextField: React.FC<Props> = (props) => {
/>
)}
name={name}
+ rules={rules}
/>
)
}
在本教程中,我将把验证逻辑委托给Braintree 的 card-validator库,以便我们专注于表单部分。现在我需要rules为我们的FormTextField组件定义一个对象,rules该对象将包含两个属性:
required:这用于设置当字段为空时显示的消息。validate.{custom_validation_name}我们可以在这里创建一个自定义验证方法。我将使用它,通过card-validation库来验证输入值的完整性。
我们的输入字段需要如下所示。您可以在GitHub上查看完整的验证规则差异。
// CreditCardForm.tsx
<>
<FormTextField
style={styles.textField}
name="holderName"
label="Cardholder Name"
rules={{
required: 'Cardholder name is required.',
validate: {
isValid: (value: string) => {
return (
cardValidator.cardholderName(value).isValid ||
'Cardholder name looks invalid.'
);
},
},
}}
/>
<FormTextField
style={styles.textField}
name="cardNumber"
label="Card Number"
keyboardType="number-pad"
rules={{
required: 'Card number is required.',
validate: {
isValid: (value: string) => {
return (
cardValidator.number(value).isValid ||
'This card number looks invalid.'
);
},
},
}}
/>
<FormTextField
style={[
styles.textField,
{
marginRight: 24,
},
]}
name="expiration"
label="Expiration Date"
rules={{
required: 'Expiration date is required.',
validate: {
isValid: (value: string) => {
return (
cardValidator.expirationDate(value).isValid ||
'This expiration date looks invalid.'
);
},
},
}}
/>
<FormTextField
style={styles.textField}
name="cvv"
label="Security Code"
keyboardType="number-pad"
maxLength={4}
rules={{
required: 'Security code is required.',
validate: {
isValid: (value: string) => {
const cardNumber = formMethods.getValues('cardNumber');
const { card } = cardValidator.number(cardNumber);
const cvvLength = card?.type === 'american-express' ? 4 : 3;
return (
cardValidator.cvv(value, cvvLength).isValid ||
'This security code looks invalid.'
);
},
},
}}
/>
</>
完成这些更改后,点击按钮将看到以下屏幕PAY:
触发验证
验证触发方案react-hook-form无需任何自定义代码即可配置。mode参数用于配置验证触发方案:
onChange验证将在提交事件时触发,无效输入将附加 onChange 事件监听器以重新验证它们。onBlur:失去焦点时将触发验证。onTouched验证将在第一次失去焦点事件时触发。之后,它将在每次更改事件时触发。
虽然这些模式足以应对大多数情况,但我希望我的表单能够实现自定义行为。我希望能够快速地向用户提供反馈,但又不能太快。这意味着我希望在用户输入足够的字符后立即验证输入。因此,我创建了一个 effect,它会监视输入值,并在输入值超过某个阈值(此处为 prop)FormTextField时触发验证。validationLength
请注意,这并非表单正常运行的必要条件,而且如果您的验证方法比较耗费资源,则可能会造成一定的性能损失。
// FormTextField.tsx
type Props = React.ComponentProps<typeof TextField> & {
name: string
rules: RegisterOptions
+ validationLength?: number
}
const FormTextField: React.FC<Props> = (props) => {
- const { name, rules, ...restOfProps } = props
- const { control, errors } = useFormContext()
+ const {
+ name,
+ rules,
+ validationLength = 1,
+ ...restOfProps
+ } = props
+ const { control, errors, trigger, watch } = useFormContext()
+ const value = watch(name)
+
+ useEffect(() => {
+ if (value.length >= validationLength) {
+ trigger(name)
+ }
+ }, [value, name, validationLength, trigger])
格式化输入值
为了使卡号和有效期输入字段看起来美观,我会根据用户输入的每个新字符立即格式化它们的值。
- 信用卡号:我将按
XXXX XXXX XXXX XXXX格式格式化其值。 - 到期日期:我将按
MM/YY格式格式化其值。
虽然有一些库可以实现类似的功能,但我希望自己创建一个简单的解决方案。因此,我utils/formatters.ts为此创建了一个文件:
// utils/formatters.ts
export function cardNumberFormatter(
oldValue: string,
newValue: string,
): string {
// user is deleting so return without formatting
if (oldValue.length > newValue.length) {
return newValue;
}
return newValue
.replace(/\W/gi, '')
.replace(/(.{4})/g, '$1 ')
.substring(0, 19);
}
export function expirationDateFormatter(
oldValue: string,
newValue: string,
): string {
// user is deleting so return without formatting
if (oldValue.length > newValue.length) {
return newValue;
}
return newValue
.replace(/\W/gi, '')
.replace(/(.{2})/g, '$1/')
.substring(0, 5);
}
现在我们只需formatter为FormTextField组件创建一个 prop,并将它返回的值传递给它onChange:
// FormTextField.tsx
- onChangeText={(value) => onChange(value)}
+ onChangeText={(text) => {
+ const newValue = formatter ? formatter(value, text) : text
+ onChange(newValue)
+ }}
value={value}
/>
)}
我创建了一些测试,以确保格式化工具使用 Jest 的test.each方法返回预期值。我希望这能帮助您更容易地理解这些工具方法的作用:
// utils/formatters.test.ts
import { cardNumberFormatter, expirationDateFormatter } from './formatters';
describe('cardNumberFormatter', () => {
test.each([
{
// pasting the number
oldValue: '',
newValue: '5555555555554444',
output: '5555 5555 5555 4444',
},
{
// trims extra characters
oldValue: '',
newValue: '55555555555544443333',
output: '5555 5555 5555 4444',
},
{
oldValue: '555',
newValue: '5555',
output: '5555 ',
},
{
// deleting a character
oldValue: '5555 5',
newValue: '5555 ',
output: '5555 ',
},
])('%j', ({ oldValue, newValue, output }) => {
expect(cardNumberFormatter(oldValue, newValue)).toEqual(output);
});
});
describe('expirationDateFormatter', () => {
test.each([
{
// pasting 1121
oldValue: '',
newValue: '1121',
output: '11/21',
},
{
// pasting 11/21
oldValue: '',
newValue: '11/21',
output: '11/21',
},
{
oldValue: '1',
newValue: '12',
output: '12/',
},
{
// deleting a character
oldValue: '12/2',
newValue: '12/',
output: '12/',
},
])('%j', ({ oldValue, newValue, output }) => {
expect(expirationDateFormatter(oldValue, newValue)).toEqual(output);
});
});
专注于下一个领域
我认为这是一种优秀的表单用户体验模式:当用户填写完当前输入框后,将注意力集中在下一个输入框上。有两种方法可以判断用户何时完成填写:
- 监听
onSubmitEditing输入事件。当用户点击键盘上的回车键时,此事件会被触发。 - 检查输入验证结果:这意味着用户已输入信用卡、有效期和 CVV 字段的所有必要字符(如果有效)。
我将对持卡人姓名输入使用第一种方法,对其他输入使用第二种方法。这是因为我们无法预知持卡人姓名何时输入完整,这与其他输入方式不同。
我们需要ref为每个输入保留一个 s,并nextTextInputRef.focus相应地调用方法。我们有两个自定义组件封装了 React Native TextInput:分别是FormTextField和TextField。因此,我们必须使用React.forwardRef来确保ref附加到原生组件TextInput。
以下是我搭建这个装置的步骤:
- 包装好
FormTextField并附TextField有React.forwardRef:
+ import { TextInput } from "react-native"
// components/FormTextField.tsx
-const FormTextField: React.FC<Props> = (props) => {
+const FormTextField = React.forwardRef<TextInput, Props>((props, ref) => {
// components/TextField.tsx
-const TextField: React.FC<Props> = (props) => {
+const TextField = React.forwardRef<TextInput, Props>((props, ref) => {
- 在组件上创建了
onValid属性FormTextField,并修改了触发验证的效果:
// FormTextField.tsx
useEffect(() => {
+ async function validate() {
+ const isValid = await trigger(name)
+ if (isValid) onValid?.()
+ }
+
if (value.length >= validationLength) {
- trigger(name)
+ validate()
}
}, [value, name, validationLength, trigger])
- 为每个组件创建了一个引用,并触发了下一个输入引用的
onFocus方法:
// CreditCardForm.tsx
+ const holderNameRef = useRef<TextInput>(null)
+ const cardNumberRef = useRef<TextInput>(null)
+ const expirationRef = useRef<TextInput>(null)
+ const cvvRef = useRef<TextInput>(null)
<>
<FormTextField
+ ref={holderNameRef}
name="holderName"
label="Cardholder Name"
+ onSubmitEditing={() => cardNumberRef.current?.focus()}
/>
<FormTextField
+ ref={cardNumberRef}
name="cardNumber"
label="Card Number"
+ onValid={() => expirationRef.current?.focus()}
/>
<FormTextField
+ ref={expirationRef}
name="expiration"
label="Expiration Date"
+ onValid={() => cvvRef.current?.focus()}
/>
<FormTextField
+ ref={cvvRef}
name="cvv"
label="Security Code"
+ onValid={() => {
+ // form is completed so hide the keyboard
+ Keyboard.dismiss()
+ }}
/>
</>
您可以在Github上查看此部分的完整差异。
显示卡片类型图标
这是我们的最后一个功能。我已经创建了CardIcon相应的组件,并将通过endEnhancerprop 将其传递给输入框。
// CardIcon.tsx
import React from 'react';
import { Image, StyleSheet } from 'react-native';
import cardValidator from 'card-validator';
const VISA = require('./visa.png');
const MASTERCARD = require('./mastercard.png');
const AMEX = require('./amex.png');
const DISCOVER = require('./discover.png');
type Props = {
cardNumber: string;
};
const CardIcon: React.FC<Props> = (props) => {
const { cardNumber } = props;
const { card } = cardValidator.number(cardNumber);
let source;
switch (card?.type) {
case 'visa':
source = VISA;
break;
case 'mastercard':
source = MASTERCARD;
break;
case 'discover':
source = DISCOVER;
break;
case 'american-express':
source = AMEX;
break;
default:
break;
}
if (!source) return null;
return <Image style={styles.image} source={source} />;
};
const styles = StyleSheet.create({
image: {
width: 48,
height: 48,
},
});
export default CardIcon;
您可以在这里查看卡片图标的完整差异。
测试
我将为表单的关键部分创建一些测试,以确保我们能够立即知道它们何时出现问题,这些关键部分包括验证、值格式化和表单提交。
我喜欢用react-native-testing-library来进行测试。它能让你创建类似于用户行为的测试。
我还在使用bdd-lazy-var,这是我在上一份工作中学到的工具。我仍然会在测试中使用它,因为它能以更清晰、更易读的方式描述测试变量。
因此,我将创建一个表单,并像在实际屏幕上使用一样useForm将其传递给测试。然后,我会更改输入值,测试验证结果,并在点击提交按钮时检查返回结果。以下是我所有测试用例中将使用的基本设置:FormProviderreact-hook-form
// CreditCardForm.test.tsx
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react-native';
import { def, get } from 'bdd-lazy-var/getter';
import { useForm, FormProvider } from 'react-hook-form';
import { Button } from 'react-native';
import CreditCardForm from './CreditCardForm';
const FormWrapper = () => {
const formMethods = useForm({
mode: 'onBlur',
defaultValues: {
holderName: '',
cardNumber: '',
expiration: '',
cvv: '',
},
});
const { handleSubmit } = formMethods;
const onSubmit = (model) => {
get.onSubmit(model);
};
return (
<FormProvider {...formMethods}>
<CreditCardForm />
<Button onPress={handleSubmit(onSubmit)} title={'Submit'} />
</FormProvider>
);
};
def('render', () => () => render(<FormWrapper />));
def('onSubmit', () => jest.fn());
测试信用卡号验证
本测试用例中包含三个断言:
- 输入16个字符后才会触发验证。
- 当我输入无效的信用卡号码时,屏幕显示错误信息。
- 输入有效的卡号后,错误就消失了。
// CreditCardForm.test.tsx
it('validates credit card number', async () => {
const { queryByText, getByTestId } = get.render();
// does not display validation message until input is filled
const cardInput = getByTestId('TextField.cardNumber');
fireEvent.changeText(cardInput, '55555555');
await waitFor(() => {
expect(queryByText(/This card number looks invalid./)).toBeNull();
});
// invalid card
fireEvent.changeText(cardInput, '5555555555554440');
await waitFor(() => {
expect(queryByText(/This card number looks invalid./)).not.toBeNull();
});
// valid card
fireEvent.changeText(cardInput, '5555 5555 5555 4444');
await waitFor(() => {
expect(queryByText(/This card number looks invalid./)).toBeNull();
});
});
测试有效期验证
使用已通过且有效的日期进行测试,并检查验证错误是否显示/隐藏:
// CreditCardForm.test.tsx
it('validates expiration date', async () => {
const { queryByText, getByTestId } = get.render();
const input = getByTestId('TextField.expiration');
// passed expiration date
fireEvent.changeText(input, '1018');
await waitFor(() =>
expect(queryByText(/This expiration date looks invalid./)).not.toBeNull(),
);
// valid date
fireEvent.changeText(input, '10/23');
await waitFor(() =>
expect(queryByText(/This expiration date looks invalid./)).toBeNull(),
);
});
测试表单提交
在每个输入框中输入正确的值,然后点击提交按钮。我期望之后该onSubmit方法会被调用,并传入正确且格式化的数据:
// CreditCardForm.test.tsx
it('submits the form', async () => {
const { getByText, getByTestId } = get.render();
fireEvent.changeText(getByTestId('TextField.holderName'), 'Halil Bilir');
fireEvent.changeText(getByTestId('TextField.cardNumber'), '5555555555554444');
fireEvent.changeText(getByTestId('TextField.expiration'), '0224');
fireEvent.changeText(getByTestId('TextField.cvv'), '333');
fireEvent.press(getByText('Submit'));
await waitFor(() =>
expect(get.onSubmit).toHaveBeenLastCalledWith({
holderName: 'Halil Bilir',
// cardNumber and expiration are now formatted
cardNumber: '5555 5555 5555 4444',
expiration: '02/24',
cvv: '333',
}),
);
});
输出
您可以在Github上找到完整版本。如果您有任何反馈或疑问,请随时通过Twitter给我发消息。
文章来源:https://dev.to/halilb/react-native-form-management-tutorial-building-a-credit-card-form-3bjp
