可访问性 (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><inputid="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 { 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 */}<inputid="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>)}
改进之后,屏幕阅读器将说:“姓名,编辑,无效输入,这是必需的”。
向导表单 / 漏斗
通过不同的页面和部分收集用户信息非常常见。我们建议使用状态管理库在不同的页面或部分存储用户输入。在本示例中,我们将使用 little state machine 作为我们的状态管理库(如果您更熟悉 redux,则可以使用它)。
步骤 1: 设置您的路由和存储。
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: 创建您的页面,收集数据并将其提交到存储,然后推送到下一个表单/页面。
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: 使用存储中的所有数据进行最终提交,或显示结果数据。
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>}
按照上述模式,您应该能够构建一个向导表单/漏斗,从多个页面收集用户输入数据。
智能表单组件
这里的想法是,您可以轻松地使用输入来组合您的表单。我们将创建一个 Form
组件来自动收集表单数据。
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 } = methodsreturn (<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
的 props
注入子组件,您可以在应用程序中轻松创建和组合复杂表单。
错误消息
错误消息是在用户输入存在问题时向用户提供视觉反馈。React Hook Form 提供了一个 errors
对象,让您可以轻松检索错误。有几种不同的方法可以改进屏幕上的错误呈现。
-
注册
您可以像这样简单地将错误消息传递给
register
,通过验证规则对象的message
属性。<input {...register('test', { maxLength: { value: 2, message: "error message" } })} />
-
可选链
?.
可选链 运算符允许您读取errors
对象,而无需担心由于null
或undefined
导致的另一个错误。errors?.firstName?.message
-
Lodash
get
如果您的项目使用 lodash,那么您可以利用 lodash get 函数。例如
get(errors, 'firstName.message')
连接表单
当我们构建表单时,有时我们的输入位于深层嵌套的组件树中,此时 FormContext 就派上用场了。但是,我们可以通过创建一个 ConnectForm
组件并利用 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 的 Context API 构建的。它解决了数据在组件树中传递而无需手动在每一层传递 props 的问题。这也导致组件树在 React Hook Form 触发状态更新时触发重新渲染,但我们仍然可以通过以下示例优化我们的应用程序(如果需要)。
注意: 在某些情况下,使用 React Hook Form 的 开发者工具 和 FormProvider 会导致性能问题。在深入研究性能优化之前,请先考虑这个瓶颈。
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 changedconst 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 Proxyreturn (<FormProvider {...methods}><form onSubmit={methods.handleSubmit(onSubmit)}><NestedInputContainer /><input type="submit" /></form></FormProvider>)}
受控组件与非受控组件混合使用
React Hook Form 接受非受控组件,但也与受控组件兼容。大多数 UI 库都构建为仅支持受控组件,例如 MUI 和 Antd。但是,使用 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, register } = useForm({defaultValues,})const onSubmit = (data) => console.log(data)return (<form onSubmit={handleSubmit(onSubmit)}><Controllerrender={({ 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>)}
自定义钩子与解析器
您可以构建一个自定义钩子作为解析器。自定义钩子可以轻松地与 yup/Joi/Superstruct 集成作为验证方法,并在验证解析器中使用。
- 定义一个记忆化的验证模式(如果您没有任何依赖项,则可以在组件外部定义它)。
- 通过传递验证模式来使用自定义钩子。
- 将验证解析器传递给 useForm 钩子。
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 }) => (<Listheight={height}itemCount={items.length}itemSize={() => 100}width={width}itemData={items}>{WindowedRow}</List>)}</AutoSizer></FormProvider><button type="submit">Submit</button></form>)}
测试表单
测试非常重要,因为它可以防止代码出现错误或失误。它还保证在重构代码库时代码的安全。
我们建议使用 testing-library,因为它简单,测试更侧重于用户行为。
步骤 1: 设置您的测试环境。
请使用最新版本的 jest
安装 @testing-library/jest-dom,因为 react-hook-form 使用 MutationObserver
来检测输入,并从 DOM 中卸载。
注意: 如果您使用的是 React Native,则无需安装 @testing-library/jest-dom。
npm install -D @testing-library/jest-dom
创建 setup.js
来导入 @testing-library/jest-dom。
import "@testing-library/jest-dom"
注意: 如果您使用的是 React Native,则需要创建 setup.js,定义 window
对象,并在设置文件中包含以下行。
global.window = {}global.window = global
最后,您必须更新 jest.config.js
中的 setup.js
,以包含该文件。
module.exports = {setupFilesAfterEnv: ["<rootDir>/setup.js"], // or .ts for TypeScript App// ...other settings}
此外,您可以设置 eslint-plugin-testing-library 和 eslint-plugin-jest-dom 以遵循最佳实践并预测编写测试时常见的错误。
步骤 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><inputid="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><inputid="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>)}
步骤 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 })})it("should display required error when value is invalid", async () => {render(<App login={mockLogin} />)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 () => {render(<App login={mockLogin} />)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 })).toHaveValue("test")expect(screen.getByLabelText("password")).toHaveValue("password")})it("should display min length error when password is invalid", async () => {render(<App login={mockLogin} />)fireEvent.input(screen.getByRole("textbox", { name: /email/i }), {target: {},})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 })).toHaveValue()expect(screen.getByLabelText("password")).toHaveValue("pass")})it("should not display error when value is valid", async () => {render(<App login={mockLogin} />)fireEvent.input(screen.getByRole("textbox", { name: /email/i }), {target: {},})fireEvent.input(screen.getByLabelText("password"), {target: {value: "password",},})fireEvent.submit(screen.getByRole("button"))await waitFor(() => expect(screen.queryAllByRole("alert")).toHaveLength(0))expect(screen.getByRole("textbox", { name: /email/i })).toHaveValue("")expect(screen.getByLabelText("password")).toHaveValue("")})
在测试期间解决 act 警告
如果您测试一个使用 react-hook-form 的组件,您可能会遇到这样的警告,即使您没有为此组件编写任何异步代码。
警告:MyComponent 中的测试更新没有包装在 act(...) 中。
import React from "react"import { useForm } from "react-hook-form"export default function App() {const { register, handleSubmit, formState } = useForm({mode: "onChange",})const onSubmit = (data) => {}return (<form onSubmit={handleSubmit(onSubmit)}><input{...register("answer", {required: true,})}/><button type="submit">SUBMIT</button></form>)}
import React from "react"import { render, screen } from "@testing-library/react"import App from "./App"it("should have a submit button", () => {render(<App />)expect(screen.getByText("SUBMIT")).toBeInTheDocument()})
在本示例中,有一个简单的表单,没有明显的异步代码,测试只是渲染了组件并测试了按钮是否存在。但是,它仍然记录了有关未包装在 act()
中的更新的警告。
这是因为 react-hook-form 在内部使用异步验证处理程序。为了计算 formState,它必须首先验证表单,这是异步完成的,导致另一个渲染。该更新在测试函数返回后发生,这将触发警告。
为了解决这个问题,请等待 UI 中的某些元素出现,使用 find*
查询。请注意,您 **不能** 将您的 render()
调用包装在 act()
中。 您可以在此处阅读有关不必要地将内容包装在 act
中的更多信息。
import React from "react"import { render, screen } from "@testing-library/react"import App from "./App"it("should have a submit button", async () => {render(<App />)expect(await screen.findByText("SUBMIT")).toBeInTheDocument()// Now that the UI was awaited until the async behavior was completed,// you can keep asserting with `get*` queries.expect(screen.getByRole("textbox")).toBeInTheDocument()})
转换和解析
除非使用 valueAsNumber
或 valueAsDate
调用,否则原生输入将以 string
格式返回值,您可以在 本节 中了解更多信息。但是,它并不完美。我们仍然需要处理 isNaN
或 null
值。因此,最好将转换留在自定义钩子级别。在以下示例中,我们使用 Controller
来包含转换值输入和输出的功能。您也可以使用自定义 register
达到类似的效果。
const ControllerPlus = ({control,transform,name,defaultValue}) => (<ControllerdefaultValue={defaultValue}control={control}name={name}render={({ field }) => (<inputonChange={(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 在您的项目中很有用,请考虑为它加星并支持它。