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>
</>
}
<form>请注意,从语义上讲,将输入框包裹在 `<input>` 标签中并给输入框命名会更正确,但从功能上讲,这并非必要,因为数据已经保存在状态中,所以我们不需要真正的onSubmit事件,也不需要按下按钮时直接访问输入框。
也有一些缺点:
- 您可能不希望用户每次输入内容时都重新渲染表单。
- 管理复杂表单需要编写大量代码,因为随着表单规模的增长,状态也会随之增多。这会导致大量的样板代码,例如 useState 和状态设置,从而增加代码量。
- 构建字段数量可变的动态表单会比较困难,因为无法有条件地使用类似 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 */}
</>
);
}
另一方面,非受控输入不会将输入值保存到 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>
);
}
这种方法的优点是减少了样板代码。比较以下两个输入:
// 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" />
即使只有一个输入,差异也很显著,而输入越多,差异就越明显。请将上面较长的受控表达式与以下表达式进行比较:
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>
);
}
这种表单比受控版本代码更少,无需管理多个钩子调用或庞大复杂的状态对象。实际上,这里根本没有状态。这个表单可以有成百上千个子元素,它们之间永远不会互相导致重新渲染。使用这种方法,表单性能更好、代码更少、也更容易理解。
非受控表单的缺点在于您无法直接访问每个输入框的值。这会使自定义验证更加困难,并且无法直接操作输入框的值。
注意事项和妥协
不要使用引用
许多文章建议在非受控表单中为每个输入框使用 ref,而不是使用 useState,new FormData()我认为这是因为FormData API 的知名度较低。然而,FormData API 已经成为近十年的标准,并且多年来一直受到所有主流浏览器的支持。我强烈建议不要为每个表单输入框调用 useRef,因为这会引入与 useState 调用类似的挑战和样板代码。
然而,在某些情况下,参考文献会有所帮助。
- 焦点管理 - 以编程方式聚焦表单元素,尤其是在处理动态表单字段时。
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>
);
}
- 调用子组件 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>
);
}
- 其他与表单无关的 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);
}
注意:尽量减少状态。在这个例子中,你不需要一个 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);
}
如果你习惯了受控输入,那么乍一看上面的代码,你可能会想:“如果父表单没有传递任何状态设置器或引用,它怎么知道子表单的电话输入框的值呢?” 要理解这一点,请记住,当 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;
使用非受控组件还有另一个很大的好处。
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}
/>
);
};
表单库
React 生态系统中有很多优秀的表单库,专为受控输入而设计。最近我一直在使用 React Hook Form 来处理这类应用,不过我更多地依赖于非受控输入,所以不需要库来管理表单状态。(编辑:一些常用的表单库包括 React Hook Form、Formik 和 Informed)
结论、比较和建议
本文的撰写动机源于在谷歌搜索“react forms”时,搜索结果顶部附近出现的那些令人困惑、过时和/或具有误导性的文章。
- 其中一篇文章说“更符合 React 惯用风格的做法是使用受控表单”——我认为受控输入和非受控输入都不能说哪一种更“符合 React 惯用风格”。实际上,正如上文所述,两种输入方式各有其用武之地。事实上,许多较早的文章似乎都推荐使用受控输入,理由也同样含糊不清或具有误导性。我认为,由于非受控输入对 JavaScript 的依赖性更低,因此实际上更符合 Web 惯用风格,这使得非受控输入在 React 服务器组件中能够充分发挥其优势。
- 没有一篇热门文章使用 FormData。对于非受控表单,至少有两篇建议多次调用 useRef,这会使代码缺乏灵活性且更加冗余。
- 一些热门文章仍然使用类组件,而没有提及函数式组件。
最后的一些想法:
- 根据我的经验,大多数表单都是非受控输入和受控输入的混合体。之所以同时提供这两种选项,是为了保证灵活性,我们不应该固守成规。我们可以像上面的 React 服务器组件示例那样,将它们结合使用(尽管该示例并不需要服务器组件)。
- 如今,我尽可能地倾向于使用非受控输入。我相信这能简化代码结构并提升性能。
- 在你的 onSubmit 函数中使用“new FormData(...)”而不是使用 refs。真的。
- 使用组合和封装来控制输入,以最大限度地减少状态更新对其他组件的影响,并依靠渲染的组合 DOM 来处理提交事件。
希望这篇文章对大家有所帮助!欢迎在评论区留言提问。
文章来源:https://dev.to/ajones_codes/a-better-guide-to-forms-in-react-47f0