Skip to content

085. Form 监听、可编辑规则与保存流程

学习目标

这一节继续完善行内编辑。

学完后,你应该能理解:

  • record 和 Form 中实时编辑数据的区别;
  • 为什么要用 Form.useWatch
  • 每一行为什么要维护自己的 Form;
  • 行编辑状态为什么放在表格外层管理;
  • 列级 editable 如何控制单元格是否可编辑;
  • requiredrules 如何进入校验;
  • 保存时如何区分新增和更新。

自动撑开列

表格里有些列需要固定宽度。

例如操作列通常可以固定为 120

但如果所有列都有固定宽度,表格可能显得拥挤。

课程里加了一个自动撑开宽度的列。

它不设置明确宽度。

这样浏览器会把剩余空间分配给它。

底层可以从表格生成的 colgroup 看到每一列宽度如何生效。

原始行数据和编辑数据

行内编辑里有两份数据。

第一份是原始行数据。

它来自表格的 dataSource

可以理解为:

ts
record

这份数据只有在保存成功、表格数据被更新后才会变化。

第二份是 Form 里的实时编辑数据。

用户正在输入时,变化先发生在 Form 内部。

此时 record 还没有变化。

为什么需要 Form.useWatch

有些单元格渲染时,需要拿到最新编辑值。

例如两个列显示同一个字段。

当用户在一个输入框里修改值时,另一个单元格也应该能看到最新值。

如果只读 record,它不会更新。

因为 record 是保存后的数据。

所以需要通过 Form.useWatch 监听 Form 内部数据。

可以把它理解成:

ts
const formData = Form.useWatch([], form)

formData 表示当前行正在编辑的实时值。

Form.Item 才会纳入表单控制

只有被 Form.Item 包裹并声明 name 的字段,才会进入 Form 管理。

没有对应 Form.Item 的字段,不会出现在 Form 的编辑值里。

这点很重要。

例如创建时间、更新时间、ID 这类字段通常不需要编辑。

它们可以展示在行数据里,但不一定进入 Form。

每一行一个 Form

行内编辑选择每一行维护一个 Form。

原因是保存、取消、校验都是按行发生的。

如果整张表只用一个 Form,所有行的字段都会混在一起。

这样会带来很多问题:

  • 保存一行时难以只校验这一行;
  • 取消一行时难以只重置这一行;
  • 新增多行时字段命名和状态管理会复杂很多;
  • 统一保存或统一取消也更难协调。

所以每一行一个 Form 是更稳定的选择。

Ant Design Pro 的可编辑表格也采用类似思路。

联动字段更新

有些字段编辑后,不只更新自己。

例如项目负责人字段。

后端可能保存负责人 ID。

但界面还要显示负责人名称。

选择一个负责人后,可能需要同时更新:

  • userId
  • userName

这种情况下,可以通过 Form 实例更新多个字段。

类似:

ts
form.setFieldsValue({
  userId,
  userName,
})

这也是为什么单元格渲染需要拿到 Form 和实时编辑数据。

编辑状态放在外层

行是否可编辑,不能只放在行组件内部。

如果状态只存在某个 TableRow 里,会遇到几个问题:

  • 新增行插入表格后,无法方便地让它立即进入编辑状态;
  • 批量取消时,需要找到所有行组件实例;
  • 批量保存时,也需要从所有行组件里收集状态;
  • 行组件卸载后,状态不容易统一管理。

所以编辑状态放在通用表格外层。

行组件只接收计算后的结果。

这样表格可以统一控制新增、编辑、取消和保存。

调试状态更新

React 状态更新不是立即同步到闭包变量里。

在调试时,如果直接看闭包里的旧变量,可能会误以为状态没有变化。

课程里用额外方法读取最新值,并打印更新前和更新后的映射。

这能更直观看到:

  • 双击某行后,对应 ID 被标记为编辑状态;
  • 取消某行后,对应 ID 从编辑映射里移除。

这类调试对理解 React 状态更新很有帮助。

行组件优化

行组件要处理两种情况。

第一种是普通数据行。

它需要创建 Form、注入上下文、绑定编辑状态。

第二种是空数据或占位行。

它没有真实 recordindex,只需要正常渲染 tr

这样可以避免加载中或空状态时读取行字段导致错误。

行上下文可以用 useMemo 缓存。

目的不是改变功能,而是减少不必要的重新计算和重新渲染。

列级 editable

不是每个单元格都可编辑。

列配置可以提供 editable

它可以是布尔值。

例如:

ts
editable: false

表示这一列永远不可编辑。

它也可以是函数。

例如根据当前行数据判断是否可编辑。

这种场景很常见。

比如某字段只有在状态为草稿时才能修改,审批后就不能修改。

行可编辑和列可编辑

单元格是否进入编辑态,要同时看两个条件。

第一,当前行是否处于编辑状态。

第二,当前列是否允许编辑。

所以逻辑可以理解成:

ts
cellEditable = rowEditable && columnEditable

如果行没有进入编辑状态,列配置再允许也不会显示编辑组件。

如果行进入编辑状态,但列配置禁止编辑,也只显示普通内容。

校验规则

列配置里可以提供校验规则。

例如 Ant Design Form 的 rules

也可以提供快捷的 required

required 为真时,自动追加必填规则。

例如:

ts
required: true

可以转成:

ts
{ required: true, message: "请填写" }

如果列本身还有更多规则,也要一起传给 Form.Item

例如用 pattern 要求字段必须以某个文本开头。

表格参数用类型约束

课程里还优化了传给 Table 的参数。

例如分页参数、行事件参数。

在 TypeScript 中,如果直接返回对象,编辑器可能不知道具体类型。

可以通过显式类型或 satisfies 约束。

这样写配置时能拿到字段提示。

对新手来说,先记住一个经验:

复杂组件的配置对象,最好让 TypeScript 知道它到底是什么类型。

否则自动补全和类型检查都会变弱。

保存流程

保存行数据时,需要先判断它是新增还是更新。

判断方式来自临时 ID。

如果 ID 是前端生成的新建 ID,就走新增接口。

如果是后端已有 ID,就走更新接口。

流程大致是:

  1. 找到当前行在数据数组里的位置;
  2. 找到这一行对应的 Form 实例;
  3. 根据配置决定是否校验;
  4. 读取编辑后的表单值;
  5. 判断新增还是更新;
  6. 组装请求;
  7. 调用模块接口;
  8. 用接口返回的新数据替换表格里的旧行;
  9. 清理编辑状态或新增状态;
  10. 提示保存成功。

新增和更新接口

新增时调用 insert

更新时调用 update

它们都属于通用模块对象提供的接口能力。

这样通用表格不需要知道具体业务接口路径。

它只需要根据配置里的模块信息,生成对应请求。

保存前清理临时 ID

新增行有前端临时 ID。

这个 ID 只是为了让表格能渲染。

保存到后端时不能把它当成真实主键。

所以新增请求前要把这个临时 ID 清掉。

由后端生成真正的 ID。

保存成功后,再用后端返回的新数据更新表格行。

undefined 转成 null

保存前还会处理字段值。

如果某个字段是 undefined,请求序列化时可能会被忽略。

但有时用户的真实意图是清空这个字段。

如果字段被忽略,后端按需更新时就不会改它。

所以课程里把 undefined 转成 null

这样能明确告诉后端:这个字段要清空。

保存前校验

保存时可以先执行 Form 校验。

如果校验失败,就不要发请求。

例如字段必须以指定文本开头。

输入不符合规则时,Form 会提示错误。

校验通过后,才读取表单值并进入保存请求。

这比请求失败后再回头提示更适合前端体验。

更新表格数据

接口成功后,会返回新增或更新后的行数据。

前端要用它替换当前表格里的旧行。

不能只相信本地编辑值。

因为后端可能补充了字段。

例如:

  • 新 ID;
  • 创建时间;
  • 更新时间;
  • 关联对象名称;
  • 服务端计算字段。

所以保存成功后,应该以接口返回值为准。

这一节的核心

行内编辑逐渐从“能显示输入框”变成真正可用的表格能力。

关键变化是:

  • Form.useWatch 读取实时编辑数据;
  • 用列级 editable 控制单元格是否可编辑;
  • rulesrequired 接入校验;
  • 用 Form 实例执行校验和取值;
  • 用模块接口区分新增和更新;
  • 用后端返回结果更新本地表格数据。

到这里,通用表格已经具备保存单行编辑数据的基础闭环。

AI Agent 课程学习文档。