React入门(五)– 创建表单

表单是很多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来验证数据,并详细讲解了一个项目实现。

下一篇介绍后端的连接。

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注