React 基础体系 · 第 8/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 表单工程:受控组件、校验、异步提交、错误与性能
表单看似只是若干 <input> 和一个提交按钮,实际上同时涉及五类问题:
- 数据所有权:输入框中的值由 DOM 保存,还是由 React 状态保存?
- 校验时机:什么时候校验,错误显示在哪里,客户端校验和服务端校验如何分工?
- 异步流程:提交期间如何防止重复提交、处理网络失败和竞态?
- 错误表达:字段错误、表单级错误、权限错误和系统异常如何区分?
- 渲染成本:每次击键都触发 React 更新时,如何避免无关组件重复渲染?
本文以 React 19、现代 TypeScript 和主流 React 框架为背景,先建立表单的状态模型,再实现一个可运行的登录表单,最后讨论 React 19 的表单 Action、服务端边界、可访问性和性能取舍。
一、先建立表单的状态模型
1. 受控组件是什么
受控组件是指表单元素的当前值由 React 状态决定:
const [email, setEmail] = useState("");
<input
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
数据流是:
用户输入
↓
浏览器触发 input/change 事件
↓
onChange 读取 event.target.value
↓
React 更新 state
↓
重新渲染
↓
value 再次写入 input
因此,输入框的值不是一个独立于 React 的事实源,而是:
在正常更新路径中,这个等式应当成立。
受控组件的直接收益是:渲染、校验、提交、禁用按钮、错误提示都可以读取同一个状态。比如:
<button disabled={email.trim() === ""}>
提交
</button>
这里的按钮状态不是通过查询 DOM 得到的,而是由 React 状态推导出来的。
2. 非受控组件是什么
非受控组件由 DOM 保存当前值,React 只在需要时通过 ref 或 FormData 读取:
const formRef = useRef<HTMLFormElement>(null);
<form ref={formRef}>
<input name="email" defaultValue="" />
</form>
提交时:
const form = formRef.current;
if (!form) return;
const formData = new FormData(form);
const email = formData.get("email");
defaultValue 只用于初始值;后续输入由浏览器 DOM 管理。它与 value 的区别不是语法偏好,而是所有权不同:
| 方式 | 当前值的事实源 | 常见读取方式 |
|---|---|---|
| 受控 | React state | 直接读取状态 |
| 非受控 | DOM | ref、FormData |
受控和非受控可以混合使用,但同一个字段不能在生命周期中从一种模式切换到另一种模式。例如:
// 初始时 value 是 undefined,之后变成字符串
<input value={user?.email} onChange={...} />
这可能导致 React 的 “uncontrolled to controlled” 警告。应将空值明确初始化为字符串:
<input
value={user?.email ?? ""}
onChange={...}
/>
对于复选框,使用 checked,而不是 value:
<input
type="checkbox"
checked={remember}
onChange={(event) => setRemember(event.target.checked)}
/>
3. 受控组件不等于必须把所有字段放进一个对象
以下两种写法都可以:
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
或者:
type Values = {
email: string;
password: string;
};
const [values, setValues] = useState<Values>({
email: "",
password: "",
});
多个字段之间存在联动、校验状态较复杂时,useReducer 通常更容易表达状态转换:
type FormState = {
values: Values;
touched: Record<keyof Values, boolean>;
errors: Partial<Record<keyof Values, string>>;
submitting: boolean;
serverError: string | null;
};
useReducer 的价值主要是集中描述状态转换,不是自动带来性能提升。它仍然会导致使用该状态的组件重新渲染。
二、状态快照、批处理与事件处理
React 函数组件中的 state 是当前渲染的快照。事件处理函数捕获的是创建它的那次渲染中的值。
function Example() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1);
}
return <button onClick={handleClick}>{count}</button>;
}
点击一次后通常得到 1,而不是 2。两次调用都读取了同一个快照:
count = 0
第一次 setCount(0 + 1)
第二次 setCount(0 + 1)
最终结果 = 1
如果更新依赖前一个状态,应使用函数式更新:
function handleClick() {
setCount((previous) => previous + 1);
setCount((previous) => previous + 1);
}
此时 React 会按顺序计算:
previous = 0 → 1
previous = 1 → 2
最终结果 = 2
React 18 及之后,React 会对更多异步场景中的更新进行批处理;React 19 延续这一模型。批处理意味着多个更新可能合并为一次渲染,但不意味着状态变量会在当前函数中立即改变。
表单提交尤其容易出现这个误区:
setValues(nextValues);
await submit(values); // 这里的 values 仍可能是旧快照
正确做法是直接提交已知的 nextValues:
const nextValues = {
...values,
email: values.email.trim(),
};
setValues(nextValues);
await submit(nextValues);
或者让提交函数接收明确的数据参数,而不是依赖刚刚排队更新的 state。
三、校验不是一个函数,而是多个边界
1. 校验的形式化模型
对字段值 ,校验可以看作一个返回错误集合的函数:
其中:
- 是字段或表单数据;
- 是错误集合;
- 表示通过校验;
- 表示存在错误。
例如邮箱校验可以写成:
但这只是客户端可观察的校验。完整的提交条件还包括服务端规则:
客户端只能提前判断第一部分,不能证明整个表达式为真。比如:
- 邮箱格式正确,但账号不存在;
- 密码格式正确,但密码错误;
- 用户有输入权限,但会话已经过期;
- 表单提交时资源已被其他请求占用。
因此,客户端校验的作用是改善反馈速度,不是建立安全边界。
2. 三类校验时机
输入时校验
每次 onChange 都校验:
onChange={(event) => {
const value = event.target.value;
setEmail(value);
setEmailError(validateEmail(value));
}}
优点是反馈快,缺点是用户还没输入完成时就可能看到错误。例如用户输入 a 时显示邮箱格式错误,体验通常较差。
失焦时校验
在 onBlur 中标记字段已访问:
onBlur={() => {
setTouched((previous) => ({
...previous,
email: true,
}));
}}
渲染时只对已访问字段显示错误:
{touched.email && emailError && (
<p>{emailError}</p>
)}
这是常见默认策略:输入过程中保持稳定,离开字段后反馈问题。
提交时校验
提交时必须对全部字段校验,即使用户没有逐个失焦:
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const errors = validateAll(values);
if (Object.keys(errors).length > 0) {
// 标记所有相关字段,显示错误
return;
}
// 进入异步提交
}
提交时校验是不可省略的最后一道客户端检查,因为用户可以直接按 Enter 提交,也可能通过脚本触发提交。
3. 原生约束与自定义校验
HTML 原生约束可以表达一部分通用规则:
<input
name="email"
type="email"
required
autoComplete="email"
/>
<input
name="password"
type="password"
required
minLength={8}
autoComplete="current-password"
/>
浏览器会在提交前执行这些约束,并阻止不符合约束的表单提交。自定义错误可以通过 React 自己渲染,也可以使用 setCustomValidity,但两者不要重复制造互相矛盾的错误来源。
如果完全由 React 负责校验,可以使用:
<form noValidate onSubmit={handleSubmit}>
这会关闭浏览器的默认约束提示,但不会删除 required、type="email" 等属性本身。关闭后,React 必须负责完整的错误反馈和可访问性。
四、一个完整的受控异步表单
下面实现一个登录表单,包含:
- 受控输入;
useReducer管理状态;- 失焦和提交校验;
- 网络请求;
- 4xx/5xx 错误解析;
- 重复提交保护;
- 请求竞态保护;
- 字段错误和表单级错误;
- 基本可访问性处理。
示例假设服务端接口为:
POST /api/login
Content-Type: application/json
成功响应:
{
"ok": true,
"redirectTo": "/dashboard"
}
校验失败响应:
{
"ok": false,
"fieldErrors": {
"email": "账号或密码不正确"
},
"formError": "登录失败"
}
1. 类型和校验函数
"use client";
import {
FormEvent,
useEffect,
useReducer,
useRef,
} from "react";
type Values = {
email: string;
password: string;
remember: boolean;
};
type FieldName = keyof Values;
type FieldErrors = Partial<Record<"email" | "password", string>>;
type FormState = {
values: Values;
touched: Partial<Record<FieldName, boolean>>;
errors: FieldErrors;
submitting: boolean;
serverError: string | null;
succeeded: boolean;
};
const initialState: FormState = {
values: {
email: "",
password: "",
remember: false,
},
touched: {},
errors: {},
submitting: false,
serverError: null,
succeeded: false,
};
function validateEmail(email: string): string | undefined {
const value = email.trim();
if (!value) {
return "请输入邮箱";
}
// 这里只做基础格式校验,不试图完整实现 RFC 邮箱语法。
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return "请输入有效的邮箱地址";
}
return undefined;
}
function validatePassword(password: string): string | undefined {
if (!password) {
return "请输入密码";
}
if (password.length < 8) {
return "密码至少需要 8 个字符";
}
return undefined;
}
function validateValues(values: Values): FieldErrors {
const errors: FieldErrors = {};
const emailError = validateEmail(values.email);
const passwordError = validatePassword(values.password);
if (emailError) {
errors.email = emailError;
}
if (passwordError) {
errors.password = passwordError;
}
return errors;
}
这里没有把 remember 当作错误字段,因为它是可选项。FieldErrors 只允许字段错误映射到 email 和 password,可以避免任意字符串导致的类型失控。
2. Reducer:把状态转换写清楚
type Action =
| {
type: "change";
field: FieldName;
value: string | boolean;
}
| {
type: "blur";
field: FieldName;
}
| {
type: "show-validation-errors";
errors: FieldErrors;
}
| {
type: "submit-start";
}
| {
type: "submit-success";
}
| {
type: "submit-failure";
fieldErrors?: FieldErrors;
formError: string;
};
function reducer(state: FormState, action: Action): FormState {
switch (action.type) {
case "change": {
const values = {
...state.values,
[action.field]: action.value,
} as Values;
// 用户修改字段后清除该字段旧的服务端错误或客户端错误。
const errors = { ...state.errors };
if (action.field === "email" || action.field === "password") {
delete errors[action.field];
}
return {
...state,
values,
errors,
serverError: null,
succeeded: false,
};
}
case "blur": {
const touched = {
...state.touched,
[action.field]: true,
};
const errors = validateValues(state.values);
return {
...state,
touched,
errors,
};
}
case "show-validation-errors":
return {
...state,
errors: action.errors,
touched: {
email: true,
password: true,
},
};
case "submit-start":
return {
...state,
submitting: true,
serverError: null,
succeeded: false,
};
case "submit-success":
return {
...state,
submitting: false,
errors: {},
serverError: null,
succeeded: true,
};
case "submit-failure":
return {
...state,
submitting: false,
errors: action.fieldErrors ?? {},
serverError: action.formError,
succeeded: false,
};
default:
return state;
}
}
这里的状态转换有几个重要特点:
change只修改一个字段,但保留其他字段;- 字段发生新修改时清除旧错误,避免错误信息滞留;
submit-start不清除字段错误,因为提交前可能已经存在校验问题;submit-failure同时支持fieldErrors和formError;submitting是状态的一部分,按钮可以据此禁用。
3. 表单组件
export default function LoginForm() {
const [state, dispatch] = useReducer(reducer, initialState);
// 每次提交使用递增序号,防止旧响应覆盖新状态。
const requestIdRef = useRef(0);
const abortControllerRef = useRef<AbortController | null>(null);
const firstInvalidRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (state.serverError) {
firstInvalidRef.current?.focus();
}
}, [state.serverError]);
function handleChange(
field: FieldName,
value: string | boolean,
) {
dispatch({
type: "change",
field,
value,
});
}
function handleBlur(field: FieldName) {
dispatch({
type: "blur",
field,
});
}
async function handleSubmit(
event: FormEvent<HTMLFormElement>,
) {
event.preventDefault();
// 先对当前渲染快照中的 values 进行同步校验。
const errors = validateValues(state.values);
if (Object.keys(errors).length > 0) {
dispatch({
type: "show-validation-errors",
errors,
});
return;
}
// 如果允许重复提交,则取消之前的请求。
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
const requestId = ++requestIdRef.current;
dispatch({ type: "submit-start" });
try {
const response = await fetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: state.values.email.trim(),
password: state.values.password,
remember: state.values.remember,
}),
signal: controller.signal,
});
// fetch 只会在网络层失败时 reject。
// HTTP 400、401、500 通常仍然会得到一个 fulfilled Response。
const payload: {
ok?: boolean;
redirectTo?: string;
fieldErrors?: FieldErrors;
formError?: string;
} = await response.json();
if (requestId !== requestIdRef.current) {
return;
}
if (!response.ok || payload.ok !== true) {
dispatch({
type: "submit-failure",
fieldErrors: payload.fieldErrors,
formError: payload.formError ?? "登录失败,请稍后重试",
});
return;
}
dispatch({ type: "submit-success" });
if (payload.redirectTo) {
window.location.assign(payload.redirectTo);
}
} catch (error) {
if (requestId !== requestIdRef.current) {
return;
}
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
dispatch({
type: "submit-failure",
formError: "网络异常,请检查连接后重试",
});
}
}
const emailError =
state.touched.email || state.submitting
? state.errors.email
: undefined;
const passwordError =
state.touched.password || state.submitting
? state.errors.password
: undefined;
return (
<form
noValidate
onSubmit={handleSubmit}
aria-describedby={state.serverError ? "form-error" : undefined}
>
<div>
<label htmlFor="login-email">邮箱</label>
<input
ref={firstInvalidRef}
id="login-email"
name="email"
type="email"
autoComplete="username"
value={state.values.email}
onChange={(event) =>
handleChange("email", event.target.value)
}
onBlur={() => handleBlur("email")}
aria-invalid={Boolean(emailError)}
aria-describedby={emailError ? "email-error" : undefined}
/>
{emailError && (
<p id="email-error" role="alert">
{emailError}
</p>
)}
</div>
<div>
<label htmlFor="login-password">密码</label>
<input
id="login-password"
name="password"
type="password"
autoComplete="current-password"
value={state.values.password}
onChange={(event) =>
handleChange("password", event.target.value)
}
onBlur={() => handleBlur("password")}
aria-invalid={Boolean(passwordError)}
aria-describedby={
passwordError ? "password-error" : undefined
}
/>
{passwordError && (
<p id="password-error" role="alert">
{passwordError}
</p>
)}
</div>
<label>
<input
name="remember"
type="checkbox"
checked={state.values.remember}
onChange={(event) =>
handleChange("remember", event.target.checked)
}
/>
记住我
</label>
{state.serverError && (
<p id="form-error" role="alert">
{state.serverError}
</p>
)}
{state.succeeded && (
<p role="status">登录成功,正在跳转……</p>
)}
<button type="submit" disabled={state.submitting}>
{state.submitting ? "提交中……" : "登录"}
</button>
</form>
);
}
4. 这段提交流程的中间状态
一次合法提交的状态变化是:
初始:
submitting = false
errors = {}
serverError = null
用户点击提交:
1. validateValues(values)
2. dispatch(submit-start)
3. submitting = true
4. 发起 fetch
服务端成功:
5. dispatch(submit-success)
6. submitting = false
7. succeeded = true
8. 跳转
服务端返回业务错误:
5. dispatch(submit-failure)
6. submitting = false
7. errors = fieldErrors
8. serverError = formError
如果同步校验失败,则不会进入网络阶段:
validateValues(values) → { email: "请输入邮箱" }
↓
show-validation-errors
↓
标记字段 touched
↓
显示字段错误
↓
不发送请求
五、异步提交中的错误、取消与竞态
1. fetch 不会因 4xx 或 5xx 自动抛错
这是常见误解:
try {
const response = await fetch("/api/login");
// 即使 response.status 是 401,也通常不会进入 catch。
} catch {
// 这里主要处理 DNS 失败、断网、请求被取消等网络层异常。
}
因此必须显式检查:
if (!response.ok) {
// 处理 HTTP 错误
}
可以将错误分成四层:
| 层级 | 示例 | 用户界面 |
|---|---|---|
| 字段错误 | 邮箱格式不合法 | 显示在对应字段旁 |
| 表单错误 | 邮箱或密码错误 | 表单级错误 |
| 传输错误 | 断网、超时、请求取消 | 网络提示和重试 |
| 系统错误 | 服务端 500、未知异常 | 通用错误,不暴露内部细节 |
不要把异常堆栈、SQL 错误或内部服务名直接返回给浏览器。服务端应记录详细日志,客户端只接收适合用户理解的错误码或消息。
2. 为什么需要请求序号
考虑两个请求:
t1: 请求 A 发出
t2: 请求 B 发出
t3: 请求 B 返回成功
t4: 请求 A 返回失败
如果响应处理函数不检查来源,A 的旧结果可能覆盖 B 的新结果。请求序号建立了一个简单条件:
这就是示例中:
const requestId = ++requestIdRef.current;
if (requestId !== requestIdRef.current) {
return;
}
AbortController 是资源和体验层面的补充:它可以取消仍在进行的请求,但取消并不能保证服务端已经撤销了操作。对支付、创建订单等不可逆操作,服务端还需要幂等键,而不能只依赖前端取消。
3. 超时不是浏览器 fetch 的默认行为
fetch 没有一个普遍可用的默认业务超时。可以使用 AbortSignal.timeout:
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify(values),
headers: {
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(10_000),
});
这是现代浏览器能力,但应根据目标浏览器和框架运行环境确认兼容性。也可以手动创建 AbortController 和 setTimeout,以便兼容更广泛的运行环境。
六、异步字段校验:必须处理竞态
例如用户名可用性检查:
useEffect(() => {
const username = values.username.trim();
if (username.length < 3) {
return;
}
const controller = new AbortController();
async function checkAvailability() {
try {
const response = await fetch(
`/api/users/check?name=${encodeURIComponent(username)}`,
{ signal: controller.signal },
);
const result: { available: boolean } =
await response.json();
// 只有当前 effect 对应的 username 才能更新状态。
if (result.available) {
// 清除“用户名已存在”错误
} else {
// 设置“用户名已存在”错误
}
} catch (error) {
if (
error instanceof DOMException &&
error.name === "AbortError"
) {
return;
}
// 处理网络错误
}
}
checkAvailability();
return () => {
controller.abort();
};
}, [values.username]);
这里的因果关系是:
values.username变化;- 旧 effect 的清理函数执行;
- 旧请求被取消;
- 新 effect 发起新请求;
- 新结果只作用于当前输入值。
如果不取消或不检查版本,用户快速输入 alice、alice1 时,旧请求可能晚于新请求返回,造成错误提示与输入内容不一致。
异步校验还应避免每个字符都请求服务端。常见方法是:
- 只在长度达到最低要求后请求;
- 使用防抖;
- 服务端仍然在最终提交时再次校验;
- 将“检查中”作为独立状态,而不是把它误当作“通过”。
七、错误反馈与可访问性
1. 标签必须与控件关联
<label htmlFor="login-email">邮箱</label>
<input id="login-email" name="email" />
只把文字放在输入框旁边但不使用 label,会损害屏幕阅读器和点击标签聚焦输入框的行为。
2. aria-invalid 表示当前字段有错误
<input
aria-invalid={Boolean(emailError)}
aria-describedby={emailError ? "email-error" : undefined}
/>
<p id="email-error" role="alert">
请输入有效的邮箱地址
</p>
aria-describedby 将输入框和错误文本关联起来。错误文本应具有稳定的 id,不要依赖随机生成的字符串,否则服务端渲染和客户端水合时可能出现不一致。
aria-invalid 不应在用户刚打开空表单时就无条件设置为 true。通常应在字段失焦、用户修改后或提交失败后设置,否则用户还没有操作就会得到大量错误提示。
3. 提交失败后管理焦点
提交失败时,用户需要知道错误发生在哪里。可以:
- 聚焦错误摘要;
- 如果没有摘要,聚焦第一个错误字段;
- 保留错误文本;
- 不要仅依赖颜色表达错误。
简单错误摘要可以是:
{hasErrors && (
<div tabIndex={-1} role="alert" ref={summaryRef}>
表单中有错误,请检查标记的字段。
</div>
)}
tabIndex={-1} 允许程序聚焦,但不会把摘要加入正常 Tab 顺序。动态内容用 role="alert" 或 role="status" 时应谨慎,避免每次击键都触发屏幕阅读器播报。
4. 键盘提交依赖正确的语义结构
应使用:
<form onSubmit={handleSubmit}>
<button type="submit">提交</button>
</form>
而不是只给一个按钮绑定 onClick。form 的语义可以支持:
- 键盘 Enter 提交;
- 浏览器原生约束;
- 辅助技术识别表单;
FormData收集字段;- React 表单 Action。
八、服务端边界:客户端校验永远不是安全校验
如果组件位于 Next.js 等框架的客户端边界中,"use client" 组件可以读取用户输入、调用浏览器 API 和执行 fetch。但服务端接口仍必须独立完成:
- 解析请求体;
- 校验类型和长度;
- 验证身份和权限;
- 防止 CSRF 或使用同源安全策略;
- 执行业务规则;
- 控制速率;
- 记录安全审计信息;
- 返回不泄露敏感信息的错误。
服务端不能相信:
if (clientErrorsAreEmpty) {
// 服务端直接写数据库
}
正确逻辑是服务端再次执行校验:
客户端快速校验
↓
减少明显错误请求
↓
服务端解析并重新校验
↓
验证认证、权限、业务条件
↓
执行操作
密码字段不应写入日志。服务端错误日志也应避免记录完整请求体、密码、令牌和敏感个人信息。
九、React 19 的表单 Action
React 19 增加了与表单 Action 相关的能力,包括 useActionState、useFormStatus 等。它们适合将“提交状态、返回状态、pending 状态”与表单提交流程关联起来,但服务端 Action 是否可用取决于所使用的框架和构建环境。React 本身不是后端,也不会自动提供数据库接口或 API 路由。
1. useActionState 的基本形态
在支持 React 19 表单 Action 的环境中,可以使用类似结构:
import { useActionState } from "react";
type ActionState = {
message: string;
fieldErrors?: {
email?: string;
password?: string;
};
};
const initialActionState: ActionState = {
message: "",
};
async function loginAction(
previousState: ActionState,
formData: FormData,
): Promise<ActionState> {
const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
const fieldErrors: ActionState["fieldErrors"] = {};
if (!email) {
fieldErrors.email = "请输入邮箱";
}
if (password.length < 8) {
fieldErrors.password = "密码至少需要 8 个字符";
}
if (Object.keys(fieldErrors).length > 0) {
return {
message: "请修正表单错误",
fieldErrors,
};
}
// 在实际项目中,这里应调用服务端能力或服务端 Action。
return {
message: "登录请求已提交",
};
}
export function ActionLoginForm() {
const [state, formAction, isPending] = useActionState(
loginAction,
initialActionState,
);
return (
<form action={formAction}>
<label>
邮箱
<input name="email" type="email" />
</label>
{state.fieldErrors?.email && (
<p role="alert">{state.fieldErrors.email}</p>
)}
<label>
密码
<input name="password" type="password" />
</label>
{state.fieldErrors?.password && (
<p role="alert">{state.fieldErrors.password}</p>
)}
{state.message && <p role="status">{state.message}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "提交中……" : "登录"}
</button>
</form>
);
}
useActionState 的 Action 接收两个参数:
(previousState, formData)
这与普通的 onSubmit(event) 不同:表单数据由 React 根据表单字段的 name 收集。没有 name 的控件不会按预期出现在 FormData 中。
2. useFormStatus 必须位于表单内部的子组件
useFormStatus 读取最近的父级 <form> 的提交状态,因此不能在同一个组件中、父级 <form> 之前直接读取它:
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "提交中……" : "提交"}
</button>
);
}
function Form() {
return (
<form action={someAction}>
{/* 其他字段 */}
<SubmitButton />
</form>
);
}
这里的组件关系是:
flowchart TD
Form --> NativeForm
NativeForm --> Input
NativeForm --> SubmitButton
SubmitButton --> useFormStatus
NativeForm --> Action
Action --> ActionState
ActionState --> Form
useFormStatus 负责读取最近一次表单提交的 pending 状态;useActionState 负责让 Action 返回的状态进入 React 渲染。二者解决的问题不同,不能把 useFormStatus 当作任意异步函数的全局 loading 状态。
3. Action 与普通 onSubmit 的取舍
普通 onSubmit 的特点:
- 请求过程完全显式;
- 容易接入任意 REST 或 GraphQL API;
- 可以精确控制取消、重试、请求序号;
- 需要自己维护 loading、错误和结果状态。
表单 Action 的特点:
- 与
<form action={...}>结合; - 可以自然使用
FormData; - React 负责部分提交状态连接;
- 服务端函数和部署框架之间存在额外约束;
- 不能假定所有 React 环境都支持同样的服务端调用方式。
如果表单只是调用现有 HTTP API,普通 onSubmit 往往更直观。如果框架明确支持 React 19 的服务端 Action,并且希望减少客户端提交胶水代码,可以考虑 Action。
十、文件上传、富文本和复杂控件的边界
1. 文件输入通常保持非受控
浏览器不允许脚本任意设置用户选择的本地文件路径,因此不要这样做:
<input type="file" value={fileName} />
应使用 ref 或在提交时读取:
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const file = formData.get("avatar");
if (file instanceof File && file.size > 0) {
// 上传文件
}
}
服务端仍要检查:
- 文件大小;
- MIME 类型;
- 文件内容,而不仅是扩展名;
- 文件名和路径;
- 存储位置;
- 病毒扫描和访问权限。
2. select 和多选值
单选:
<select
name="role"
value={role}
onChange={(event) => setRole(event.target.value)}
>
<option value="user">用户</option>
<option value="admin">管理员</option>
</select>
多选不能简单使用单字符串状态,应明确建模为数组:
const [roles, setRoles] = useState<string[]>([]);
<select
multiple
value={roles}
onChange={(event) => {
const nextRoles = Array.from(
event.target.selectedOptions,
(option) => option.value,
);
setRoles(nextRoles);
}}
>
<option value="reader">阅读</option>
<option value="writer">写作</option>
</select>
3. FormData 的值不是都为字符串
const value = formData.get("remember");
其返回类型是 FormDataEntryValue | null,也就是 string | File | null。复选框被选中时通常是其 value 属性,未选中时可能没有该键,不能直接写:
const remember = Boolean(formData.get("remember"));
因为字符串 "false" 和 "on" 都会被转换为 true。应明确判断:
const remember = formData.get("remember") === "on";
如果指定了:
<input type="checkbox" name="remember" value="yes" />
则判断:
const remember = formData.get("remember") === "yes";
十一、性能:受控输入为什么可能变慢
受控输入的每次击键都会产生一次状态更新:
keydown
→ input/change
→ setState 或 dispatch
→ 组件重新渲染
→ input value 更新
这不是错误,而是受控模型的必然成本。性能问题通常发生在状态所在组件同时负责大量工作,例如:
function LargePage() {
const [query, setQuery] = useState("");
// 每次输入都重新计算大型列表、图表、编辑器和多个面板
const result = expensiveSearch(query);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<HugeTable rows={result} />
<Chart />
<Editor />
</>
);
}
优化应从因果关系出发,而不是先把所有代码包进 memo。
1. 缩小状态作用域
将输入框拆到较小组件中:
function SearchBox() {
const [query, setQuery] = useState("");
return (
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
);
}
如果父组件不需要每次读取 query,就不要把 query 放在父组件中。
2. 使用 memo 但理解其条件
const ResultList = memo(function ResultList({
rows,
}: {
rows: string[];
}) {
return (
<ul>
{rows.map((row) => (
<li key={row}>{row}</li>
))}
</ul>
);
});
memo 只会对 props 做浅比较。每次都创建的新对象仍然会导致子组件更新:
<Panel options={{ dense: true }} />
可以将稳定配置移到组件外,或使用 useMemo。但 useMemo 是性能优化提示,不应作为业务正确性的依赖。
3. 区分输入状态和昂贵派生结果
useDeferredValue 可以让非关键的派生内容滞后:
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const results = useMemo(
() => expensiveSearch(deferredQuery),
[deferredQuery],
);
输入框仍然使用即时的 query:
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
这表达了两个不同的时间要求:
query:必须立即响应用户输入
deferredQuery:可以稍后更新列表
不要用 startTransition 或 useDeferredValue 延迟受控文本输入本身的 value。文本输入的控制值需要同步更新,否则可能出现输入延迟或光标行为异常。可以延迟结果列表、统计信息等非关键 UI。
4. 不要在每次输入时做不可取消的昂贵工作
常见问题包括:
- 每次击键都发送远程请求;
- 每次击键都进行大规模 JSON 转换;
- 每次击键都重新初始化第三方编辑器;
- 每次击键都重新计算整个表格。
应优先使用:
- 防抖或节流;
- 请求取消;
useMemo;- 组件拆分;
- 虚拟列表;
- 将字段注册和 DOM 读取交给非受控方案;
- 浏览器原生约束减少无意义逻辑。
5. 受控与非受控的性能取舍
大型动态表单中,如果每个字段都放在顶层 state,可能导致每次击键让整个表单重新渲染。此时可以:
- 将每个字段拆成独立组件;
- 让状态更靠近使用它的字段;
- 使用
useReducer统一复杂状态,但配合拆分; - 对不需要即时业务逻辑的字段使用非受控和
FormData; - 使用成熟表单库,但仍要理解其受控、订阅和校验模型。
非受控不必然更快,受控也不必然很慢。真正决定成本的是更新频率、渲染范围和每次渲染执行的工作量。
十二、常见失败表现与诊断路径
1. 输入框无法输入
失败代码:
<input value={email} />
这里提供了 value,却没有更新它的 onChange。React 每次渲染都会把旧值写回 DOM,因此用户看到输入被“锁住”。
诊断方法:
- 检查是否设置了
value; - 检查
onChange是否执行; - 检查
onChange是否更新同一个状态; - 检查状态是否被其他逻辑重置;
- 检查是否将异步请求结果错误地覆盖了当前输入。
2. 切换复选框无效
失败代码:
<input
type="checkbox"
value={remember}
onChange={(event) => setRemember(event.target.value)}
/>
复选框的选中状态由 checked 表示,value 只是提交时的值。正确代码必须读取:
event.target.checked
3. response.ok 漏检导致错误提示“成功”
失败代码:
const data = await fetch("/api/login").then((response) =>
response.json(),
);
dispatch({ type: "submit-success" });
服务端即使返回 401,代码也会把 JSON 当作成功数据。应先检查 HTTP 状态和业务字段:
const response = await fetch("/api/login");
const data = await response.json();
if (!response.ok || data.ok !== true) {
// 错误
}
HTTP 状态和业务成功标志是两个层次,具体协议应由接口契约明确规定。
4. 错误与字段内容不一致
如果错误请求晚于新请求返回,通常是竞态问题。诊断时记录:
输入版本
请求开始时间
请求序号
响应时间
响应状态
然后确认旧响应是否仍然调用了 setState。解决方式是取消旧请求、比较请求序号,或两者同时使用。
5. 服务端渲染后出现 hydration 警告
常见原因包括:
- 服务端初始值与客户端初始值不同;
- 使用当前时间、随机数生成默认值;
- 服务端认为字段为空,客户端首次渲染却使用了用户缓存值;
value与defaultValue混用。
受控表单应保证服务端和客户端首次渲染使用同一份初始数据,并将可能为空的值归一化:
value={serverValue ?? ""}
十三、生产取舍:何时选择哪种模型
选择受控组件
适合:
- 输入内容需要即时影响其他 UI;
- 字段之间有联动;
- 需要显示即时或失焦校验;
- 需要根据输入禁用、启用或切换控件;
- 需要精确控制错误和焦点。
选择非受控组件
适合:
- 字段很多,输入过程中不需要驱动其他 UI;
- 主要在提交时读取数据;
- 文件上传;
- 与原生表单或第三方 DOM 控件集成;
- 需要减少顶层状态更新范围。
选择普通 onSubmit + fetch
适合:
- 已有 API;
- 需要取消、重试、请求序号、超时等精细控制;
- 客户端负责较多交互状态;
- 不依赖特定框架的服务端 Action。
选择 React 19 表单 Action
适合:
- 运行环境明确支持相关能力;
- 表单提交天然以
FormData为中心; - 希望将 Action 返回状态连接到表单;
- 服务端函数边界由框架提供并且团队熟悉其部署模型。
无论使用哪种方案,都不能省略服务端校验、权限判断、错误分类和敏感信息保护。
十四、最终状态机
一个工程化表单可以抽象为以下状态机:
stateDiagram-v2
[*] --> Idle
Idle --> Editing: 用户修改字段
Editing --> Editing: 输入/失焦
Editing --> Invalid: 提交且客户端校验失败
Invalid --> Editing: 修正字段
Editing --> Submitting: 提交且校验通过
Submitting --> Success: HTTP 成功且业务成功
Submitting --> FieldError: 服务端返回字段错误
Submitting --> FormError: 服务端返回表单错误
Submitting --> NetworkError: 网络失败或超时
Submitting --> Editing: 请求被取消
FieldError --> Editing: 修改错误字段
FormError --> Editing: 修改字段或重试
NetworkError --> Submitting: 重试
Success --> [*]
这张图中的关键约束是:
也就是说,提交开始后,代码必须能够进入成功、业务错误、网络错误或取消路径,不能因为异常未捕获而永久保持 submitting = true。
同时:
客户端校验通过只允许请求发出,并不意味着业务操作成功。最终成功必须由服务端响应确认。
表单工程的核心不是“把输入值放进 state”,而是建立一条可验证的数据流:
字段值
→ 明确的状态模型
→ 分层校验
→ 可取消、可诊断的异步提交
→ 分类错误
→ 可访问的反馈
→ 受控的渲染成本
当这些边界被明确后,useState、useReducer、批处理、状态快照、表单 Action 和性能优化就不再是孤立 API,而会成为同一个表单状态系统中的不同工具。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React Ref 与 DOM:useRef、forwardRef、测量、焦点和命令式边界
- 下一篇:React Context 与 Reducer:跨层状态、更新边界和可测试设计
- 延伸:React State 与 Hooks:useState、useReducer、批处理和状态快照
- 延伸:React 可访问性:语义、键盘、焦点、ARIA、Portal 和动态内容
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论