表单草稿先分层

表单最怕的不是字段多,而是草稿状态说不清。用户打开一个编辑弹窗,改了三项,接口还没保存,突然关闭;下次打开应该恢复草稿,还是回到服务器数据?校验错误要不要保留?切换到另一个对象时,旧草稿能不能带过去?这些问题如果不提前定义,最后就会被组件默认行为决定。
我见过一个 React 后台表单问题。用户编辑供应商信息,关闭弹窗后重新打开,部分字段恢复了,部分字段丢了;校验错误有时还在,有时消失。代码里同时存在三份状态:接口返回的数据、表单库内部状态、组件自己维护的草稿。每个状态都在不同时间被重置,结果就是“看起来像随机”。
草稿边界不是说所有表单都要持久化,而是要先回答:谁是源数据,谁是编辑副本,什么时候提交,什么时候丢弃,什么时候恢复。React 表单写得稳,靠的不是多用一个表单库,而是状态归属清楚。
源数据和草稿不能混成一份
很多表单问题来自把接口数据直接当成编辑状态。接口返回 user,组件把它放进 state,输入框直接修改这个对象。看起来省事,实际上源数据被污染了。用户还没点击保存,页面其他地方可能已经看到被修改后的值。
更稳的做法是分两层:source draft。source 表示服务器或上层传入的可信数据,draft 表示用户当前正在编辑的副本。输入框只改 draft,保存成功后再更新 source。取消时丢弃 draft 或按规则保留。
  1. const [source, setSource] = useState(null)
  2. const [draft, setDraft] = useState(null)

  3. useEffect(() => {
  4. fetchUser(id).then((data) => {
  5. setSource(data)
  6. setDraft(data)
  7. })
  8. }, [id])
jsx
这段代码只是起点。真正要注意的是:当 id 变化时,旧 draft 是否能覆盖新对象?通常不能。对象身份变化应该重建 draft,否则会出现 A 用户的编辑内容带到 B 用户身上。
关闭弹窗不是天然等于重置表单
很多组件库的弹窗关闭后会卸载内容,也有些只是隐藏。表单库也可能在 unmount 时清理状态。你不能把业务规则交给这些默认行为。
比如“新增供应商”弹窗,关闭后通常应该清空,因为下一次新增是新的任务。但“编辑长表单”弹窗,用户误关后可能希望恢复草稿。两者都是弹窗表单,但规则完全不同。
所以关闭动作要拆成几类。用户点击取消,是明确放弃还是暂时关闭?点击遮罩关闭,是否要二次确认?路由切走,是否保存本地草稿?保存成功后关闭,是否清空校验?这些动作背后的含义不同,不应该都走同一个 onClose
一个实用做法是让弹窗生命周期和表单生命周期分开。弹窗只负责显示隐藏,表单状态由上层或专门 hook 管理。关闭弹窗时调用明确动作:discard、keep、submitSuccess、switchTarget。动作名字比布尔值更不容易误解。
校验状态也有生命周期
表单校验状态经常被忽略。用户输入错误邮箱,关闭再打开,错误提示是否还在?如果恢复草稿,校验状态大概率也应该恢复;如果重新开始,校验状态就应该清空。
更复杂的是异步校验。比如用户名是否重复、手机号是否已存在。用户输入 A,校验请求发出;随后改成 B,A 的校验结果晚回来,如果没有取消或比对版本,就可能把 B 标成错误。草稿边界要包含异步校验的版本。
可以给 draft 加一个 revision。每次关键字段变化时 revision 增加,异步校验返回时只接受当前 revision 的结果。或者使用 AbortController 取消旧请求。核心原则是:旧草稿的校验结果不能污染新草稿。
  1. const current = ++revisionRef.current
  2. validateName(value).then((result) => {
  3. if (current !== revisionRef.current) return
  4. setErrors(result.errors)
  5. })
jsx
这不是为了炫技巧,而是避免用户看到“明明改对了,错误还在”的体验。
表单库解决字段,不解决业务边界
React Hook Form、Formik、Ant Design Form 都能帮你管理字段、校验和提交,但它们不会替你决定业务生命周期。比如 reset 什么时候调用,defaultValues 什么时候更新,dirty 状态怎么处理,这些仍然是你的规则。
常见坑是接口数据更新后,表单默认值没有同步。表单第一次打开用 A 数据初始化,切到 B 对象时,组件没卸载,表单内部仍然保留 A。有人用 key={id} 强制重建表单,这能解决一部分问题,但也会丢掉你可能想保留的状态。更好的做法是明确对象切换规则。
如果对象切换就必须放弃旧草稿,那可以重建。如果同一个对象的服务器数据刷新,但用户正在编辑,就不能直接 reset 覆盖 draft。否则用户输入会被后台刷新冲掉。可以提示“服务器数据已更新”,让用户选择覆盖或继续编辑。
表单库是工具,业务边界要自己写。尤其是后台系统,表单通常连接权限、审核、草稿、附件、审批流,默认行为很难正好符合业务。
dirty 状态要能解释
很多页面用 dirty 判断是否需要离开确认。但 dirty 的定义要清楚:是任意字段变化就算,还是和 source 深比较?格式化变化算不算?自动填充算不算?上传附件失败后算不算?
如果 dirty 只是表单库内部标记,可能会出现误判。比如字段初始化后触发格式化,表单被标成已修改;或者用户改了值又改回去,dirty 仍然是 true。后台表单最好把 dirty 和业务源数据比较,至少关键字段要可解释。
对于大表单,深比较可能有成本,可以维护变更记录。每个字段变化时记录 patch,保存成功后清空 patch。这样不仅能判断是否修改,还能知道改了什么。审计、二次确认、冲突提示都能复用这份信息。
保存失败后不要急着清草稿
保存失败是表单体验里最容易伤人的场景。用户填了很多内容,点击保存,接口返回失败。如果你直接关闭弹窗或重置表单,用户会非常崩溃。保存失败后 draft 应该保留,错误应该定位到可处理位置。
错误也要分类。字段错误可以回填到对应字段;权限错误应该提示当前用户不能保存;版本冲突要提示数据已被别人修改;网络失败可以允许重试。所有错误都弹一个 toast“保存失败”,用户不知道该做什么。
如果是长表单,可以把保存过程设计成可恢复。提交前先保留本地 draft,保存成功后清除;保存失败时保留草稿和错误上下文。对于附件、富文本、分步表单,还可以做局部保存,但局部保存又会引入一致性问题,需要明确哪些字段已经提交,哪些还只是草稿。
接口返回的服务器新值也要小心处理。保存失败时,后端可能返回当前最新版本;保存成功时,后端可能会格式化字段、补默认值、生成 id。前端不能简单假设“提交的 draft 就是最终 source”。更稳的是保存成功后用后端返回值更新 source,再用这份 source 重建 draft。这样金额格式、枚举值、附件地址、服务端计算字段都能对齐。
如果保存失败是版本冲突,草稿更不能直接丢。应该把用户草稿和服务器最新值同时保留,至少提示哪些字段冲突。后台系统里两个人同时编辑同一条数据很常见,草稿边界不处理冲突,就会出现“我保存失败了,也不知道自己改了什么”的体验。
什么时候需要本地持久化
不是所有草稿都要写 localStorage。持久化会带来隐私、过期、跨账号、跨对象、数据结构升级等问题。短表单、低成本输入,关闭丢弃可能更清爽。长表单、高成本输入、弱网环境,持久化更有价值。
如果要持久化,要至少带上用户、业务对象、版本和过期时间。不要只用一个固定 key 存所有草稿。用户 A 的草稿不能给用户 B,供应商 1 的草稿不能带到供应商 2,旧版本字段结构不能无限期恢复。
恢复草稿时也不要静默覆盖服务器数据。可以提示“发现未提交草稿”,让用户选择恢复或丢弃。这样用户知道当前看到的是草稿,而不是服务器最新状态。
写表单前先写状态表
复杂表单开发前,我建议先写一张状态表:打开时加载什么,编辑时写哪里,关闭时是否保留,切换对象时怎么处理,保存成功后怎么清理,保存失败后保留什么,异步校验如何取消。
这张表比一开始写代码更有用。它能让产品、前端、后端对“草稿”达成共识。很多表单 bug 不是代码能力问题,而是规则没说清。规则模糊时,任何实现都会被用户认为“不稳定”。
状态表最好覆盖几个真实动作。比如用户点击取消、点击遮罩、按 Escape、路由切走、切换编辑对象、保存成功、保存失败、接口刷新、权限变化。每个动作都写清 source、draft、errors、dirty、pending validation 如何变化。写完这张表,你会发现很多原本模糊的问题必须被决定。
测试也可以围绕状态表写。不要只测“输入一个字段能保存”。还要测关闭后重新打开、切换对象、异步校验晚返回、保存失败重试、用户改回原值 dirty 是否清除、服务端数据刷新是否覆盖草稿。表单问题常常不是单个字段错,而是动作顺序一复杂就错。
最后给一个判断标准:如果你不能清楚回答“现在输入框里的值来自 source 还是 draft”,这个表单状态就已经危险了。React 表单先定草稿边界,后面的校验、关闭、保存和恢复才有稳定基础。