辅助功能 (A11y)
React Hook Form 支持表单本地校验,它也允许你使用自定义的规则校验输入组件的值。由于我们大多数人都必须在自定义设计和布局中构建表单,因此我们有责任去确保表单校验过程具有可访问性(A11y)。
下面的代码示例展示了如何校验;但是, 它可以提高可访问性。
import React from "react"; import { useForm } from "react-hook-form"; export default function App() { const { register, handleSubmit, formState: { errors } } = useForm(); const onSubmit = data => console.log(data); return ( <form onSubmit={handleSubmit(onSubmit)}> <label htmlFor="name">Name</label> <input id="name" {...register('name', { required: true, maxLength: 30 })} /> {errors.name && errors.name.type === "required" && <span>This is required</span>} {errors.name && errors.name.type === "maxLength" && <span>Max length exceeded</span> } <input type="submit" /> </form> ); }
下面的代码示例是利用ARIA后的改进版本。
import React from "react"; import { useForm } from "react-hook-form"; export default function App() { const { register, handleSubmit, formState: { errors } } = useForm(); const onSubmit = (data) => console.log(data); return ( <form onSubmit={handleSubmit(onSubmit)}> <label htmlFor="name">Name</label> {/* use aria-invalid to indicate field contain error */} <input id="name" aria-invalid={errors.name ? "true" : "false"} {...register('name', { required: true, maxLength: 30 })} /> {/* use role="alert" to announce the error message */} {errors.name && errors.name.type === "required" && ( <span role="alert">This is required</span> )} {errors.name && errors.name.type === "maxLength" && ( <span role="alert">Max length exceeded</span> )} <input type="submit" /> </form> ); }
改进后,屏幕阅读器会念出: "Name, edit, invalid entry, This is required.”
多步骤表单/漏斗
通过不同的页面和部分收集用户信息是很常见的。 我们建议使用状态管理库通过不同的页面/部分去存储用户输入的值。 在这个例子中,我们将使用little state machine作为我们的状态管理库(如果您更熟悉redux,可以直接替代)。
♦
步骤1: 设置你的routes和store。
import React from "react"; import { BrowserRouter as Router, Route } from "react-router-dom"; import { StateMachineProvider, createStore } from "little-state-machine"; import Step1 from "./Step1"; import Step2 from "./Step2"; import Result from "./Result"; createStore({ data: { firstName: '', lastName: '', } }); export default function App() { return ( <StateMachineProvider> <Router> <Route exact path="/" component={Step1} /> <Route path="/step2" component={Step2} /> <Route path="/result" component={Result} /> </Router> </StateMachineProvider> ); }
步骤2: 创建页面,收集、提交数据到 store 中,并推送到下一个表单/页面。
import React from "react"; import { useForm } from "react-hook-form"; import { withRouter } from "react-router-dom"; import { useStateMachine } from "little-state-machine"; import updateAction from "./updateAction"; const Step1 = props => { const { register, handleSubmit } = useForm(); const { actions } = useStateMachine({ updateAction }); const onSubmit = data => { actions.updateAction(data); props.history.push("./step2"); }; return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register("firstName")} /> <input {...register("lastName")} /> <input type="submit" /> </form> ); }; export default withRouter(Step1);
步骤3: 最终提交 store 里的所有数据,或者展示出来。
import React from "react"; import { useStateMachine } from "little-state-machine"; import updateAction from "./updateAction"; const Result = props => { const { state } = useStateMachine(updateAction); return <pre>{JSON.stringify(state, null, 2)}</pre>; };
按照上述模式,您应该能够构建多步骤表单/漏斗以收集来自多个页面的用户输入数据。
智能表单组件
它的思想是,你可以很容易地用输入组件来组合你的表单。 我们将创建一个表单组件来自动收集表单数据。
import React from "react"; import { Form, Input, Select } from "./Components"; export default function App() { const onSubmit = data => console.log(data); return ( <Form onSubmit={onSubmit}> <Input name="firstName" /> <Input name="lastName" /> <Select name="gender" options={["female", "male", "other"]} /> <Input type="submit" value="Submit" /> </Form> ); }
♦
让我们来逐个看下这些组件。
Form
Form
组件的职责是将react-hook-form
所有的方法注入到子组件中
import React from "react"; import { useForm } from "react-hook-form"; export default function Form({ defaultValues, children, onSubmit }) { const methods = useForm({ defaultValues }); const { handleSubmit } = methods; return ( <form onSubmit={handleSubmit(onSubmit)}> {React.Children.map(children, child => { return child.props.name ? React.createElement(child.type, { ...{ ...child.props, register: methods.register, key: child.props.name } }) : child; })} </form> ); }
Input / Select
这些输入组件的职责是将它们注册到 react-hook-form
中。
import React from "react"; export function Input({ register, name, ...rest }) { return <input {...register(name)} {...rest} />; } export function Select({ register, options, name, ...rest }) { return ( <select {...register(name)} {...rest}> {options.map(value => ( <option key={value} value={value}> {value} </option> ))} </select> ); }
随着Form
组件将react-hook-form's props
注入到子组件中,您可以轻松地在应用中组合各种复杂的表单。
错误信息
错误信息是对用户非正确输入的可视化反馈。React Hook Form 提供的错误对象可以让你轻松地定位错误。有很多不同的方式更好地在屏幕上展示错误。
Register
你可以通过校验规则对象中的
message
属性将错误信息传递给register
, 就像这样:<input {...register('test', { maxLength: { value: 2, message: "error message" } })} />
Optional Chaining
optional chaining运算符
?.
。允许您读取错误对象,而不必担心由于null
或undefined
而导致错误。errors?.firstName?.message
Lodash
get
如果你的项目中引入了 lodash, 那么可以使用
get
函数。例如:get(errors, 'firstName.message')
连接表单
当我们构建表单时,有时输入组件在组件树中嵌套的很深,这正是FormContext的使用场景。 然而,我们可以通过创建 ConnectionForm 组件并利用React的renderProps来进一步改善开发体验。它的好处是可以更简单地连接输入组件与 React Hook Form。
import { FormProvider, useForm, useFormContext } from "react-hook-form"; export const ConnectForm = ({ children }) => { const methods = useFormContext(); return children({ ...methods }); }; export const DeepNest = () => ( <ConnectForm> {({ register }) => <input {...register("deepNestedInput")} />} </ConnectForm> ); export const App = () => { const methods = useForm(); return ( <FormProvider {...methods} > <form> <DeepNest /> </form> </FormProvider> ); }
FormProvider 性能优化
React Hook Form的FormProvider是建立在React的ContextAPI上。它解决了在组件树中数据需要逐级手动传递的问题。 但是同时它也会导致组件树在 React Hook Form 状态更新时触发重渲染,而下方的示例展示了——如果需要,我们仍然可以继续优化它。
import React, { memo } from "react"; import { useForm, FormProvider, useFormContext } from "react-hook-form"; // we can use React.memo to prevent re-render except isDirty state changed const NestedInput = memo( ({ register, formState: { isDirty } }) => ( <div> <input {...register("test")} /> {isDirty && <p>This field is dirty</p>} </div> ), (prevProps, nextProps) => prevProps.formState.isDirty === nextProps.formState.isDirty ); export const NestedInputContainer = ({ children }) => { const methods = useFormContext(); return <NestedInput {...methods} />; }; export default function App() { const methods = useForm(); const onSubmit = data => console.log(data); console.log(methods.formState.isDirty); // make sure formState is read before render to enable the Proxy return ( <FormProvider {...methods}> <form onSubmit={methods.handleSubmit(onSubmit)}> <NestedInputContainer /> <input type="submit" /> </form> </FormProvider> ); }
受控组件与非控制组件共存
React Hook Form 优先采用非受控组件,与此同时也兼容受控组件。 大多数UI库仅限于支持受控组件,如Material-UI和And。此外,受控组件的重渲染 React Hook Form 也做了优化。 以下表单验证的例子包含了受控组件和非受控组件。
import React, { useEffect } from "react"; import { Input, Select, MenuItem } from "@material-ui/core"; import { useForm, Controller } from "react-hook-form"; const defaultValues = { select: "", input: "" }; function App() { const { handleSubmit, reset, watch, control } = useForm({ defaultValues }); const onSubmit = data => console.log(data); return ( <form onSubmit={handleSubmit(onSubmit)}> <Controller render={ ({ field }) => <Select {...field}> <MenuItem value={10}>Ten</MenuItem> <MenuItem value={20}>Twenty</MenuItem> </Select> } control={control} name="select" defaultValue={10} /> <Input {...register("input")} /> <button type="button" onClick={() => reset({ defaultValues })}>Reset</button> <input type="submit" /> </form> ); }
Resolver 与自定义Hook
您可以构建一个自定义hook 作为 resolver。作为校验方法,yup/Joi/Superstruct可以非常方便地集成到自定义hook中 ,并在校验 resolver 中使用。
- 定义一个缓存的校验结构(或者在没有任何依赖的前提下定义在组件外部)
- 传入校验结构,使用自定义hook
- 将校验 resolver 传递给 useForm hook
import React, { useCallback, useMemo } from "react"; import { useForm } from "react-hook-form"; import * as yup from "yup"; const useYupValidationResolver = validationSchema => useCallback( async data => { try { const values = await validationSchema.validate(data, { abortEarly: false }); return { values, errors: {} }; } catch (errors) { return { values: {}, errors: errors.inner.reduce( (allErrors, currentError) => ({ ...allErrors, [currentError.path]: { type: currentError.type ?? "validation", message: currentError.message } }), {} ) }; } }, [validationSchema] ); const validationSchema = yup.object({ firstName: yup.string().required("Required"), lastName: yup.string().required("Required") }); export default function App() { const resolver = useYupValidationResolver(validationSchema); const { handleSubmit, register } = useForm({ resolver }); return ( <form onSubmit={handleSubmit(data => console.log(data))}> <input {...register("firstName")} /> <input {...register("lastName")} /> <input type="submit" /> </form> ); }
使用虚拟列表
想象一下您有一个表。该表可能包含数百或数千行,并且每一行都可以输入。 一种常见的做法是仅渲染视窗内的部分,但是这会引起问题,因为这些表格会在脱离视窗时被移出DOM,反之则重新添加。这样就会导致这些表格 在进入视窗时被重置为默认值。
下面是一个使用示例 react-window.
import React from 'react' import { FormProvider, useForm, useFormContext } from 'react-hook-form' import { VariableSizeList as List } from 'react-window' import AutoSizer from 'react-virtualized-auto-sizer' import ReactDOM from 'react-dom' import './styles.css' const items = Array.from(Array(1000).keys()).map((i) => ({ title: `List ${i}`, quantity: Math.floor(Math.random() * 10), })) const WindowedRow = React.memo(({ index, style, data }) => { const { register } = useFormContext() return <input {...register(`${index}.quantity`)} /> }) export const App = () => { const onSubmit = (data) => console.log(data) const formMethods = useForm({ defaultValues: items }) return ( <form className="form" onSubmit={formMethods.handleSubmit(onSubmit)}> <FormProvider {...formMethods}> <AutoSizer> {({ height, width }) => ( <List height={height} itemCount={items.length} itemSize={() => 100} width={width} itemData={items}> {WindowedRow} </List> )} </AutoSizer> </FormProvider> <button type="submit">Submit</button> </form> ) }
测试表单
测试是非常重要的,因为它避免代码错误或 人为错误,并在重构时确保代码的安全性。
我们建议使用testing-library,因为它很简单,而且更聚焦用户的行为。
♦
步骤 1: 设置您的测试环境。
请安装@testing-library/jest-dom ,以及jest
的最新版本,因为 react-hook-form 使用 MutationObserver
来探测输入组件是否从DOM中移除。
注意: 如果您使用的是React Native,你不需要安装 @testing-library/jest-dom.
npm install -D @testing-library/jest-dom
创建setup.js
来导入@testing-library/jest-dom。
注意: 如果您使用的是React Native,你需要创建 setup.js , 并定义 window
对象。
import "mutationobserver-shim";
最后,你必须更新jest.config.js
中的setup.js
来包含该文件。
module.exports = { setupFilesAfterEnv: ["<rootDir>/setup.js"] // or .ts for TypeScript App // ...other settings };
步骤 2: 创建登录表单。
我们已经针对性地设置了 role 的属性。 在您编写测试和提高可访问性时,这些属性很有帮助。您可以参考。testing-library文档获取更多信息。
import React from "react"; import { useForm } from "react-hook-form"; export default function App({ login }) { const { register, handleSubmit, formState: { errors }, reset } = useForm(); const onSubmit = async data => { await login(data.email, data.password); reset(); }; return ( <form onSubmit={handleSubmit(onSubmit)}> <label htmlFor="email">email</label> <input id="email" {...register("email", { required: "required", pattern: { value: /\S+@\S+\.\S+/, message: "Entered value does not match email format" } })} type="email" /> {errors.email && <span role="alert">{errors.email.message}</span>} <label htmlFor="password">password</label> <input id="password" {...register("password", { required: "required", minLength: { value: 5, message: "min length is 5" } })} type="password" /> {errors.password && <span role="alert">{errors.password.message}</span>} <button type="submit">SUBMIT</button> </form> ); }
Step 3: 编写测试。
以下标准是我们在测试中试图涵盖的内容。
测试提交失败。
我们使用
waitFor
和find*
方法来 检测提交反馈,因为handleSubmit
方法是异步执行的。测试每个输入组件的校验。
我们使用
*ByRole
方法查询不同的 元素,因为这就是用户使用你的UI组件的方式。测试提交成功。
import React from "react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import App from "./App"; const mockLogin = jest.fn((email, password) => { return Promise.resolve({ email, password }); }); describe("App", () => { beforeEach(() => { render(<App login={mockLogin} />); }); it("should display required error when value is invalid", async () => { fireEvent.submit(screen.getByRole("button")); expect(await screen.findAllByRole("alert")).toHaveLength(2); expect(mockLogin).not.toBeCalled(); }); it("should display matching error when email is invalid", async () => { fireEvent.input(screen.getByRole("textbox", { name: /email/i }), { target: { value: "test" } }); fireEvent.input(screen.getByLabelText("password"), { target: { value: "password" } }); fireEvent.submit(screen.getByRole("button")); expect(await screen.findAllByRole("alert")).toHaveLength(1); expect(mockLogin).not.toBeCalled(); expect(screen.getByRole("textbox", { name: /email/i }).value).toBe("test"); expect(screen.getByLabelText("password").value).toBe("password"); }); it("should display min length error when password is invalid", async () => { fireEvent.input(screen.getByRole("textbox", { name: /email/i }), { target: { value: "test@mail.com" } }); fireEvent.input(screen.getByLabelText("password"), { target: { value: "pass" } }); fireEvent.submit(screen.getByRole("button")); expect(await screen.findAllByRole("alert")).toHaveLength(1); expect(mockLogin).not.toBeCalled(); expect(screen.getByRole("textbox", { name: /email/i }).value).toBe( "test@mail.com" ); expect(screen.getByLabelText("password").value).toBe("pass"); }); it("should not display error when value is valid", async () => { fireEvent.input(screen.getByRole("textbox", { name: /email/i }), { target: { value: "test@mail.com" } }); fireEvent.input(screen.getByLabelText("password"), { target: { value: "password" } }); fireEvent.submit(screen.getByRole("button")); await waitFor(() => expect(screen.queryAllByRole("alert")).toHaveLength(0)); expect(mockLogin).toBeCalledWith("test@mail.com", "password"); expect(screen.getByRole("textbox", { name: /email/i }).value).toBe(""); expect(screen.getByLabelText("password").value).toBe(""); }); });
转换和解析
原生输入的返回值通常为string
。 格式,除非使用valueAsNumber
或 调用。valueAsDate
,您可以在 下阅读更多信息。本节. 但是,这并不完美,我们还是要处理 。isNaN
或null
值。所以最好留下 的变换。在下面的例子中,我们是 使用Controller
来包含Controller
的功能。 转换值的输入和输出。您也可以实现类似的 结果与自定义注册
。
const ControllerPlus = ({ control, transform, name, defaultValue }) => ( <Controller defaultValue={defaultValue} control={control} name={name} render={({ field }) => ( <input onChange={(e) => field.onChange(transform.output(e))} value={transform.input(field.value)} /> )} /> ); // usage below: <ControllerPlus<string, number> transform={{ input: (value) => isNaN(value) || value === 0 ? "" : value.toString(), output: (e) => { const output = parseInt(e.target.value, 10); return isNaN(output) ? 0 : output; } }} control={control} name="number" defaultValue="" />
我们需要您的支持
如果您发现React Hook Form在您的研究项目中有用,请支持我们和贡献者❤