React 基础体系 · 第 36/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React Hook Form:字段注册、Schema、动态表单和性能
React Hook Form(RHF)是 React 生态中的表单状态管理库。它的核心目标不是替代 React,而是把表单字段的值、校验状态、提交状态和错误状态组织起来,同时尽量避免“每输入一个字符就让整个表单组件重新渲染”。
标题中的四个概念分别对应四个层次:
- 字段注册:RHF 如何知道某个输入框属于表单,以及如何读取和更新它的值。
- Schema:如何用一个可组合的规则描述数据结构,并将其接入 RHF。
- 动态表单:字段数量、字段路径或字段类型运行时变化时,如何保证数据和 React 列表身份一致。
- 性能:哪些状态变化会触发哪些组件重新渲染,以及如何定位真正的性能问题。
一、先建立表单的状态模型
一个表单至少包含三类数据:
- 字段值:用户当前输入的原始值。
- 校验状态:字段是否有效、错误消息是什么、是否已经被触碰。
- 提交状态:是否正在提交、是否提交成功、服务端是否返回了错误。
可以把一次表单提交抽象为:
这里的 raw input 是浏览器输入产生的值,validated input 是通过 Schema 和业务规则后的数据,persisted result 才是服务端真正接受并写入数据库的数据。
客户端校验只能改善交互,不能替代服务端校验。原因是浏览器中的 JavaScript、请求内容和校验逻辑都可以被用户修改。
1. 字段值不等于 React state
在传统的受控组件中,数据流通常是:
const [email, setEmail] = useState("");
<input
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
每次输入的过程是:
- 浏览器产生
input事件。 onChange调用setEmail。- 组件重新渲染。
- React 将新的
value写回输入框。
RHF 常见的用法则是:
const { register } = useForm();
<input {...register("email")} />
此时输入框通常仍由 DOM 保持当前值,RHF 通过注册时附加的事件处理器和 ref 观察字段,而不是要求父组件保存每一次击键产生的值。
这就是 RHF 常被称为偏向 非受控表单 的原因。但“非受控”并不意味着没有状态。RHF 仍然维护字段值、错误、脏状态和提交状态,只是这些状态不必全部通过 React 组件的 useState 在每次输入时传播。
二、字段注册:register 到底做了什么
1. register 的返回值
下面的代码:
<input {...register("email")} />
可以概念性地理解为:
const fieldProps = register("email");
<input
name={fieldProps.name}
onChange={fieldProps.onChange}
onBlur={fieldProps.onBlur}
ref={fieldProps.ref}
/>
register("email") 返回的对象通常包含:
name:字段路径;onChange:通知 RHF 字段发生变化;onBlur:通知 RHF 字段失去焦点;ref:让 RHF 获取 DOM 节点、读取初始值或执行聚焦;- 其他由当前版本和配置决定的字段属性。
因此,下面这种写法通常是错误的:
<input {...register("email")} ref={someOtherRef} />
后面的 ref 会覆盖 register 提供的 ref,导致 RHF 可能无法正确追踪该字段。若必须合并 ref,需要显式合并两个 ref。
2. 字段名是数据路径
字段名不是仅用于 HTML 的字符串,它同时决定了提交数据的结构。
<input {...register("user.name")} />
<input {...register("user.email")} />
提交结果可以是:
{
user: {
name: "Ada",
email: "ada@example.com"
}
}
数组路径也可以直接表达嵌套数据:
<input {...register("items.0.sku")} />
<input {...register("items.1.sku")} />
对应的数据形状类似:
{
items: [
{ sku: "A-001" },
{ sku: "B-002" }
]
}
字段路径必须与类型和 Schema 的结构一致。比如 Schema 期望 items[].sku,却注册成 products[].code,客户端校验即使本身正确,也不会命中预期字段。
3. defaultValues 是初始快照
推荐从 useForm 提供初始值:
type ProfileForm = {
name: string;
age: number;
};
const form = useForm<ProfileForm>({
defaultValues: {
name: "",
age: 0,
},
});
defaultValues 的作用包括:
- 初始化未填写字段;
- 参与
isDirty和dirtyFields的比较; - 避免输入框从
undefined突然变成字符串; - 为重置操作提供基准值。
一个常见误区是以为 defaultValues 会随着 React props 自动更新:
function ProfileEditor({ profile }: { profile: Profile }) {
const form = useForm({
defaultValues: profile,
});
// profile 后续发生变化时,defaultValues 不会自动作为新值应用
}
如果数据是异步加载的,应在加载完成后显式重置:
const form = useForm<ProfileForm>({
defaultValues: {
name: "",
age: 0,
},
});
useEffect(() => {
if (profile) {
form.reset(profile);
}
}, [profile, form]);
也可以使用 RHF 支持的异步初始值能力,但实际项目仍应明确数据加载完成后的重置语义,尤其是要决定:加载新数据时是否覆盖用户已经修改过的字段。
4. HTML 输入值与 TypeScript 类型不自动一致
原生 <input> 的值通常先以字符串形式产生:
<input type="number" {...register("age")} />
如果没有额外配置,age 可能仍然以字符串 "18" 进入表单数据,而不是数字 18。
可以使用转换选项:
<input
type="number"
{...register("age", {
valueAsNumber: true,
min: {
value: 0,
message: "年龄不能小于 0",
},
})}
/>
但要注意,空的 number input 可能被转换为 NaN。Schema 和服务端必须明确如何处理空值,而不能仅根据 TypeScript 的 number 判断运行时一定是有效数字。
对日期也类似:
<input
type="date"
{...register("birthday", {
valueAsDate: true,
})}
/>
此时得到的可能是 Date 或无效日期值,服务端序列化前仍需检查。
三、字段级规则与 Schema 校验
RHF 自带字段级校验规则:
<input
{...register("username", {
required: "用户名不能为空",
minLength: {
value: 3,
message: "用户名至少 3 个字符",
},
validate: async (value) => {
const available = await checkUsername(value);
return available || "用户名已经被占用";
},
})}
/>
这些规则适合简单字段校验。但当表单有嵌套对象、数组、字段间关系或需要复用同一套规则时,单独把规则散落在 JSX 中会变得难以维护。
1. Schema 的定义
Schema 是对数据形状和约束的声明。例如使用 Zod:
import { z } from "zod";
export const profileSchema = z.object({
name: z
.string()
.trim()
.min(1, "姓名不能为空")
.max(50, "姓名不能超过 50 个字符"),
email: z
.string()
.trim()
.email("邮箱格式不正确"),
age: z
.number()
.int("年龄必须是整数")
.min(18, "年龄必须年满 18 岁"),
});
export type ProfileInput = z.infer<typeof profileSchema>;
这里的 Schema 同时描述:
- 顶层数据必须是对象;
name必须是字符串,去除首尾空格后不能为空;email必须符合邮箱格式;age必须是整数且不小于 18。
z.infer<typeof profileSchema> 从 Schema 推导 TypeScript 类型,减少了“类型声明一份、校验规则再写一份”造成的不一致。
不过,TypeScript 类型只在编译阶段提供帮助,Schema 才是在运行时真正检查数据。
2. 将 Schema 接入 RHF
以 @hookform/resolvers 和 Zod 为例:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const form = useForm<ProfileInput>({
resolver: zodResolver(profileSchema),
defaultValues: {
name: "",
email: "",
age: 18,
},
mode: "onBlur",
});
resolver 是 RHF 与外部校验库之间的适配器。提交或触发校验时,流程大致是:
- RHF 收集当前字段值;
- resolver 调用 Schema;
- Schema 返回成功数据或错误集合;
- resolver 将错误转换成 RHF 的
formState.errors; - RHF 根据错误路径渲染错误消息。
mode: "onBlur" 表示字段失去焦点后开始校验。常见模式包括:
onSubmit:提交时校验,交互更安静;onBlur:离开字段时校验;onChange:每次变更都校验,反馈及时但可能产生更多计算和渲染;all:同时响应提交、失焦和变更等事件。
模式不是单纯的性能开关,它还决定用户何时看到错误。长文本或复杂 Schema 使用 onChange 时,可能在用户尚未完成输入前显示中间态错误。
3. 完整的字段级错误渲染
function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ProfileInput>({
resolver: zodResolver(profileSchema),
defaultValues: {
name: "",
email: "",
age: 18,
},
mode: "onBlur",
});
const onSubmit = async (data: ProfileInput) => {
const response = await fetch("/api/profile", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error("保存失败");
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="name">姓名</label>
<input
id="name"
{...register("name")}
aria-invalid={errors.name ? "true" : "false"}
aria-describedby={errors.name ? "name-error" : undefined}
/>
{errors.name && (
<p id="name-error" role="alert">
{errors.name.message}
</p>
)}
</div>
<div>
<label htmlFor="email">邮箱</label>
<input
id="email"
type="email"
{...register("email")}
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}
</div>
<div>
<label htmlFor="age">年龄</label>
<input
id="age"
type="number"
{...register("age", { valueAsNumber: true })}
aria-invalid={errors.age ? "true" : "false"}
aria-describedby={errors.age ? "age-error" : undefined}
/>
{errors.age && (
<p id="age-error" role="alert">
{errors.age.message}
</p>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "保存中…" : "保存"}
</button>
</form>
);
}
handleSubmit(onSubmit) 不只是一个事件包装器。它会先运行校验:
- 校验失败:不调用
onSubmit,错误写入formState.errors; - 校验成功:将解析后的数据传给
onSubmit; isSubmitting在异步提交期间用于阻止重复提交或显示状态。
需要捕获网络层异常:
const onSubmit = async (data: ProfileInput) => {
try {
const response = await fetch("/api/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
// 显示全局错误、上报日志或更新服务端错误状态
}
};
RHF 能处理客户端校验状态,但不会自动把所有网络异常变成用户可见的页面消息。
四、跨字段校验:Schema 不是只有单字段规则
很多约束无法通过单个字段独立判断,例如:
- 密码和确认密码必须相同;
- 开始日期不能晚于结束日期;
- 当
type为"company"时,taxId必填。
以密码确认作为例子:
const registrationSchema = z
.object({
password: z.string().min(8, "密码至少 8 位"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "两次输入的密码不一致",
});
这里的关键是 path: ["confirmPassword"]。跨字段条件是在对象层面判断的,但错误被定位到具体字段,因此 RHF 可以在确认密码输入框旁边显示消息。
如果省略路径,错误可能成为对象级错误,UI 需要自己处理:
const {
formState: { errors },
} = useForm<RegistrationInput>({
resolver: zodResolver(registrationSchema),
});
跨字段校验的因果链是:
- 单个字段值变化;
- Schema 需要读取同一对象中的其他字段;
- resolver 重新校验相关数据;
- 错误路径决定错误最终挂在哪个字段或表单根节点;
- UI 根据路径渲染消息。
因此,不能把所有跨字段规则都写成单字段的 validate,否则规则可能无法正确获取同级字段,也容易形成重复逻辑。
五、客户端 Schema 与服务端 Schema 的边界
在现代 React 应用中,组件可能运行在:
- 浏览器客户端组件;
- 服务端渲染环境;
- 服务端路由或 API handler;
- React Server Components 与客户端组件的组合边界。
RHF 主要用于浏览器交互,因此调用 useForm 的组件通常应位于客户端边界。以支持客户端指令的框架为例,使用浏览器事件和 Hook 的组件需要放在相应的客户端文件或客户端入口中。
但服务端仍必须重新验证请求:
// 服务端 handler 的概念性示例
export async function POST(request: Request) {
const body: unknown = await request.json();
const result = profileSchema.safeParse(body);
if (!result.success) {
return Response.json(
{
message: "输入数据无效",
issues: result.error.issues,
},
{ status: 422 },
);
}
// result.data 是通过运行时 Schema 校验后的数据
// 之后还要进行权限检查、唯一性检查和数据库操作
return Response.json({ ok: true });
}
这里不能直接信任客户端传来的 JSON,也不能因为客户端已经运行过相同 Schema 就跳过服务端校验。
更准确的边界是:
客户端:
用户体验、即时反馈、减少无效请求
服务端:
安全边界、权限、业务真实性、并发一致性、最终数据校验
同一份 Schema 可以在客户端和服务端共享,但并不意味着所有规则都应共享。例如“用户名是否已占用”依赖数据库,不能只写成纯本地 Schema 规则。
六、将服务端字段错误映射回 RHF
服务端可能返回唯一性冲突或业务校验错误:
{
"message": "保存失败",
"fieldErrors": {
"email": "该邮箱已注册"
}
}
客户端可使用 setError:
const {
setError,
setFocus,
handleSubmit,
} = useForm<ProfileInput>({
resolver: zodResolver(profileSchema),
});
const onSubmit = async (data: ProfileInput) => {
const response = await fetch("/api/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (response.ok) {
return;
}
if (response.status === 422) {
const body: {
fieldErrors?: Partial<Record<keyof ProfileInput, string>>;
} = await response.json();
for (const [field, message] of Object.entries(
body.fieldErrors ?? {},
)) {
if (message) {
setError(field as keyof ProfileInput, {
type: "server",
message,
});
}
}
setFocus("email");
return;
}
throw new Error("服务器暂时不可用");
};
如果错误属于整个表单,而不是某一个字段,可以使用根错误:
setError("root.server", {
type: "server",
message: "保存失败,请稍后重试",
});
然后渲染:
{errors.root?.server && (
<p role="alert">{errors.root.server.message}</p>
)}
服务端错误映射的关键是保证路径协议一致。前端注册的是 items.0.sku,服务端却返回 items[0].sku,就需要在边界层统一路径格式,否则错误无法准确显示。
七、动态表单:为什么需要 useFieldArray
动态表单是指字段数量或字段结构运行时变化,例如:
- 订单中的商品行可以添加和删除;
- 联系人列表可以重新排序;
- 问卷题目由服务端配置;
- 根据选择的类型显示不同字段。
简单地用 useState 保存数组也能完成 UI,但如果同时让 RHF 管理每个字段,必须解决两个问题:
- 数组项的字段路径如何变化;
- React 列表中的组件身份如何保持稳定。
useFieldArray 专门处理这两个问题。
1. 一个完整的订单明细示例
import { useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const orderSchema = z.object({
customer: z.string().min(1, "客户名称不能为空"),
items: z
.array(
z.object({
sku: z.string().min(1, "SKU 不能为空"),
quantity: z
.number()
.int("数量必须是整数")
.min(1, "数量至少为 1"),
}),
)
.min(1, "至少添加一件商品"),
});
type OrderInput = z.infer<typeof orderSchema>;
export function OrderForm() {
const {
register,
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<OrderInput>({
resolver: zodResolver(orderSchema),
defaultValues: {
customer: "",
items: [
{
sku: "",
quantity: 1,
},
],
},
});
const { fields, append, remove, move } = useFieldArray({
control,
name: "items",
});
const onSubmit = async (data: OrderInput) => {
const response = await fetch("/api/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error("订单提交失败");
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="customer">客户名称</label>
<input id="customer" {...register("customer")} />
{errors.customer && (
<p role="alert">{errors.customer.message}</p>
)}
<h2>商品</h2>
{fields.map((field, index) => (
<div key={field.id}>
<label htmlFor={`items-${index}-sku`}>SKU</label>
<input
id={`items-${index}-sku`}
{...register(`items.${index}.sku`)}
/>
{errors.items?.[index]?.sku && (
<p role="alert">
{errors.items[index]?.sku?.message}
</p>
)}
<label htmlFor={`items-${index}-quantity`}>数量</label>
<input
id={`items-${index}-quantity`}
type="number"
{...register(`items.${index}.quantity`, {
valueAsNumber: true,
})}
/>
{errors.items?.[index]?.quantity && (
<p role="alert">
{errors.items[index]?.quantity?.message}
</p>
)}
<button
type="button"
onClick={() => remove(index)}
>
删除
</button>
{index > 0 && (
<button
type="button"
onClick={() => move(index, index - 1)}
>
上移
</button>
)}
</div>
))}
{errors.items?.root && (
<p role="alert">{errors.items.root.message}</p>
)}
<button
type="button"
onClick={() => append({ sku: "", quantity: 1 })}
>
添加商品
</button>
<button type="submit" disabled={isSubmitting}>
提交订单
</button>
</form>
);
}
这个例子中的数据流是:
fields描述当前数组行以及 RHF 为每行生成的稳定标识;fields.map负责渲染行;register(\items.${index}.sku`)` 将输入映射到数组路径;append增加完整的一行;remove删除一行并更新索引路径;move调整数组顺序;- Schema 对整个
items数组进行逐项和整体校验。
2. 为什么 key 必须使用 field.id
错误写法:
{fields.map((field, index) => (
<div key={index}>
{/* fields */}
</div>
))}
当删除中间项时,React 会把后面的组件复用到前一个位置。例如原始行是:
A(index 0), B(index 1), C(index 2)
删除 A 后,数组变成:
B(index 0), C(index 1)
使用 key={index} 时,React 可能认为原 index 0 的组件仍然是“同一个组件”,只是数据换了。这在输入框、焦点、动画和本地组件状态上容易产生错位。
field.id 表示数组项的稳定身份:
{fields.map((field, index) => (
<div key={field.id}>
...
</div>
))}
索引仍然用于字段路径,因为当前数据路径是 items.0.sku、items.1.sku;但 React key 使用的是项身份。两者解决的是不同问题。
3. append、remove 与完整对象
数组操作应尽量传入完整对象:
append({
sku: "",
quantity: 1,
});
不要依赖一个结构不完整的对象:
append({ sku: "" } as OrderInput["items"][number]);
后者绕过了 TypeScript 的保护,运行时可能产生 undefined,使 Schema、默认值和 UI 三者不一致。
4. 更新单行时的行为
如果使用 update(index, value),某些情况下整行组件会被卸载并重新挂载。若只需要更新一个字段,可以使用:
setValue(`items.${index}.quantity`, 10, {
shouldDirty: true,
shouldValidate: true,
});
选择原则是:
- 整行结构发生替换:使用
update; - 单个字段值变化:使用
setValue; - 数组整体替换:使用当前 RHF 版本提供的数组操作,确认是否会导致整组重建;
- 需要保留局部组件状态或焦点:避免不必要的卸载重挂载。
5. shouldUnregister 的取舍
条件渲染会带来一个问题:字段从 DOM 中移除后,值是否还应保留?
const form = useForm({
shouldUnregister: false,
});
常见语义是:
- 保留注册过但暂时未显示的字段值;
- 适合步骤表单或条件区域切换;
- 但提交时可能包含当前界面不可见的旧值。
若设置为 true,字段卸载时会从表单状态移除:
- 更接近原生表单字段存在即提交的直觉;
- 条件字段不会残留旧数据;
- 但步骤切换后可能需要重新恢复用户输入。
这不是纯技术优劣,而是数据语义选择:隐藏字段是“暂时不可见但仍属于草稿”,还是“已经不属于当前提交模型”。
八、动态字段类型与 Controller
并不是所有组件都能直接使用 register。原生 <input>、<select>、<textarea> 通常可以直接注册;但第三方选择器、日期选择器、富文本编辑器常常不是标准的 value/onChange 接口。
这时使用 Controller:
import { Controller, useForm } from "react-hook-form";
type FormValues = {
country: string;
};
function CountrySelect() {
const { control } = useForm<FormValues>({
defaultValues: {
country: "",
},
});
return (
<Controller
name="country"
control={control}
render={({ field, fieldState }) => (
<div>
<CustomSelect
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
/>
{fieldState.error && (
<p role="alert">{fieldState.error.message}</p>
)}
</div>
)}
/>
);
}
Controller 的作用是把受控组件的接口适配为 RHF 字段接口:
第三方组件的 value
↓
Controller field.value
第三方组件的 onChange
↓
Controller field.onChange
不要对同一个字段同时使用 register 和 Controller:
// 错误倾向:同一字段被两套注册逻辑管理
<Controller name="country" control={control} ... />
<input {...register("country")} />
这会造成重复注册、值来源不明确或事件冲突。
Controller 并不代表整个表单必须变成受控模式。它只让特定字段使用受控适配,原生字段仍可以继续使用 register。
九、性能:RHF 为什么通常能减少渲染
性能问题要先明确“谁在重新渲染”。一个组件重新渲染不等于浏览器一定完成了昂贵的 DOM 更新;反过来,少量 React 渲染也可能触发昂贵的计算或第三方组件更新。
RHF 的性能思路可以抽象为:
如果一个组件订阅整个表单,就可能因任意字段变化而重新渲染;如果它只订阅 email 的错误,就可以把更新范围限制在该字段。
1. watch 的订阅范围
const value = watch();
订阅整个表单,任何字段变化都可能让当前组件重新渲染。
const email = watch("email");
只读取一个字段,范围更窄。
但在大型表单中,更推荐使用 useWatch 将订阅隔离到专门的子组件:
import { useWatch, Control } from "react-hook-form";
function OrderSummary({
control,
}: {
control: Control<OrderInput>;
}) {
const items = useWatch({
control,
name: "items",
});
const totalRows = items?.length ?? 0;
return <p>当前有 {totalRows} 件商品</p>;
}
这样,汇总组件明确订阅 items,而不是让整个表单父组件读取所有字段。
2. useFormState 的局部订阅
import { useFormState, Control } from "react-hook-form";
function SubmitStatus({
control,
}: {
control: Control<OrderInput>;
}) {
const { isSubmitting, isDirty } = useFormState({ control });
return (
<button type="submit" disabled={isSubmitting || !isDirty}>
{isSubmitting ? "提交中…" : "提交"}
</button>
);
}
useFormState 让组件声明自己关心哪些表单状态。把错误、提交状态和字段渲染拆分到子组件后,输入一个字段通常不需要让整棵表单树都更新。
3. 精确读取错误
在子组件中:
function EmailField({
register,
error,
}: {
register: ReturnType<typeof useForm<ProfileInput>>["register"];
error?: { message?: string };
}) {
return (
<div>
<input {...register("email")} />
{error?.message && <p role="alert">{error.message}</p>}
</div>
);
}
父组件可以只把 errors.email 传下来,而不是把整个 formState 传给所有字段组件。
需要注意,RHF 的 formState 具有代理和订阅语义。常见写法是直接在组件渲染期间解构需要的属性:
const {
formState: { isSubmitting, errors },
} = useForm();
不要把整个 formState 当作无差别的全局状态传遍组件树。
4. 受控组件的成本
受控组件的每次输入都需要:
- 触发 React 状态更新;
- 重新执行相关组件函数;
- 重新协调子树;
- 可能触发第三方组件内部逻辑。
这不意味着受控组件一定慢,也不意味着 Controller 一定有问题。性能取决于:
- 字段数量;
- 单字段渲染成本;
- 是否有复杂计算;
- 是否订阅整个表单;
- 第三方组件是否本身昂贵;
- 是否在输入事件中执行网络请求或大型 Schema 计算。
因此,register 通常是原生字段的低开销路径,Controller 是必要时的适配路径;不能把它们简单理解为“一个正确、一个错误”。
十、性能诊断应从测量开始
当表单感觉卡顿时,先确认问题属于哪一类:
1. React 重新渲染过多
使用 React DevTools Profiler 观察:
- 输入单个字符时,哪些组件重新渲染;
- 是否整个表单都被重新渲染;
- 哪个组件提交了最长的渲染时间;
- 是否因为
watch()读取了整个表单。
典型问题:
function Form() {
const allValues = watch();
return (
<>
<ExpensivePreview values={allValues} />
{/* 大量字段 */}
</>
);
}
改为拆分订阅:
function Preview({
control,
}: {
control: Control<FormValues>;
}) {
const values = useWatch({ control });
return <ExpensivePreview values={values} />;
}
如果预览只依赖部分字段:
const values = useWatch({
control,
name: ["title", "quantity"],
});
2. 校验计算过重
如果 Schema 含有大量数组、复杂正则或同步计算,onChange 校验会在输入期间反复运行。可考虑:
- 将主要反馈放在
onBlur或onSubmit; - 将昂贵的派生预览从输入组件中拆出;
- 只对当前步骤或当前数组项触发校验;
- 将异步唯一性检查做去抖,但不能把去抖结果当作服务端最终判断。
3. 组件卸载和重新挂载
动态数组中使用错误 key、频繁 update 整行或条件渲染结构变化,可能造成:
- 输入焦点丢失;
- 本地组件状态重置;
- 第三方选择器重新初始化;
- 用户看到输入值闪烁或错位。
此时应查看 React key、useFieldArray 操作方式和条件字段的注册策略,而不是首先增加 memo。
4. 无效的 memo 化
如果父组件每次都创建新的大对象或函数,子组件即使使用 memo 也可能继续更新:
<Preview config={{ currency: "CNY" }} />
可以稳定引用:
const config = useMemo(
() => ({ currency: "CNY" }),
[],
);
但 memo、useMemo 和 useCallback 应用于已经测量出的瓶颈。它们会增加依赖维护成本,不能替代合理的 RHF 订阅边界。
十一、一个动态、分区且有服务端错误的结构
大型表单可以按功能拆分,但所有字段需要共享同一个 RHF 上下文:
import { FormProvider, useFormContext } from "react-hook-form";
function CustomerSection() {
const {
register,
formState: { errors },
} = useFormContext<OrderInput>();
return (
<section>
<label htmlFor="customer">客户名称</label>
<input id="customer" {...register("customer")} />
{errors.customer && (
<p role="alert">{errors.customer.message}</p>
)}
</section>
);
}
function OrderPage() {
const methods = useForm<OrderInput>({
resolver: zodResolver(orderSchema),
defaultValues: {
customer: "",
items: [{ sku: "", quantity: 1 }],
},
});
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(async (data) => {
// 提交逻辑
})}>
<CustomerSection />
{/* 其他字段区域 */}
<button type="submit">提交</button>
</form>
</FormProvider>
);
}
FormProvider 通过 React Context 提供表单方法,子组件用 useFormContext 获取。它解决的是组件层级传递问题,但不是性能隔离的自动保证。
如果子组件这样写:
const { watch } = useFormContext();
const values = watch();
它仍可能订阅整个表单。上下文解决“如何访问”,useWatch 和 useFormState 才解决“订阅多大范围”。
十二、常见失败表现与诊断路径
1. 错误提示一直不出现
检查顺序:
- 输入是否真的使用了
{...register("field")}; name是否与 Schema 路径完全一致;resolver是否传入当前useForm;mode是否导致校验只在提交或失焦时执行;- 是否误用了
errors.field?.message,但错误实际位于嵌套路径; - 是否把
ref覆盖,导致字段没有正确注册。
例如 Schema 是:
z.object({
user: z.object({
email: z.string().email(),
}),
});
UI 应读取:
errors.user?.email?.message
而不是:
errors.email?.message
2. 数字校验提示“必须是数字”
代码:
<input type="number" {...register("age")} />
可能提交字符串 "20"。如果 Schema 使用:
age: z.number()
就会失败。
修复方式之一:
<input
type="number"
{...register("age", { valueAsNumber: true })}
/>
或者让 Schema 明确负责预处理:
const schema = z.object({
age: z.coerce.number().int().min(0),
});
两种方式的选择要统一整个项目的数据边界。若同时做多层转换,应检查空值、NaN 和非法字符串的处理结果。
3. 删除动态行后值或错误错位
首先检查:
key={field.id}
而不是:
key={index}
其次确认字段路径使用当前索引:
register(`items.${index}.sku`)
最后检查是否在删除后手动维护了一份与 RHF 数组重复的 React state。两份数组状态如果更新时序不同,就会出现 UI 数量与提交数据不一致。
4. 异步加载数据后表单仍是空的
defaultValues 通常只用于初始化。数据加载完成后需要:
reset(loadedData);
同时决定是否保留用户修改:
reset(loadedData, {
keepDirtyValues: true,
});
这类选项的具体可用性和语义应以当前 RHF 版本文档为准;核心问题始终是:服务端新数据到达时,哪些字段拥有覆盖权限。
5. 提交按钮一直可点击并导致重复请求
提交状态应来自 RHF:
const {
formState: { isSubmitting },
} = useForm();
<button disabled={isSubmitting}>提交</button>
但这只控制同一个表单实例的客户端提交过程。服务端仍应考虑重复请求、幂等键、数据库唯一约束和网络重试。
6. 服务端返回错误但客户端没有定位到字段
检查服务端错误路径和前端注册路径是否一致:
前端:items.0.sku
服务端:items[0].sku
如果协议不一致,应在 API 边界统一格式,而不是让每个字段组件自己猜测错误路径。
十三、RHF 与 React 19 的关系
React 19 提供了新的 React 表单相关能力和服务端交互模型,但 RHF 仍然有自己的职责:
- React 原生能力可以处理某些原生表单提交和服务端动作场景;
- RHF 提供字段注册、字段级状态、Schema resolver、动态数组和细粒度订阅;
- 两者可以组合,但不是同一套状态系统。
如果使用 React Server Components 或服务端动作,必须明确:
- 哪些代码在服务端执行;
- 哪些组件需要浏览器事件,因此必须是客户端组件;
- 表单数据在哪一侧进行第一次校验;
- 服务端最终是否再次校验;
- 服务端错误如何传回客户端字段。
不能因为使用了 React 19 的服务端能力,就认为浏览器中的 RHF 规则能够自动成为服务端安全边界;也不能因为用了 RHF,就自动获得 React 服务端动作的能力。具体框架对客户端入口、服务端路由和动作函数的约束也可能不同,应按框架当前版本的边界执行。
十四、如何选择字段注册、Schema 和动态数组
可以用以下因果关系进行选择:
原生字段
<input {...register("name")} />
适合标准 HTML 控件,代码短,通常有较小的更新范围。
第三方受控组件
<Controller
name="country"
control={control}
render={({ field }) => (
<CustomSelect {...field} />
)}
/>
适合值和事件接口不是原生形式的组件。
简单校验
register("name", {
required: "必填",
minLength: { value: 3, message: "太短" },
});
适合局部、简单且不需要跨字段复用的规则。
结构化校验
useForm({
resolver: zodResolver(schema),
});
适合嵌套对象、数组、跨字段关系和客户端/服务端共享规则。
动态数组
useFieldArray({
control,
name: "items",
});
适合数组项的增删、移动和稳定身份管理。
性能隔离
useWatch({ control, name: "items" });
useFormState({ control, name: "items" });
适合将订阅范围限制在实际需要的字段或状态上。
十五、最终的数据流检查
一个可靠的 RHF 表单,应能清楚回答以下问题:
输入框由谁持有当前值?
↓
register 还是 Controller?
↓
字段路径是什么?
↓
值是否需要从字符串转换为数字、日期或其他类型?
↓
Schema 是否能验证实际运行时的数据?
↓
错误路径能否映射到具体控件?
↓
动态数组使用了什么稳定 key?
↓
字段卸载后值是否保留?
↓
客户端通过后,服务端是否重新验证?
↓
服务端字段错误如何回填?
↓
哪些组件订阅了哪些表单状态?
其中任何一环不明确,都可能出现“界面看起来正常,但提交数据错误”“删除一行后字段错位”“Schema 类型正确却在运行时收到字符串”或“输入一个字符导致整个页面卡顿”等问题。
RHF 的核心不是某个单独的 Hook,而是一套数据流设计:用字段注册连接 DOM 或受控组件,用 Schema 明确运行时约束,用 useFieldArray 管理动态身份,用局部订阅控制更新范围,再把客户端交互与服务端最终校验分开。只有这些部分同时保持一致,表单才既能正确工作,也能在复杂场景下保持可诊断和可维护。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 表单与 Action:原生提交、状态、乐观更新和错误
- 下一篇:React 文件上传:拖拽、分片、进度、取消、重试和预览
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论