首页 / 文章

手工构建的 React 表单:受控输入、验证与提交状态

了解 React 表单的工作原理,包括受控输入与各类型处理函数、验证功能、提交状态、动态字段、文件上传,以及何时使用表单库会更高效。

发布者:REACTAPP.TOP阅读约 13 分钟2992 词

来源: React Forms: A Complete Guide from Basics to Production Patterns

登录、结账、搜索、个人资料设置、调查问卷:几乎所有应用都是以表单为核心构建的。在 React 中,表单意味着要处理值的管理、验证、错误信息、提交操作、加载状态与服务器错误、运行时添加的字段、重置功能以及性能优化。本指南将逐步展示从受控输入到无需任何库即可实现的完整注册表单的全过程,并说明何时应该使用 React Hook Form 和 Zod。

表单在 React 应用中的处理流程

表单用于收集用户信息,就像这个注册界面一样:

┌──────────────────────────────┐
│       Create Account         │
│                              │
│ Name     [______________]    │
│ Email    [______________]    │
│ Password [______________]    │
│                              │
│         [ Register ]         │
└──────────────────────────────┘

React 会将这些输入与应用逻辑相连,而几乎所有表单都遵循相同的生命周期:

User Input
    ↓
Form State
    ↓
Validation
    ↓
Submit
    ↓
API Request
    ↓
Success / Error

受控组件:以状态作为真实数据源

在受控组件中,输入框的value来自React状态,任何变化都会通过onChange事件写回状态。

import { useState } from "react";

function LoginForm() {
  const [email, setEmail] = useState("");

  return (
    <input
      value={email}
      onChange={(event) => {
        setEmail(event.target.value);
      }}
    />
  );
}

设置value={email}意味着由React决定该字段显示的内容。每次按键都会经过这个循环:

User types
    ↓
onChange fires
    ↓
setEmail()
    ↓
React state updates
    ↓
Component renders
    ↓
Input receives new value

由于显示的值始终来源于状态,因此状态就是唯一的真实数据源。

“受控”这一名称反映了输入框并不拥有自己的值:

<input
  value={email}
  onChange={handleChange}
/>

数据从状态流向元素,经过用户的输入和处理函数,然后再回到状态中:

             React State
                  │
                  ▼
              input value
                  │
                  ▼
              User types
                  │
                  ▼
              onChange
                  │
                  ▼
             React State

多个字段共用一个状态对象

为每个字段都调用一次useState会导致性能下降:

const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");

以字段名为键的单一对象更易于维护。这种形式将三个字段放在一个对象中,共享同一个handleChange处理函数,并在表单提交时记录数据。

import { useState } from "react";

function RegisterForm() {
  const [form, setForm] = useState({
    name: "",
    email: "",
    password: "",
  });

  const handleChange = (event) => {
    const { name, value } = event.target;

    setForm((previous) => ({
      ...previous,
      [name]: value,
    }));
  };

  const handleSubmit = (event) => {
    event.preventDefault();

    console.log(form);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="name"
        value={form.name}
        onChange={handleChange}
      />

      <input
        name="email"
        value={form.email}
        onChange={handleChange}
      />

      <input
        name="password"
        type="password"
        value={form.password}
        onChange={handleChange}
      />

      <button type="submit">
        Register
      </button>
    </form>
  );
}

关键所在在于计算属性[name]: value。每个输入元素的name属性都与状态键对应,因此同一个处理函数可以更新触发该事件的任意字段。

对于文本输入框,处理函数会从事件中读取该元素的内容:

<input
  value={name}
  onChange={(event) => {
    setName(event.target.value);
  }}
/>

event.target指的是输入元素,因此event.target.value即为该元素的当前文本内容:

User types: Salim

event.target.value
       ↓
"Salim"

正确处理各类输入类型

表单中包含文本、密码、数字、复选框、单选框、下拉菜单、文本区域、文件上传、日期选择及范围选择控件。这些控件大多遵循相同的处理模式,但也存在一些重要差异。

文本、密码和数字输入

文本字段将value与其键绑定:

<input
  type="text"
  value={form.name}
  onChange={handleChange}
/>

密码字段仅在type上有所不同:

<input
  type="password"
  value={form.password}
  onChange={handleChange}
/>

数字字段也是如此:

<input
  type="number"
  value={form.age}
  onChange={handleChange}
/>

问题在于:即使设置了type="number",event.target.value仍然是字符串。下面的代码片段将代码与解释混在了一起,关键在于你会得到“25”而非数字25,需要使用Number()进行转换。

const age = event.target.value;

may produce:

"25" rather than: 25

If you need a number:

const age = Number(event.target.value);

注意空值问题:Number("")会返回0,因此许多表单会保留原始字符串,只在验证或提交时再进行转换。

复选框使用checked属性

对于复选框,将checked绑定到布尔值,并读取event.target.checked:

<input
  type="checkbox"
  checked={form.agree}
  onChange={(event) => {
    setForm((previous) => ({
      ...previous,
      agree: event.target.checked,
    }));
  }}
/>

复选框的event.target.value返回的是其value属性值(默认为“on”),而非该复选框是否被选中。

下拉菜单、文本区域和单选组

下拉菜单是通过select元素上的value属性来控制的;第一个为空的选项可用作占位符。

<select
  name="role"
  value={form.role}
  onChange={handleChange}
>
  <option value="">
    Select role
  </option>

  <option value="frontend">
    Frontend Developer
  </option>

  <option value="backend">
    Backend Developer
  </option>
</select>

React将textarea视为输入元素,它通过value属性而非内部文本来传递值。

<textarea
  name="message"
  value={form.message}
  onChange={handleChange}
/>

单选组中的各个单选按钮共享同一个name属性。当某个单选按钮的value与状态匹配时,该按钮就会被选中,而统一的处理函数会将所选值存储在form.gender中。

<label>
  <input
    type="radio"
    name="gender"
    value="male"
    checked={form.gender === "male"}
    onChange={handleChange}
  />
  Male
</label>

<label>
  <input
    type="radio"
    name="gender"
    value="female"
    checked={form.gender === "female"}
    onChange={handleChange}
  />
  Female
</label>

接管表单提交

默认情况下,浏览器会直接提交表单并可能进行导航或重新加载页面。React 应用则通过 JavaScript 处理表单提交,因此提交处理函数会像注册示例中那样以 event.preventDefault() 开头。之后的流程由你决定:

Submit
  ↓
preventDefault()
  ↓
Validate
  ↓
API
  ↓
Update UI

在请求前进行验证

验证用于检查输入是否有效。最初的版本会按字段收集错误信息:

const validate = () => {
  const errors = {};

  if (!form.name.trim()) {
    errors.name = "Name is required";
  }

  if (!form.email.trim()) {
    errors.email = "Email is required";
  }

  if (!form.password) {
    errors.password = "Password is required";
  }

  return errors;
};

提交处理函数会先进行验证,将错误信息存储起来,一旦发现错误就会立即终止流程:

const handleSubmit = (event) => {
  event.preventDefault();

  const validationErrors = validate();

  if (Object.keys(validationErrors).length > 0) {
    setErrors(validationErrors);
    return;
  }

  // Submit form
};

错误信息会存储在专门的状态中:

const [errors, setErrors] = useState({});

在某一时刻,该状态可能包含:

{
  email: "Invalid email",
  password: "Password must contain at least 8 characters"
}

每个字段会根据条件显示相应的提示信息:

{errors.email && (

  <p>{errors.email}</p>

)}

一个更好的 validate 函数会将表单作为参数,使其具备纯函数特性且易于测试,并在字段存在时检查其格式和长度:

function validate(form) {
  const errors = {};

  if (!form.name.trim()) {
    errors.name = "Name is required";
  }

  if (!form.email.trim()) {
    errors.email = "Email is required";
  } else if (!form.email.includes("@")) {
    errors.email = "Invalid email";
  }

  if (!form.password) {
    errors.password = "Password is required";
  } else if (form.password.length < 8) {
    errors.password =
      "Password must contain at least 8 characters";
  }

  return errors;
}

处理程序也以相同方式调用它:

const errors = validate(form);

if (Object.keys(errors).length > 0) {
  setErrors(errors);
  return;
}

includes("@")检测只能捕捉到明显的拼写错误;地址是否有效的真正证明是确认邮件。

客户端与服务器的验证功能各不相同

客户端验证在浏览器中执行,通常包括:

  1. 必填字段
  2. 电子邮件格式
  3. 密码最小长度要求
  4. 密码确认

它能快速反馈、提升用户体验并减少不必要的请求,但并非安全屏障。服务器必须再次进行验证,并回答浏览器无法解答的问题:

  1. 该电子邮件是否已注册?
  2. 用户是否有权限?
  3. 价格是否有效?
  4. 该用户能否执行此操作?

安全的应用程序会同时使用这两种验证方式。切勿编写如下逻辑:

if (clientValidationPassed) {
  // Data is safe
}

通过编辑页面或直接调用API可以绕过浏览器的检查,因此后端会独立验证每个请求:

Request
  ↓
Authentication
  ↓
Authorization
  ↓
Validation
  ↓
Business Logic
  ↓
Database

提交状态建模

生产环境中的表单会经历不同的状态,UI应反映这些状态:

Idle
 ↓
Submitting
 ↓
Success

or

Idle
 ↓
Submitting
 ↓
Error

有两个状态变量用于跟踪它们:

const [isSubmitting, setIsSubmitting] = useState(false);
const [serverError, setServerError] = useState("");

异步处理程序会设置标志、清除旧的服务器错误、等待请求完成,在失败时记录友好的提示信息,并在finally块中无论如何都会重置该标志。

const handleSubmit = async (event) => {
  event.preventDefault();

  setIsSubmitting(true);
  setServerError("");

  try {
    await createUser(form);
  } catch (error) {
    setServerError("Unable to create account");
  } finally {
    setIsSubmitting(false);
  }
};

在请求处理期间禁用并重新命名按钮可以防止重复提交:

<button
  type="submit"
  disabled={isSubmitting}
>
  {isSubmitting
    ? "Creating..."
    : "Create Account"}
</button>

按钮的生命周期与请求的处理流程一致:

User clicks Submit
      ↓
Request starts
      ↓
Button disabled
      ↓
Request finishes
      ↓
Button enabled

受控输入与不受控输入

在受控输入中,React状态会保存该值。下方的代码展示了简写形式、完整组件以及数据流,各部分之间以星号行分隔。

<input
  value={email}
  onChange={handleChange}
/>

********************************************

function Form() {
  const [email, setEmail] = useState("");

  return (
    <input
      value={email}
      onChange={(event) =>
        setEmail(event.target.value)
      }
    />
  );
}

********************************************



DOM event
   ↓
React state
   ↓
Render
   ↓
DOM value

在非受控输入中,DOM会保存该值,React则通过ref读取它,通常在表单提交时读取,而不会存储每次按键的操作:

const emailRef = useRef();

<input ref={emailRef} />

********************************************

function Form() {
  const emailRef = useRef(null);

  const handleSubmit = (event) => {
    event.preventDefault();

    console.log(emailRef.current.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input ref={emailRef} />

      <button type="submit">
        Submit
      </button>
    </form>
  );
}

********************************************

DOM
 ↓
Input value

对于需要即时验证、条件化UI、派生值、格式化处理、由React驱动的状态或自定义交互的场景,应选择受控输入。因为React知晓该值,所以UI能够立即做出响应:

{password.length < 8 && (
  <p>Password is too short</p>
)}

在简单表单中,当逐键状态无法带来任何价值、需要减少更新次数,或已有库能自动管理引用时,可选择不受控制的输入方式。React 19的表单功能也基于这一模型实现;可参阅使用useActionState和useOptimistic实现异步表单。

何时该使用表单库

React Hook Form

手动连接数十个字段十分繁琐。React Hook Form的register函数可用来关联输入元素并定义其规则,handleSubmit会在调用回调函数之前进行验证,而formState.errors则用于显示错误信息。

import { useForm } from "react-hook-form";

function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm();

  const onSubmit = (data) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register("email", {
          required: "Email is required",
        })}
      />

      {errors.email && (
        <p>{errors.email.message}</p>
      )}

      <input
        type="password"
        {...register("password", {
          required: "Password is required",
          minLength: {
            value: 8,
            message:
              "Password must be at least 8 characters",
          },
        })}
      />

      {errors.password && (
        <p>{errors.password.message}</p>
      )}

      <button type="submit">
        Login
      </button>
    </form>
  );
}

它简化了注册、验证、错误处理、提交操作、重置功能以及动态字段和表单状态的管理。由于它会默认注册那些不受控制的输入元素,因此无需像传统的受控表单那样每次按键都重新渲染整个表单。

使用 Zod 进行模式验证

随着规则数量的增加,零散的 if 语句会变得难以管理。Zod 可以通过一条声明来描述期望的数据结构:

import { z } from "zod";

const userSchema = z.object({
  name: z
    .string()
    .min(2, "Name must be at least 2 characters"),

  email: z
    .string()
    .email("Invalid email"),

  password: z
    .string()
    .min(8, "Password must be at least 8 characters"),
});

在实际开发中,通常会结合该库来管理状态,再使用模式定义验证规则:

React Hook Form
       +
      Zod
       ↓
Form state + validation

该模式可以设计得如此简洁:

const formSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

React Hook Form 通过解析器来引入模式,这非常适合那些规则需要保持一致的大型表单。

动态字段、条件字段和依赖字段

重复出现的组

用户常常会根据需求添加新条目,比如教育经历记录:

Education

Degree  [________]
College [________]

[ + Add Education ]

将它们存储为对象数组:

const [education, setEducation] = useState([
  {
    degree: "",
    college: "",
  },
]);

添加新行时会追加一个空对象:

setEducation((previous) => [
  ...previous,
  {
    degree: "",
    college: "",
  },
]);

通过遍历数组来进行渲染:

{education.map((item, index) => (
  <div key={index}>
    <input
      value={item.degree}
      onChange={(event) => {
        // update degree
      }}
    />

    <input
      value={item.college}
      onChange={(event) => {
        // update college
      }}
    />
  </div>
))}

只有当行不会被删除或重新排序时,使用索引key才是安全的;否则应使用稳定的标识符,否则带类型的文本可能会附加到错误的行上。

条件字段

某些字段取决于之前的选择,例如企业账户的公司详情:

Account Type

○ Individual
○ Business

If the user chooses:

Business

show:
Company Name
GST Number
Company Address

条件表达式可用来处理这种情况:

{form.accountType === "business" && (
  <>
    <input
      name="companyName"
      value={form.companyName}
      onChange={handleChange}
    />

    <input
      name="gstNumber"
      value={form.gstNumber}
      onChange={handleChange}
    />
  </>
)}

如果用户返回,应清除或排除隐藏值,以避免提交过时的数据。

级联选择

国家、州和城市之间是相互关联的:

Country
   ↓
State
   ↓
City

上游的更改会在获取新选项之前重置其下方的所有内容:

Country changes
      ↓
Clear State
      ↓
Fetch states
      ↓
User selects State
      ↓
Fetch cities

由于选项来自服务器,TanStack Query 与表单库配合使用可很好地实现缓存和加载状态管理。

文件上传

无法直接控制文件输入元素,需从event.target.files中读取文件:

<input
  type="file"
  onChange={(event) => {
    const file = event.target.files?.[0];

    if (file) {
      console.log(file);
    }
  }}
/>

要上传文件,需发送包含FormData的请求体:

const formData = new FormData();

formData.append("name", form.name);
formData.append("file", file);

await fetch("/api/upload", {
  method: "POST",
  body: formData,
});

不要使用fetch和FormData手动设置Content-Type: multipart/form-data,浏览器会自动生成包含所需分隔符的该标头。

保持大型表单的快速响应

大型且复杂的表单会给那种每次按键都会重新渲染的简单控制型表单带来压力:

User types
   ↓
setState
   ↓
Render
   ↓
Large form tree

有效的应对措施:

  1. 尽量将状态保存在本地,避免不必要的全局状态。
  2. 将表单拆分为多个部分,这样每次更改带来的重新渲染次数就会减少:
Form
├── PersonalDetails
├── Address
├── Education
├── Experience
└── Documents
  1. 使用合适的库,例如 React Hook Form。
  2. 避免对每个字符进行复杂的计算或 API 调用。
  3. 对必须执行的操作进行防抖处理,比如用户名是否可用的检查:
Username input
     ↓
Wait 300ms
     ↓
Check availability

不使用库的完整表单

这个示例包含了用于重置的 initialForm 常量、分别存储字段错误、提交错误和服务器错误的独立状态、一个 validate 函数,以及一个异步提交功能,该功能会发送 JSON 数据,将非正常响应视为失败,在成功时重置表单,并始终清除加载状态。标签通过 htmlFor 和 id 与输入框关联。

import { useState } from "react";

const initialForm = {
  name: "",
  email: "",
  password: "",
};

function RegisterForm() {
  const [form, setForm] = useState(initialForm);
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] =
    useState(false);
  const [serverError, setServerError] =
    useState("");

  const handleChange = (event) => {
    const { name, value } = event.target;

    setForm((previous) => ({
      ...previous,
      [name]: value,
    }));
  };

  const validate = () => {
    const errors = {};

    if (!form.name.trim()) {
      errors.name = "Name is required";
    }

    if (!form.email.includes("@")) {
      errors.email = "Enter a valid email";
    }

    if (form.password.length < 8) {
      errors.password =
        "Password must be at least 8 characters";
    }

    return errors;
  };

  const handleSubmit = async (event) => {
    event.preventDefault();

    const validationErrors = validate();

    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      return;
    }

    setErrors({});
    setServerError("");
    setIsSubmitting(true);

    try {
      const response = await fetch("/api/register", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(form),
      });

      if (!response.ok) {
        throw new Error("Registration failed");
      }

      setForm(initialForm);
    } catch (error) {
      setServerError(
        "Unable to create your account."
      );
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">
          Name
        </label>

        <input
          id="name"
          name="name"
          value={form.name}
          onChange={handleChange}
        />

        {errors.name && (
          <p>{errors.name}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">
          Email
        </label>

        <input
          id="email"
          name="email"
          type="email"
          value={form.email}
          onChange={handleChange}
        />

        {errors.email && (
          <p>{errors.email}</p>
        )}
      </div>

      <div>
        <label htmlFor="password">
          Password
        </label>

        <input
          id="password"
          name="password"
          type="password"
          value={form.password}
          onChange={handleChange}
        />

        {errors.password && (
          <p>{errors.password}</p>
        )}
      </div>

      {serverError && (
        <p>{serverError}</p>
      )}

      <button
        type="submit"
        disabled={isSubmitting}
      >
        {isSubmitting
          ? "Creating..."
          : "Create Account"}
      </button>
    </form>
  );
}

export default RegisterForm;

在正式环境中,还需为无效字段设置 aria-invalid 属性,并使用 aria-describedby 将提示信息与对应字段关联。

整体流程概览

从输入、验证,到字段错误处理,或是包含成功与失败分支的 API 调用,所有内容都适用于同一棵决策树:

                    React Form
                        │
                        ▼
                   User Input
                        │
                        ▼
                    Form State
                        │
                        ▼
                    Validation
                   /          \
                Valid        Invalid
                  │              │
                  ▼              ▼
               Submit        Show Errors
                  │
                  ▼
               API Call
              /        \
          Success      Error
             │           │
             ▼           ▼
        Reset/Form    Show Server
        Success UI       Error

关键要点

  • 通过控制输入,可使状态成为唯一真实来源;一个对象加上 [name]: value 处理函数即可覆盖大多数字段。
  • 数字以字符串形式传递,复选框使用 checked 属性,下拉菜单通过 select 元素进行控制,而文件输入始终为非受控类型。
  • 客户端验证旨在提升用户体验,只有服务器端验证才能真正保护数据安全。
  • 需明确标注模型提交状态,并在请求处理期间禁用提交功能。
  • 当字段数量过多、规则需要共享或渲染成本过高时,可使用 React Hook Form 和 Zod 来简化状态管理。