发布于 2026-01-05 1 阅读
0

React 表单的更好指南 DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

React表单的更佳指南

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

React 生态系统拥有丰富的库、文章、视频以及各种资源,涵盖你能想到的所有 Web 开发主题。然而,随着时间的推移,其中许多资源会因无法跟上现代最佳实践而过时。

最近在为一个人工智能相关的项目开发一个复杂的动态表单,并且在研究 React 表单的优秀指南时,我发现大多数关于构建表单的资源都已过时,往往过时很多年。

本文将阐述在 React 中构建表单的现代最佳实践,包括如何构建动态表单、表单与 React 服务器组件的关系等等。最后,在深入理解这些主题之后,我将总结其他指南中存在的不足,并根据我自身的 React 使用经验提出一些建议。

受控与非受控

理解 React 表单的关键在于理解“受控”输入与“非受控”输入的概念,这是 React 中构建表单的两种不同方法。

受控表单会将每个输入框的值存储在 React 状态中,并在每次重新渲染时根据该状态设置每个输入框的值。如果其他函数更新了状态,组件的值会立即反映出来。即使你的 React 逻辑没有渲染表单输入框,这些值仍然会保留在应用的状态中。受控输入框通常能为你的 React 逻辑提供更多选择,例如在用户输入时进行复杂的、非 HTML 标准的表单验证(例如检查密码强度),以及对输入框的值进行代码操作(例如在用户输入时对电话号码进行断字处理)。

它们看起来大概是这样的:

import React, { useState } from 'react';

function ControlledForm() {
  const [value, setValue] = useState('');

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

  const handleSubmit = () => {
    // no "submit event" param needed above ^
    sendInputValueToApi(value).then(() => /* Do something */)
  };

  return (
    <>
      <input type="text" value={value} onChange={handleChange} />   
      <button onClick={handleSubmit}>Send</button>
    </>
}
Enter fullscreen mode Exit fullscreen mode

<form>请注意,从语义上讲,将输入框包裹在 `<input>` 标签中并给输入框命名会更正确,但从功能上讲,这并非必要,因为数据已经保存在状态中,所以我们不需要真正的onSubmit事件,也不需要按下按钮时直接访问输入框。

也有一些缺点:

  1. 您可能不希望用户每次输入内容时都重新渲染表单。
  2. 管理复杂表单需要编写大量代码,因为随着表单规模的增长,状态也会随之增多。这会导致大量的样板代码,例如 useState 和状态设置,从而增加代码量。
  3. 构建字段数量可变的动态表单会比较困难,因为无法有条件地使用类似 useState 的钩子函数。为了解决这个问题:(a) 整个表单状态很可能变成一个巨大的对象;(b) 由于使用了对象而不是基本类型作为状态,所有子组件都会在每次输入更改时重新渲染;(c) 唯一的解决方法是使用 memoization,但这会增加大量样板代码。

这可能会导致处理大型表单(如电子表格、表格等)时出现性能问题。

import React, { useState } from "react";

function CumbersomeForm() {
  const [formData, setFormData] = useState({
    firstName: "",
    lastName: "",
    email: "",
    address: "",
    // ... potentially many more individual properties
  });

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData((prevState) => ({ ...prevState, [name]: value }));
  };

  return (
    <>
      <label>First Name:</label>
      <input
        type="text"
        name="firstName"
        value={formData.firstName}
        onChange={handleChange}
      />
      <label>Last Name:</label>
      <input
        type="text"
        name="lastName"
        value={formData.lastName}
        onChange={handleChange}
      />
      <label>Email:</label>
      <input
        type="email"
        name="email"
        value={formData.email}
        onChange={handleChange}
      />
      <label>Address:</label>
      <input
        type="text"
        name="address"
        value={formData.address}
        onChange={handleChange}
      />
      {/* ... potentially many more individual input fields */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

另一方面,非受控输入不会将输入值保存到 React 状态中。相反,非受控表单使用原生 HTML 和 JavaScript 的内置<form>功能来管理数据。例如,浏览器不会在每次输入值更改时都将其添加到状态并设置其 value 属性,而是直接管理输入值。我们的 React 组件永远不需要或使用这些值——当组件渲染时,React 会为表单添加 onsubmit 事件监听器,当提交按钮被按下时,handleSubmit 函数就会执行。这种方式更接近于不使用任何 JavaScript 的纯 HTML 表单的工作方式,而不是使用 React 状态。

function UncontrolledForm() {

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

    const formData = new FormData(event.target);
    const inputValue = formData.get('inputName');

    sendInputValueToApi(inputValue).then(() => /* Do something */)  
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" name="inputName" />
      <button type="submit">Send</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

这种方法的优点是减少了样板代码。比较以下两个输入:

// Controlled

const [value, setValue] = useState('');

const handleChange = (event) => {
    setValue(event.target.value);
};
...
<input type="text" value={value} onChange={handleChange} />

// Uncontrolled

<input type="text" name="inputName" />
Enter fullscreen mode Exit fullscreen mode

即使只有一个输入,差异也很显著,而输入越多,差异就越明显。请将上面较长的受控表达式与以下表达式进行比较:

import React from "react";

function UncontrolledForm() {
  const handleSubmit = (event) => {
    event.preventDefault();
    const formData = new FormData(event.target);
    for (let [key, value] of formData.entries()) {
      console.log(`${key}: ${value}`);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>First Name:</label>
      <input type="text" name="firstName" />

      <label>Last Name:</label>
      <input type="text" name="lastName" />

      <label>Email:</label>
      <input type="email" name="email" />

      <label>Address:</label>
      <input type="text" name="address" />
      {/* ... potentially many more individual input fields */}
      <button type="submit">Submit</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

这种表单比受控版本代码更少,无需管理多个钩子调用或庞大复杂的状态对象。实际上,这里根本没有状态。这个表单可以有成百上千个子元素,它们之间永远不会互相导致重新渲染。使用这种方法,表单性能更好、代码更少、也更容易理解。

非受控表单的缺点在于您无法直接访问每个输入框的值。这会使自定义验证更加困难,并且无法直接操作输入框的值。

注意事项和妥协

不要使用引用

许多文章建议在非受控表单中为每个输入框使用 ref,而不是使用 useState,new FormData()我认为这是因为FormData API 的知名度较低。然而,FormData API 已经成为近十年的标准,并且多年来一直受到所有主流浏览器的支持。我强烈建议不要为每个表单输入框调用 useRef,因为这会引入与 useState 调用类似的挑战和样板代码。

然而,在某些情况下,参考文献会有所帮助。

  1. 焦点管理 - 以编程方式聚焦表单元素,尤其是在处理动态表单字段时。
function MyForm() {
  const inputRef = useRef(null);

  const focusInput = () => {
    inputRef.current.focus();
  };

  return (
    <form>
      <input ref={inputRef} type="text" />
      <button type="button" onClick={focusInput}>
        Focus Input
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode
  1. 调用子组件 HTML 元素的方法——例如,如果您的表单被拆分成多个子组件,并且您需要从父表单或子组件的同级元素中聚焦于其中一个子组件,则需要从父表单传递一个 ref,如下所示:
const ChildComponent = React.forwardRef((props, ref) => (
  <input ref={ref} type="text" />
));

function MyForm() {
  const inputRef = useRef(null);

  const focusInput = () => {
    inputRef.current.focus();
  };

  return (
    <form>
      <ChildComponent ref={inputRef} />
      <button type="button" onClick={focusInput}>
        Focus Input
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode
  1. 其他与表单无关的 ref 用例——例如使用 useEffect 保存先前的值,或测量元素(例如用户可以扩展的文本区域)的物理大小。

混合控制和非控制

很多情况下,你可能需要控制一个或几个输入框。例如,在用户输入电话号码时对其进行操作。在这种情况下,即使你使用的是非受控表单,也可以只使用一个受控输入框。此时,不要使用状态来访问表单提交时输入框的值——继续使用状态new FormData(...)——只需使用状态来管理相关输入框的显示即可。

function MixedForm() {
  const [phoneNumber, setPhoneNumber] = useState("");

  const handlePhoneNumberChange = (event) => {
    // Some function to format the phone number
    const formattedNumber = formatPhoneNumber(event.target.value);
    setPhoneNumber(formattedNumber);
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    const formData = new FormData(event.target);
    for (let [key, value] of formData.entries()) {
      console.log(`${key}: ${value}`);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>Name:</label>
      <input type="text" name="name" />

      <label>Email:</label>
      <input type="email" name="email" />

      <label>Phone Number:</label>
      <input
        type="tel"
        name="phoneNumber"
        value={phoneNumber}
        onChange={handlePhoneNumberChange}
      />

      <label>Address:</label>
      <input type="text" name="address" />

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

// Helper function for phone number formatting (just an example)
function formatPhoneNumber(number) {
  // Format the phone number as desired. This is just a basic example.
  return number.replace(/\D/g, "").slice(0, 10);
}
Enter fullscreen mode Exit fullscreen mode

注意:尽量减少状态。在这个例子中,你不需要一个 useState 来保存原始电话号码,再用一个单独的 useState 来保存格式化后的电话号码,然后通过 effect 来同步它们——这样做会导致不必要的重新渲染。

说到优化重新渲染,我们还可以将受控输入移到它自己的组件中,以最大限度地减少表单其余部分的重新渲染。

const PhoneInput = () => {
  const [phoneNumber, setPhoneNumber] = useState("");

  const handlePhoneNumberChange = (event) => {
    // Some function to format the phone number
    const formattedNumber = formatPhoneNumber(event.target.value);
    setPhoneNumber(formattedNumber);
  };

  return (
    <input
      type="tel"
      name="phoneNumber"
      value={phoneNumber}
      onChange={handlePhoneNumberChange}
    />
  );
};

function MixedForm() {
  const handleSubmit = (event) => {
    event.preventDefault();
    const formData = new FormData(event.target);
    for (let [key, value] of formData.entries()) {
      console.log(`${key}: ${value}`);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>Name:</label>
      <input type="text" name="name" />

      <label>Email:</label>
      <input type="email" name="email" />

      <label>Phone Number:</label>
      <PhoneInput />

      <label>Address:</label>
      <input type="text" name="address" />

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

// Helper function for phone number formatting (just an example)
function formatPhoneNumber(number) {
  // Format the phone number as desired. This is just a basic example.
  return number.replace(/\D/g, "").slice(0, 10);
}
Enter fullscreen mode Exit fullscreen mode

如果你习惯了受控输入,那么乍一看上面的代码,你可能会想:“如果父表单没有传递任何状态设置器或引用,它怎么知道子表单的电话输入框的值呢?” 要理解这一点,请记住,当 React 代码渲染成 HTML 时,浏览器只会“看到”表单及其内部输入框的 HTML 代码,包括 PhoneInput 组件渲染的输入框——React 组件的结构不会影响渲染后的 HTML 的功能。然后,该输入框的值会像表单中的其他字段一样包含在 FormData 中。这就是 React 中组件封装和组合的强大之处。我们可以将重新渲染限制在受影响最小的 JSX 中,而 DOM 仍然以原生 HTML 的形式呈现。

这对于构建动态表单也很有用。如果您使用受控组件管理状态对象,则需要在创建或删除表单字段时手动添加和删除属性。另一方面,使用非受控组件,您只需渲染/取消渲染具有唯一名称的输入框,它们的值就会在提交时自动出现在 FormData 中。

但是等等……我该如何验证不受控输入中的输入值?

如果你也想到了这一点,你并不孤单!当需要在提交表单前进行验证时,React 开发者往往会选择使用带有状态的受控组件。

许多开发者可能没有意识到,这类验证并非总是需要 React 或自定义 JavaScript——实际上,Web 平台已经将它们作为表单输入的属性内置了!详情请查看 MDN 上的这篇文章:https://developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation

无需任何 JS,您可以使用正则表达式来要求输入值、设置长度要求和设置格式要求(像 ChatGPT 这样的 AI 工具非常适合帮助处理复杂的正则表达式)。

错误处理

关于相关话题,当需要在客户端显示一些验证错误时,开发人员往往会选择使用受控组件。然而,如果需求允许,我更倾向于使用非受控组件,并在 onSubmit 函数中处理错误,而不是使用受控组件并在每次输入更改时设置值。这样可以最大限度地减少表单中的状态数量和状态更新。代码可能如下所示:

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

  const handleSubmit = (event) => {
    event.preventDefault();
    const formData = new FormData(event.target);

    let validationErrors = {};

    // Custom validation: Ensure the email domain is "example.com"
    const email = formData.get("email");
    if (email && !email.endsWith("@example.com")) {
      validationErrors.email = "Email must be from the domain example.com.";
    }

    if (formData.get("phoneNumber").length !== 10) {
      validationErrors.phoneNumber = "Phone number must be 10 digits.";
    }

    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
    } else {
      // Handle the successful form submission, e.g., sending formData to a server
      console.log(Array.from(formData.entries()));
      setErrors({}); // Clear any previous errors
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>Name:</label>
      <input type="text" name="name" required />
      {errors.name && <div className="error">{errors.name}</div>}

      <label>Email (must be @example.com):</label>
      <input type="email" name="email" required />
      {errors.email && <div className="error">{errors.email}</div>}

      <label>Phone Number (10 digits):</label>
      <input type="tel" name="phoneNumber" required pattern="\d{10}" />
      {errors.phoneNumber && <div className="error">{errors.phoneNumber}</div>}

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

export default UncontrolledForm;
Enter fullscreen mode Exit fullscreen mode

使用非受控组件还有另一个很大的好处。

React 服务器组件中的表单

React 服务器组件 (RSC) 使用服务器端框架来渲染部分组件,从而减少 Web 浏览器为运行网站而下载的 JavaScript 量。这可以显著提升网站性能。

RSC 对我们编写表单的方式产生了重大影响,因为,如果表单输入不使用状态,它们就可以在服务器端渲染成 HTML,而无需向浏览器发送任何 JavaScript。这意味着非受控表单无需 JavaScript 即可实现交互,从而可以更快地响应,而无需等待 JS 下载和执行——实际上,它使您的网站运行速度更快,交互体验更加流畅。

使用 Next.js,您还可以在表单中使用服务器端操作,这样就无需编写 API 来进行表单交互——您只需要一个处理函数即可。您可以从Next.js 文档Lee 的精彩讲解视频(链接在此)了解更多相关信息。

如果您使用 RSC 渲染表单构建包含一些受控输入的混合表单,请务必将受控组件封装到一个单独的客户端组件中,类似于<PhoneInput />之前的组件,但要放在单独的文件中。这样可以确保只打包所需的最少 JavaScript 代码。它可能看起来像这样:

// page.jsx
import { PhoneInput } from "./PhoneInput";

export default function Page() {
  async function create(formData: FormData) {
    "use server";

    // ... use the FormData
  }

  return (
    <form action={create}>
      <label>Name:</label>
      <input type="text" name="name" />

      <label>Email:</label>
      <input type="email" name="email" />

      <label>Phone Number:</label>
      <PhoneInput />

      <label>Address:</label>
      <input type="text" name="address" />

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

// PhoneInput.jsx
"use client";

// Helper function for phone number formatting (just an example)
function formatPhoneNumber(number) {
  // Format the phone number as desired. This is just a basic example.
  return number.replace(/\D/g, "").slice(0, 10);
}

import { useState } from "react";

export const PhoneInput = () => {
  const handlePhoneNumberChange = (event) => {
    // Some function to format the phone number
    const formattedNumber = formatPhoneNumber(event.target.value);
    setPhoneNumber(formattedNumber);
  };
  const [phoneNumber, setPhoneNumber] = useState("");

  return (
    <input
      type="tel"
      name="phoneNumber"
      value={phoneNumber}
      onChange={handlePhoneNumberChange}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

表单库

React 生态系统中有很多优秀的表单库,专为受控输入而设计。最近我一直在使用 React Hook Form 来处理这类应用,不过我更多地依赖于非受控输入,所以不需要库来管理表单状态。(编辑:一些常用的表单库包括 React Hook Form、Formik 和 Informed)

结论、比较和建议

本文的撰写动机源于在谷歌搜索“react forms”时,搜索结果顶部附近出现的那些令人困惑、过时和/或具有误导性的文章。

  • 其中一篇文章说“更符合 React 惯用风格的做法是使用受控表单”——我认为受控输入和非受控输入都不能说哪一种更“符合 React 惯用风格”。实际上,正如上文所述,两种输入方式各有其用武之地。事实上,许多较早的文章似乎都推荐使用受控输入,理由也同样含糊不清或具有误导性。我认为,由于非受控输入对 JavaScript 的依赖性更低,因此实际上更符合 Web 惯用风格,这使得非受控输入在 React 服务器组件中能够充分发挥其优势。
  • 没有一篇热门文章使用 FormData。对于非受控表单,至少有两篇建议多次调用 useRef,这会使代码缺乏灵活性且更加冗余。
  • 一些热门文章仍然使用类组件,而没有提及函数式组件。

最后的一些想法:

  1. 根据我的经验,大多数表单都是非受控输入和受控输入的混合体。之所以同时提供这两种选项,是为了保证灵活性,我们不应该固守成规。我们可以像上面的 React 服务器组件示例那样,将它们结合使用(尽管该示例并不需要服务器组件)。
  2. 如今,我尽可能地倾向于使用非受控输入。我相信这能简化代码结构并提升性能。
  3. 在你的 onSubmit 函数中使用“new FormData(...)”而不是使用 refs。真的。
  4. 使用组合和封装来控制输入,以最大限度地减少状态更新对其他组件的影响,并依靠渲染的组合 DOM 来处理提交事件。

希望这篇文章对大家有所帮助!欢迎在评论区留言提问。

文章来源:https://dev.to/ajones_codes/a-better-guide-to-forms-in-react-47f0