表单是很多WEB应用程序的基本部分,本文我们学习使用第三方库创建React表单。我们使用React Hook Forms来管理表单状态,使用Zod来验证数据。
创建表单
我们来创建表单,先将之前在main.tsx注释的 bootstrap.css重新启用,这样我们就可以使用现代的样式。
import "bootstrap/dist/css/bootstrap.css";
在commponents文件夹下新建Form.tsx文件,键入 rafce 快捷键生成代码块,再添加相应代码。有个小提示,可以使用 “div.mb-3>label.form-label+imput[type=number].form-control” 这样的快捷代码生成相应HTML代码。
import React from "react"; const Form = () => { return ( <form> <div className="mb-3"> <label htmlFor="name" className="form-label"> Name </label> <input id="name" type="text" className="form-control" /> </div> <div className="mb-3"> <label htmlFor="age" className="form-label"> Age </label> <input id="age" type="number" className="form-control" /> </div> <button className="btn btn-primary" type="submit"> Submit </button> </form> ); }; export default Form;
再在src目录下,新建index.css,该css文件用于全局样式设置。
body { padding: 20px; }
main.tsx中导入index.css。
... import "bootstrap/dist/css/bootstrap.css"; import "./index.css"; ...
App.tsx引入刚建的Form组件。
import Form from "./components/Form"; function App() { return ( <div> <Form /> </div> ); } export default App;
保存,查看效果。
处理提交事件
我们来处理下Form的onSubmit事件。注意我们首先要调用event的preventDefault方法,默认情况下,Form的submit将提交给服务器,如果不加这一句,将导致页面全部重载。当然实际生产环境确实需要提交给服务处理,这里我们阻止这个默认行为。参数event的类型是FormEvent,鼠标移到onSubmit事件上可以得到提示。
... const Form = () => { const handleSubmit = (event: FormEvent) => { event.preventDefault(); console.log("Submitted."); }; return ( <form onSubmit={handleSubmit}> ...
访问输入字段
我们已学习了React中useState的Hook,还有另外的Hook叫useRef,它可以引用Dom元素,这一节我们使用它来获取Form提交时的字段值。
在Form组件中,我们定义了两个useRef,分别为nameRef和ageRef,由于Typescript的严格要求,我们要明确定义类型为HTMLInputElement。在html的input元素中,我们指定属性 ref={nameRef} 类似这样,与实际DOM做好关联。在submit事件处理函数中,先判断引用的Dom是否存在,然后获取value并赋给person对象,再打印出person。
... const Form = () => { const nameRef = useRef<HTMLInputElement>(null); const ageRef = useRef<HTMLInputElement>(null); const person = { name: "", age: 0, }; const handleSubmit = (event: FormEvent) => { event.preventDefault(); if (nameRef.current !== null) person.name = nameRef.current.value; if (ageRef.current !== null) person.age = parseInt(ageRef.current.value); console.log(person); }; return ( <form onSubmit={handleSubmit}> ... <input ref={nameRef} id="name" type="text" className="form-control" /> ... <input ref={ageRef} id="age" type="number" className="form-control" /> ...
效果如下:
你可能会注意到,每次我们定义一个useRef时,会初始化成null。如果去掉null,React会报错。useRef要么是null,要么就是一个真实的节点,如果在屏幕上去掉该Dom节点,React会自动将该useRef设回null。这可能是React的一个设计问题,按理这个null应该由React自己负责,用户初始化每个useRef都要明确初始化成null有点丑。但不管如何,目前我们就得如此。
受控组件
可以不使用useRef而是使用之前介绍过的useState来获取Form的字段。
下面实现中,我们创建person的StateHook,然后在input的 onChange事件中处理更新person的信息。在每个HTML元素中都有一个属性value用于维持元素本身的状态,为了保持React与元素本身的同步,我们也设置了value属性受控于person,这样组件就完成由React控制了。
... const Form = () => { const [person, setPerson] = useState({ name: "", age: "", }); const handleSubmit = (event: FormEvent) => { event.preventDefault(); console.log(person); }; return ( ... <input value={person.name} onChange={(event) => setPerson({ ...person, name: event.target.value }) } ... /> ... <input value={person.age} onChange={(event) => setPerson({ ...person, age: parseInt(event.target.value) }) } ...
这种方法在input每次输入或删除字符时都会重新渲染组件,有人会说这样太慢了,就应该使用useRef。其实这在绝大多数场景不会有问题,除非网页很复杂,严重影响性能时才需要调整。
记住编程界老话:
过早优化是万恶之源!
使用 React Hook Form 管理表单
复杂的网页下,使用useState会很花时间且容易出错,因为需要为大量的Dom元素都设置对应的State Hook,可以借助React Hook Form减轻工作量并减少错误。
安装react-hook-form:
npm i react-hook-form@7.43
修改Form.tsx,我们看到,代码大大减少,但效果是一样的。
顶部导入useForm,调用useForm(),它的结构如图所示。
解构 register及handleSubmit,然后在input中注册name和age,只需使用{…register(‘name’)},不必再处理onChange事件及value值。再在form中使用handleSubmit即可。注意handleSubmit中的参数data是Form中的各字段数据。
import { FieldValues, useForm } from "react-hook-form"; const Form = () => { const { register, handleSubmit } = useForm(); const onSubmit = (data: FieldValues) => console.log(data); return ( <form onSubmit={handleSubmit(onSubmit)}> ... <input {...register("name")} ... /> ... <input {...register("age")} ...
应用数据验证
Form的数据需要验证,我们来实现下,假设我们要求name的字符数要大于等于3个。
在使用register方法时,我们可以传递普通的HTML属性,比如input的required,minLength等。{ required: true, minLength: 3 }
接着我们要在数据验证不通过时提示用户,解构useForm的formState,formState里有errors属性,这里我们嵌套解构它。
查看errors的结构,我们能查到它的结构里有errors.name.type类似的,通过比较type,我们给出不同的提示信息,如下。
{errors.name?.type === “required” && …}
为防止name不存在而出现运行错误,我们在name?后加了问号,表示如果没有name就忽略。
为了使提示信息醒目,使用了bootstrap的 text-danger样式。
最后,为了更安全地控制Form结构,也为了在编辑器中有更好的提示,我们添加一个interface名为FormData,明确指示字段的类型。
... interface FormData { name: string; age: number; } const Form = () => { const { register, handleSubmit, formState: { errors }, } = useForm<FormData>(); return ( ... <input {...register("name", { required: true, minLength: 3 })} id="name" ... /> {errors.name?.type === "required" && ( <p className="text-danger">The name field is required.</p> )} {errors.name?.type === "minLength" && ( <p className="text-danger">The name must be at least 3 characters.</p> )} ...
Zod 基于模式的验证
当表单复杂时,会有大量的验证,实现是非常浪费时间的,于是出现了一些基于模式的验证库,比如Joi, Yup,以及Zod。本节就介绍Zod,当然Zod内容很丰富,不能全部覆盖,详细了解还需查看Zod的文档。
安装:
npm i zod@3.20.6
修改Form.tsx组件。导入 z,再调用z.object方法,设置schema。schema定义Form的数据结构以及规则限制,如min(3),更多的要查文档。去掉我们之前定义的interface FormData,它与我们定义的schema类似有点重复了,可以调用z.infer生成。typescript中type类型与 interface类似。
import { z } from "zod"; const schema = z.object({ name: z.string().min(3), age: z.number().min(18), }); type FormData = z.infer<typeof schema>; const Form = () => { ...
现在我们要将zod与Hook Form融合,需要先安装库:
npm i @hookform/resolvers@2.9.11
导入 zodResolver,在useForm调用时添加参数对象 { resolver: zodResolver(schema) },每个需要验证的input的数据验证规则也不必分散在各处设置,只需简单使用{ …register(‘name’) },所有规则都放在schema处。age字段需要转换化数字,所以要添加valueAsNumber, {…register(“age”, { valueAsNumber: true })}
各个input的提示信息也不必分散,调用类似 {errors.name && <p className=”text-danger”>{errors.name.message}</p>} 即可。
schema添加了规则后,会有一些默认的错误提示信息,如果想自定义提示信息,只需在schema的字段调用方法时添加 键为 message的对象,比如 { message: “Age must be at least 18.” }等。
import { FieldValues, useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; const schema = z.object({ name: z.string().min(3, { message: "Name must be at least 3 characters." }), age: z .number({ invalid_type_error: "Age field is required." }) .min(18, { message: "Age must be at least 18." }), }); type FormData = z.infer<typeof schema>; const Form = () => { const { register, handleSubmit, formState: { errors }, } = useForm<FormData>({ resolver: zodResolver(schema) }); ... return ( <form onSubmit={handleSubmit(onSubmit)}> ... <input {...register("name")} ... /> {errors.name && <p className="text-danger">{errors.name.message}</p>} </div> ... <input {...register("age", { valueAsNumber: true })} ... {errors.age && <p className="text-danger">{errors.age.message}</p>} ...
zod的用法远不止如此,可以访问 https://zod.dev 查看详细文档。
禁用提交按钮
我们希望在数据无效时禁用提交按钮。
formState有一个属性 isValid,我们解构出来使用即可实现数据无效禁用提交按钮。
... const Form = () => { const { ... formState: { errors, isValid }, } ... <button disabled={!isValid} ...
项目:费用追踪器
来实现一个简单的费用追踪器。
效果如图:
有添加费用的界面,添加时校验数据的有效性。完成添加后,可以在下面的列表中看到清单,清单可以分类筛选、汇总金额,也可删除某条费用。
该项目涵盖了到目前为止我们学习到的React所有知识,下面就来逐步实现。
解决步骤一:创建费用列表
尽管这是一个小项目,我们也需要将它单独放置,在实际项目中也该如此。
在 src 目录下新建 expense-tracker文件夹,再在其下创建components文件夹,接着新建组件ExpenseList.tsx。
ExpenseList组件中,我们构建表格 table,className为”table table-bordered”。表头部分<thead>,我们在<tr><th>上设置相应的标题。
表体部分<tbody>,需要动态生成。它接收父组件的数据进行渲染。在ExpenseList组件中定义Props,其中expenses为费用清单对象Expense的列表,同时定义interface Expense,指定Expense的数据结构。<tbody>部分的<tr>,用了expenses的map方法,将expenses清单详细渲染出来。表体最后一列,使用了一个button,用于删除对应的费用行。onClick用了回调函数onDelete,由父组件来实现。
表脚部分<tfoot>,作用是汇总费用,使用了Javascript的reduce方法,并用toFixed保留小数字2位。
另外,在费用列表为空时,不应该显示表格。我们判断expenses.length是否为0,如果是将返回空,不再渲染表格。
import React from "react"; interface Expense { id: number; description: string; amount: number; category: string; } interface Props { expenses: Expense[]; onDelete: (id: number) => void; } const ExpenseList = ({ expenses, onDelete }: Props) => { if (expenses.length === 0) return null; return ( <table className="table table-bordered"> <thead> <tr> <th>Description</th> <th>Amount</th> <th>Category</th> <th></th> </tr> </thead> <tbody> {expenses.map((expense) => ( <tr key={expense.id}> <td>{expense.description}</td> <td>{expense.amount}</td> <td>{expense.category}</td> <td> <button className="btn btn-outline-danger" onClick={() => onDelete(expense.id)} > Delete </button> </td> </tr> ))} </tbody> <tfoot> <tr> <td>Total</td> <td> $ {expenses .reduce((acc, expense) => expense.amount + acc, 0) .toFixed(2)} </td> <td></td> <td></td> </tr> </tfoot> </table> ); }; export default ExpenseList;
接着,我们修改App.tsx,调用ExpenseList组件。
先建立一些伪数据,使用useState设置expenses和setExpenses,随意键入些数据形成expenses列表。
再调用ExpenseList,设置expenses、onDelete属性。
import { useState } from "react"; import ExpenseList from "./expense-tracker/components/ExpenseList"; function App() { const [expenses, setExpenses] = useState([ { id: 1, description: "aaa", amount: 10, category: "Utilities" }, { id: 2, description: "bbb", amount: 10, category: "Utilities" }, { id: 3, description: "ccc", amount: 10, category: "Utilities" }, { id: 4, description: "ddd", amount: 10, category: "Utilities" }, ]); return ( <div> <ExpenseList expenses={expenses} onDelete={(id) => setExpenses(expenses.filter((e) => e.id !== id))} /> </div> ); } export default App;
目前的效果如下:
解决步骤二:创建费用过滤器
接着我们创建过滤器组件,新组件文件名为ExpenseFilter.tsx。
首先创建下拉选择框 <select>,硬编码一些选项<option>,注意All categories的value设置为空。
我们希望选择某个选项时传递给父组件处理并重新渲染组件,于是创建interface Props,设置onSelectCategory。在<select>的 onChange事件里回调onSelectCategory并传递选择的值。
interface Props { onSelectCategory: (category: string) => void; } const ExpenseFilter = ({ onSelectCategory }: Props) => { return ( <select className="form-select" onChange={(event) => onSelectCategory(event.target.value)} > <option value="">All categories</option> <option value="Groceries">Groceries</option> <option value="Utilities">Utilities</option> <option value="Entertainment">Entertainment</option> </select> ); }; export default ExpenseFilter;
然后我们在父组件App.tsx中使用ExpenseFilter组件。
创建StateHook,命为selectedCategory,用于存储选择的category,初始为空。App组件调用ExpenseFilter组件,并实现onSelectCategory,主要就是设置selecedtCategory。
为达到筛选目的,创建一个visibleExpenses列表,选择category为selectedCategory的expense。再修改之前的ExpenseList组件,属性expenses设置为visibleExpenses。
为了使两个组件之间有美观的间隔,将ExpenseFilter用“mb-3”的<div>包裹。
function App() { const [selectedCategory, setSelectedCategory] = useState(""); const [expenses, setExpenses] = useState([ ... ]); const visibleExpenses = selectedCategory ? expenses.filter((e) => e.category === selectedCategory) : expenses; return ( <div> <div className="mb-3"> <ExpenseFilter onSelectCategory={(category) => setSelectedCategory(category)} /> </div> <ExpenseList expenses={visibleExpenses} onDelete={(id) => setExpenses(expenses.filter((e) => e.id !== id))} /> </div> ); } export default App;
至目前,效果如图:
解决步骤三:创建费用表单
表单中也会用到 categories列表,我们已在ExpenseFilter中硬编码了,自然不希望再写一遍,我们把提取到App.tsx中,这样各组件都可使用它。
在App.tsx中,我们在import语句后,设置常量categories列表,并export。
... export const categories = ["Groceries", "Utilities", "Entertainment"]; ...
再到之前的ExpenseFilter.tsx中,导入categories,在<select>的<option>中,我们通过categories的map方法动态生成各category选项。
... import { categories } from "../../App"; ... <option value="">All categories</option> {categories.map((category) => ( <option key={category} value={category}> {category} </option> ))} ...
然后开始创建ExpenseForm.tsx组件。
我们用类似 “.mb-3>label.form-label+input[type=number].form-control”+TAB键的快捷方式生成各Form的字段,再设置<input>的id与<label>的 htmlForm对应,以及显示信息。
对于category,我们使用了跟ExpenseFilter一样的categories.map方法动态生成。
最后添加一个按钮<button>。
形成的代码如下:
import React from "react"; import { categories } from "../../App"; const ExpenseForm = () => { return ( <form> <div className="mb-3"> <label htmlFor="description" className="form-label"> Description </label> <input id="description" type="text" className="form-control" /> </div> <div className="mb-3"> <label htmlFor="amount" className="form-label"> Amount </label> <input id="amount" type="number" className="form-control" /> </div> <div className="mb-3"> <label htmlFor="category" className="form-label"> Category </label> <select id="category" className="form-select"> <option value="">All categories</option> {categories.map((category) => ( <option key={category} value={category}> {category} </option> ))} </select> </div> <button className="btn btn-primary">Submit</button> </form> ); }; export default ExpenseForm;
回到App.tsx调用。
... return ( <div> <div className="mb-5"> <ExpenseForm /> </div> ...
目前的效果如下。
解决步骤四:集成React Hook和Zod
之前ExpenseFilter和ExpenseForm都导入了App中定义的categories,为了避免依赖顺序错误,在expense-tracker目录下新建单独的模块categories.ts,注意这不是组件,扩展名建议为ts而不是tsx。将categories移入该文件,我们在categories后加了as const表示该列表不能再添加。categories已移至新模块,注意修改ExpenseFilter和ExpenseForm的导入语句的路径。
const categories = ["Groceries", "Utilities", "Entertainment"] as const; export default categories;
导入zod的z,调用z.object创建数据结构schema,我们有desctription, amout以及category,设置数据类型,规则以及错误提示友好信息。number和string方法之前已讲过,没什么新鲜了,而category使用的是z.enum方法,参数的列表需要只读和常量,这里传递的是categories,这个已移至categories.ts中了,而且已加上了 as const符合调用的要求。
enum的错误友好提示设置也很特别,各种库的调用方法也不尽相同,我们只需知道如何使用即可。
接着定义ExpenseFormData,由定义的schema转换。
目前的ExpenseForm.tsx添加的部分代码如下:
import { z } from "zod"; ... const schema = z.object({ desctription: z .string() .min(3, { message: "Description should be at least 3 charaters." }) .max(50), amount: z .number({ invalid_type_error: "Amount is required." }) .min(0.01) .max(100_000), category: z.enum(categories, { errorMap: () => ({ message: "Category is required." }), }), }); type ExpenseFormData = z.infer<typeof schema>; ...
接着,我们要将useForm与zod整合。
导入useForm和zodResolver,用useForm解构出register, handleSubmit及formState的errors,再在具体的表单字段里用register方法注册,比如 {…register(‘amount’, {valueAsNumber:true})} 。
再在每个表单字段下添加错误信息提示展示,这些之前都已介绍过。
... import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; ... const ExpenseForm = () => { const { register, handleSubmit, formState: { errors }, } = useForm<ExpenseFormData>({ resolver: zodResolver(schema) }); return ( <form onSubmit={handleSubmit((data) => console.log(data))}> <div className="mb-3"> ... <input {...register("desctription")} id="description" ... /> {errors.desctription && ( <p className="text-danger">{errors.desctription.message}</p> )} </div> ... {...register("amount", { valueAsNumber: true })} id="amount" ... {errors.amount && ( <p className="text-danger">{errors.amount.message}</p> )} </div> ... <select {...register("category")} id="category" className="form-select"> ... {errors.category && ( <p className="text-danger">{errors.category.message}</p> )} ...
解决步骤五:添加费用条目
ExpenseForm.tsx组件添加Props,定义onSubmit类型。解构useForm的reset方法,组件在<form>的onSubmit事件中做两件事情,一件是调用父级执行的onSubmit,再调用reset()清空表单。
... interface Props { onSubmit: (data: ExpenseFormData) => void; } ... const ExpenseForm = ({ onSubmit }: Props) => { const { ... reset, ... } = useForm<ExpenseFormData>({ resolver: zodResolver(schema) }); return ( <form onSubmit={handleSubmit((data) => { onSubmit(data); reset(); })} ...
再到App.tsx。
调用ExpenseForm组件时提供onSubmit实现将expense添加到expenses中,expenses中有一个id,这里就简单使用expenses.length+1,实际场景中可能需要服务器自动生成,这里不必考虑。
... return ( <div> <div className="mb-5"> <ExpenseForm onSubmit={(expense) => setExpenses([...expenses, { ...expense, id: expenses.length + 1 }]) } /> </div> ...
小结
本文我们学习了如何创建React表单,介绍使用第三方库React Hook Forms来管理表单状态,使用Zod来验证数据,并详细讲解了一个项目实现。
下一篇介绍后端的连接。