《TypeScript编程实战》13.3 复杂表单与动态字段

字段能增删、能嵌套、能按另一个字段的值条件出现,是表单真正开始变复杂的三个信号。本节用 useFieldArray 处理动态行,讲清 key 为什么必须用 RHF 生成的 id 而非数组下标;再用判别联合 schema 表达条件字段、用 superRefine 做跨行校验。同时实测数组级错误与行级错误在 errors 树上的不同落点,并给出分步表单的 trigger 校验与草稿持久化方案。

本节目标:掌握用 useFieldArray 管理可增删的字段行,理解 React key 与 RHF 内部字段标识的关系;学会用判别联合 schema 表达「选了 A 才出现 B」的条件字段,用 superRefine 写跨行校验;并弄清数组级错误与行级错误在 errors 树上的不同落点,以及分步表单如何只校验当前步。

13.3 复杂表单与动态字段

到这一节,表单开始脱离「一组固定输入框」的形态。订单明细可以加行删行、地址可以嵌在客户对象里、支付方式选「银行卡」时才需要卡号——这些结构一旦出现,静态的字段列表就不再够用,路径也从字符串常量变成了需要推导的模板类型。

13.3.1 动态字段要解决的三件事

问题表现本节工具
行数不固定用户可以加行、删行、拖拽换序useFieldArray
结构随取值变化选了某个选项才出现一组字段判别联合 schema
校验依赖其它字段名称不能重复、总额必须等于明细之和superRefine

这三件事的共同点是:字段集合本身是运行时的。而 TypeScript 的类型是静态的,所以整个方案的关键在于——用「一个数组字段」和「一个判别联合字段」这两把静态的钥匙,去开动态的锁。

13.3.2 useFieldArray 的基本用法

先定义 schema。动态行在 schema 里就是一个普通的数组:

import { z } from 'zod';
const itemSchema = z.object({
  name: z.string().min(1, '名称必填'),
  qty: z.coerce.number().int('数量必须是整数').min(1, '数量至少为 1'),
});
export const orderSchema = z.object({
  items: z.array(itemSchema).min(1, '至少添加一行明细'),
});
type S = typeof orderSchema;

表单侧用 useFieldArray 接管这个数组:

import { useFieldArray, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
export function OrderForm() {
  const { register, control, handleSubmit } = useForm<z.input<S>, any, z.output<S>>({
    resolver: zodResolver(orderSchema),
    defaultValues: { items: [{ name: '', qty: 1 }] },
  });
  const { fields, append, remove } = useFieldArray({ control, name: 'items' });
  return (
    <form onSubmit={handleSubmit((v) => api.save(v))}>
      {fields.map((field, index) => (
        <fieldset key={field.id}>
          <legend>第 {index + 1} 行</legend>
          <input {...register(`items.${index}.name`)} />
          <input type="number" {...register(`items.${index}.qty`)} />
          <button type="button" onClick={() => remove(index)}>删除</button>
        </fieldset>
      ))}
      <button type="button" onClick={() => append({ name: '', qty: 1 })}>添加一行</button>
      <button>提交</button>
    </form>
  );
}

三个细节值得停一下:

  1. name: 'items' 必须与 schema 里的数组字段逐字一致,写错不会报错,只是 fields 永远是空数组。
  2. defaultValues.items 至少要给一行。不给的话 fields 初始为空、.min(1) 的错误立刻挂在根上,用户看到的是一片空白加一条报错。
  3. register 的路径用模板字符串拼下标,FieldPath 里已经预置了 `items.${number}.name` 这样的模板类型,所以不用手写类型断言(老版本 TS 上可能需要补一个 as const)。

13.3.3 稳定 key:为什么不能用 index

上面代码里 key={field.id},id 是 RHF 给每一行生成的稳定标识。如果按 React 的惯性写成 key={index},会踩到一个很难查的 bug:

// 反例:删除第 0 行后,原本第 1 行的 DOM 节点被复用为第 0 行
{fields.map((field, index) => <input key={index} {...register(`items.${index}.name`)} />)}

原因在于 RHF 用 ref 把 DOM 节点与「字段路径」绑定在一起。删掉第 0 行后,register('items.0.name') 的 ref 换了一个 DOM 节点,而 React 因为 key 没变,复用了原来的节点——输入框里显示的文本和它实际归属的字段就对不上了。用户会看到「删掉第一行,第二行的内容跳上来了」这类诡异现象。

useFieldArray 返回的每个 field 都带 id(可以用 keyName 改名字):

const { fields } = useFieldArray({ control, name: 'items', keyName: 'fieldId' });
// fields[i].fieldId 是稳定 id,用它做 key

一条纪律:fields 里的 id 只用来做 key,不要提交给后端。它不是表单值,handleSubmit 拿到的 values 里没有它。

13.3.4 增删改与常用方法

useFieldArray 返回的方法相当完整,日常用到的有:

方法作用注意
append(value)末尾追加可传数组一次加多行
prepend(value)头部插入会让所有下标后移
insert(index, value)指定位置插入
remove(index)删除不传参则清空全部
swap(a, b)交换两行保留下标,只换值
move(from, to)移动一行拖拽排序用这个
update(index, value)替换整行会重置该行所有字段
replace(values)整体替换服务端回填时用

swap 与 move 的区别值得留意:swap 只交换两个位置的值,move 是「把某行插到另一处、其余顺移」。做拖拽排序时用 move,做「上移/下移」按钮时两者都行。

remove 之后不要手动去改 defaultValues 或调用 reset,那会把用户其它字段的输入一并清掉。useFieldArray 已经同步好了内部状态。

13.3.5 嵌套数组与路径

数组可以嵌套:明细行里再放一个「子项」数组。此时 useFieldArray 的 name 写完整路径:

const sub = useFieldArray({ control, name: `items.${index}.subitems` });

对应的 schema 也是嵌套的,错误路径随之变长:

z.object({
  items: z.array(z.object({
    name: z.string(),
    subitems: z.array(z.object({ label: z.string().min(1, '子项名必填') })),
  })),
});
// 错误路径:items.0.subitems.2.label

路径一深,读取错误就不能再手写点号串了。前面 13.2 介绍过的 get 在这里是必需品:

import { get, type FieldErrors } from 'react-hook-form';
const msg = get(errors, `items.${index}.subitems.${si}.label`)?.message;

get 会沿着路径逐层做可选链,任何一层缺失都安全返回 undefined,不会像 errors.items[0].subitems[2].label.message 那样在中间断掉时抛异常。

13.3.6 数组级错误与行级错误的落点

这是动态字段里最容易踩的一处。z.array(itemSchema).min(1) 这条规则针对的是数组本身,它的 issue.path 是 ['items'];而某一行的错误路径是 ['items', 0, 'name']。两者在 errors 树上的形状不一样。

实测 zodResolver + RHF 7 的三种情形:

情形errors.items 的实际结构读取方式
只有数组级错误{ type, message }errors.items?.message
只有行级错误[{ name: { type, message } }]errors.items?.[0]?.name?.message
两者同时存在{ 0: {...}, root: { type, message } }行级照旧,数组级用 errors.items?.root?.message

也就是说,数组级错误的落点会随行级错误是否存在而变化:没有行级错误时它直接挂在 items 上,一旦有行级错误,它就被挪到 items.root。原因是 resolvers 会先判断该字段是不是「数组字段」(是否存在形如 items.0 的键),是的话就把数组级错误放进 root。

这种「同一个错误有两种读法」的情况,不要在每个组件里各写一遍 if。抽一个函数兜住:

import { get, type FieldErrors, type FieldValues } from 'react-hook-form';
export function arrayRootError<T extends FieldValues>(errors: FieldErrors<T>, name: string) {
  const node = get(errors, name) as { root?: { message?: string }; message?: string } | undefined;
  if (!node || Array.isArray(node)) return undefined;
  return node.root?.message ?? node.message;
}

用法就是 arrayRootError(errors, 'items')。Array.isArray(node) 那一支返回 undefined 是刻意的:只有行级错误时,数组本身没有错误,返回空才符合语义。

13.3.7 条件字段:判别联合 schema

「选了银行卡才要卡号」这类需求,正确的建模方式是判别联合(discriminated union)——用一个字面量字段作为判别键,Zod 会自动只校验命中的那支:

const paymentSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('card'), number: z.string().regex(/^\d{16}$/, '卡号需 16 位数字') }),
  z.object({ type: z.literal('bank'), iban: z.string().min(15, 'IBAN 至少 15 位') }),
  z.object({ type: z.literal('cod'), remark: z.string().max(50).optional() }),
]);

用 useWatch 读出当前判别键,据此渲染对应分支:

import { useWatch, type Control } from 'react-hook-form';
function PaymentFields({ control }: { control: Control<z.input<S>, any, z.output<S>> }) {
  const type = useWatch({ control, name: 'payment.type' });
  if (type === 'card') return <input {...register('payment.number')} />;
  if (type === 'bank') return <input {...register('payment.iban')} />;
  return null;
}

这里有一个必须交代的坑:切换分支后,旧分支的字段值还留在表单里。用户先填了卡号,再切到银行转账,提交时 payment.number 仍在 values 中——而判别联合的 schema 只保留命中分支的键(strip 模式会丢弃多余键),所以 Zod 会把它删掉。但这依赖 schema 的剥离行为,更稳妥的做法是显式声明:

const form = useForm<z.input<S>, any, z.output<S>>({
  resolver: zodResolver(paymentSchema),
  shouldUnregister: true, // 组件卸载时同步移除对应字段值
});

shouldUnregister: true 的语义是「字段从界面上消失,值也从表单里消失」。它正好匹配条件字段的直觉。代价是:如果只是暂时隐藏(比如折叠面板),用户重新展开时输入会丢失——这时应该用 false(默认),并在提交前靠 schema 剥离多余键。

13.3.8 跨行校验:superRefine

「明细名称不能重复」「总额必须等于各行小计之和」这类规则跨越了多个字段,refine 挂在对象上拿不到行下标,superRefine 挂在数组上则可以逐行 addIssue 并指定 path:

export const orderSchema = z.object({
  items: z.array(itemSchema).min(1, '至少添加一行明细').superRefine((items, ctx) => {
    const seen = new Map<string, number>();
    items.forEach((item, i) => {
      const key = item.name.trim().toLowerCase();
      if (key && seen.has(key)) {
        ctx.addIssue({
          code: 'custom',
          path: [i, 'name'], // 关键:挂到具体行,而不是整个数组
          message: `与第 ${(seen.get(key) ?? 0) + 1} 行重复`,
        });
      } else if (key) {
        seen.set(key, i);
      }
    });
  }),
});

path: [i, 'name'] 里的 i 是数组内的下标,Zod 会自动把它与上层的 items 拼起来,最终落到 errors.items[i].name。如果不给 path,错误会挂在数组根上,用户只看到「表单有问题」却找不到是哪一行——和 13.1 里 refine 缺 path 是同一个错误。

顺带一提,superRefine 里抛出的 code: 'custom' 会原样成为 error.type,前端可以据此区分「字段级规则」与「业务级规则」,做出不同的展示(例如后者用黄色警告而非红色错误)。

13.3.9 分步表单与草稿持久化

分步表单的核心诉求是:「下一步」只校验当前步的字段,而不是整表校验。RHF 的 trigger 支持按路径列表触发:

const stepFields: FieldPath<z.input<S>>[][] = [
  ['customer.name', 'customer.phone'],
  ['items.0.name'],       // 数组字段用具体下标
  ['payment.type'],
];
const next = async () => {
  const ok = await trigger(stepFields[step]);
  if (ok) setStep((s) => s + 1);
};

注意 trigger 返回的是 Promise<boolean>,必须 await,否则会在校验完成前就切步。数组字段的路径需要写具体下标(如 items.0.name),想校验整个数组就直接写 'items'。

草稿持久化让用户刷新页面不丢输入,思路是「订阅全表 + 防抖写 localStorage + 挂载时回填」:

useEffect(() => {
  const saved = localStorage.getItem('order-draft');
  if (saved) reset(JSON.parse(saved)); // reset 才能让 defaultValues 之外的初始值生效
}, [reset]);
const values = watch();
useEffect(() => {
  const t = setTimeout(() => localStorage.setItem('order-draft', JSON.stringify(values)), 500);
  return () => clearTimeout(t);
}, [values]);

两个要点:一是回填必须用 reset,改 defaultValues 无效(它只在首次挂载时读取);二是 watch() 无参调用会订阅整个表单,每次按键都触发一次 effect,所以防抖不可省,且这个 effect 应当只存在于顶层的草稿容器组件里。

13.3.10 性能与无障碍

动态字段的行数没有上限,所以性能不能靠「先写完再说」。三条实用约束:

做法收益
用 useWatch({ name }) 而非顶层 watch() 读值重渲染限制在订阅字段的子组件
把每一行抽成 memo 组件,只传 control 与 index加一行不会重渲染其余行
避免在行组件里解构整个 formState只解构该行需要的 errors 项

无障碍方面,动态字段比静态表单更需要结构:

<fieldset aria-describedby={errors.items?.[index]?.name ? `item-${index}-name-err` : undefined}>
  <legend>第 {index + 1} 行明细</legend>
  <label htmlFor={`item-${index}-name`}>名称</label>
  <input id={`item-${index}-name`} {...register(`items.${index}.name`)} />
  <p id={`item-${index}-name-err`} role="alert">
    {errors.items?.[index]?.name?.message}
  </p>
</fieldset>

fieldset + legend 让屏幕阅读器知道「第 N 行」的归属,aria-describedby 把错误文案关联到输入框,role="alert" 让错误出现时被主动播报。删除按钮要写清 aria-label={\删除第 ${index + 1} 行`}`,否则读屏软件只念出一个孤零零的「删除」。

13.3.11 五个常见坑

一、用 index 当 React key。 删除后 DOM 节点复用,输入内容与字段错位。必须用 field.id。

二、useFieldArray 的 name 与 schema 字段名不一致。 不报错,fields 恒为空,页面上一行都渲染不出来。

三、defaultValues 里没给数组初始值。 fields 初始为空,.min(1) 的错误立刻出现,用户一进来就看到报错。

四、数组级错误只读 errors.items?.message。 一旦有行级错误,数组级错误会被挪到 root,读取落空。用 13.3.6 的 arrayRootError 兜住两种形状。

五、superRefine 不给 path。 跨行校验的错误全挂在数组根上,用户找不到是哪一行。

13.3.12 与其它章节的衔接

基础写法(register / Controller / zodResolver)见 《TypeScript编程实战》13.1 React Hook Form + Zod ;z.input / z.output 的分叉与错误映射规则见 《TypeScript编程实战》13.2 表单类型推导与错误映射 。提交成功后的缓存失效与乐观更新见 《TypeScript编程实战》14.2 乐观更新与缓存失效 ;同一份 schema 在服务端复用见 《TypeScript编程实战》5.1 HTTP 服务与路由(Fastify / Hono) ;端到端类型安全见 《TypeScript编程实战》16.1 tRPC 端到端类型安全 。

站内延伸阅读:前端表单与校验架构 、运行时类型校验与类型安全 、TypeScript 与 Zod 运行时校验 、低代码 Schema 表单引擎 、前端无障碍实践 、E2E 测试与 Playwright 。

小结

动态字段的难点不在 API,而在「运行时结构」与「静态类型」之间的翻译。useFieldArray 把一个数组字段变成可增删的行集合,代价是必须遵守它的两条纪律:name 与 schema 字段名逐字一致、React key 用 RHF 生成的 id 而非下标。嵌套越深,错误读取越不能手写点号串,get 是必需品。

条件字段用判别联合 schema 表达,Zod 只校验命中的分支;切换分支后旧值是否残留,取决于 shouldUnregister 与 schema 的剥离行为,前者适合「字段真的消失」,后者适合「只是暂时隐藏」。跨行与跨字段规则交给 superRefine,一定要给 path,否则错误无处可挂。数组级错误的落点会在 items 与 items.root 之间摇摆,用一个小函数兜住两种形状比在每个组件里判断更可靠。

分步表单用 trigger(paths) 只校验当前步,草稿持久化用 reset 回填、用防抖写盘。至此第十三章结束:从 register 到 zodResolver,从 z.input/z.output 的分叉到动态字段的路径推导,表单这条线上「状态、规则、类型」三份副本终于收敛成了一份 schema。下一章换到数据的另一侧——当表单提交之后,服务端状态如何被缓存、失效与乐观更新,TanStack Query 会接手这段旅程。

阅读导航:上一节:13.2 表单类型推导与错误映射 · 下一节:14.1 TanStack Query 类型推导 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes