弹窗先拆dirty状态
Vue 弹窗表单最容易被低估的问题,是 dirty 状态和弹窗生命周期混在一起。用户打开弹窗,改了一个字段,还没保存就关闭;再次打开,内容要不要恢复?如果切换到另一条数据,旧 dirty 是否清空?如果接口刷新了源数据,用户已经编辑的草稿要不要被覆盖?这些问题如果不提前定义,最后就会变成“有时保留,有时丢失”的体验。
一次后台配置页就出过这种问题。弹窗用 v-if 控制显示,表单组件卸载后字段清空;后来为了提升打开速度改成 v-show,字段又开始保留。产品以为这是“优化后变聪明了”,测试却发现切换编辑对象时旧数据带到了新对象。根因不是 v-if 或 v-show 谁对谁错,而是业务从来没定义 dirty 状态的边界。
dirty 状态不是一个简单布尔值。它应该回答:当前草稿相对哪个源数据发生了变化,变化是否可提交,关闭时是否保留,切换对象时是否丢弃,保存失败后如何恢复。Vue 弹窗先拆 dirty 状态,后面的关闭、校验、保存才不会靠组件默认行为碰运气。
显示隐藏不等于状态规则
很多人会把弹窗关闭等同于表单重置,因为组件卸载了;也有人把弹窗关闭等同于保留草稿,因为组件没卸载。这两种都把 UI 生命周期当成业务规则。实际上,关闭弹窗只是一个交互动作,不天然说明用户要放弃草稿。
新增表单和编辑表单就不一样。新增表单关闭后清空通常合理,因为下一次是新的创建任务;编辑表单如果用户误关,恢复草稿可能更友好。还有一些场景,比如配置项很敏感,关闭就必须放弃,避免用户以为已经保存。规则取决于业务,不取决于 v-if。
所以弹窗组件最好只负责显示隐藏,表单草稿由单独状态管理。关闭时调用明确动作:discardDraft、keepDraft、resetAfterSaved、switchTarget。动作名字越具体,后续维护越不容易误会。
source、draft、dirty 要分开
稳定的表单状态至少有两份数据:source 和 draft。source 是接口或父组件传入的可信数据,draft 是用户正在编辑的副本。dirty 则是 draft 相对 source 的变化结果。不要直接修改 source,也不要把 dirty 当成“用户点过输入框”。
Vue 里可以用 reactive 保存 draft,用 watch 在目标对象变化时重建。关键是 watch 的触发条件要清楚。对象 id 变了,通常应该重建 draft;同一个对象 source 刷新了,如果用户没有 dirty,可以同步;如果用户已经 dirty,就不能静默覆盖。
- watch(
- () => props.item?.id,
- () => {
- draft.value = clone(props.item)
- errors.value = {}
- }
- )
这段代码处理的是切换对象,不处理同对象刷新。真实项目里最好把这两类变化分开,否则接口刷新可能把用户输入冲掉。
dirty 的判断不要只靠表单库
组件库的表单通常能告诉你字段是否 touched、是否校验失败,但不一定能准确表达业务 dirty。用户把字段改了又改回去,业务上可能不算 dirty;自动格式化触发字段变化,业务上可能也不算 dirty。dirty 最好基于 source 和 draft 的关键字段比较。
比较时要注意格式。比如金额输入框显示“1,000.00”,source 是数字 1000;日期组件返回 Date,接口是字符串;多选字段顺序变化但含义不变。这些都需要归一化后比较。否则 dirty 会经常误报,用户关闭弹窗时总被提醒“有未保存修改”。
对于大表单,可以维护 patch,而不是每次深比较。字段变化时记录变更,用户改回 source 值时移除变更。这样 dirty 不只是 true/false,还能告诉你改了哪些字段。保存确认、审计日志、冲突提示都能用。
校验状态跟 dirty 不是同一个状态
dirty 表示用户是否改了,errors 表示当前草稿是否有效。两者相关,但不能混在一起。关闭弹窗是否清校验,要看草稿是否保留。如果保留草稿,校验错误通常也应该保留;如果丢弃草稿,校验错误也应该清掉。
异步校验更麻烦。比如名称唯一性校验,用户输入 A 后请求发出,又改成 B,A 的结果晚回来。如果没有版本控制,旧结果会污染新草稿。可以给校验加 revision,或在请求层使用取消。
- let revision = 0
- async function validateName(name) {
- const current = ++revision
- const result = await api.checkName(name)
- if (current !== revision) return
- errors.value.name = result.valid ? '' : '名称已存在'
- }
这不是复杂化,而是保护用户输入。表单状态最怕旧异步结果回来改新界面。
保存失败后要保留可操作状态
保存失败时,很多页面只弹 toast,然后让用户自己猜。更好的做法是保留 draft、保留 dirty,并把错误定位到可处理位置。字段错误回填字段,权限错误提示权限,版本冲突提示数据已变化,网络错误提供重试。
保存成功后也不要简单把 draft 当 source。后端可能格式化字段、生成 id、补默认值、返回最新版本。保存成功应该用后端返回值更新 source,再重建 draft,清空 dirty 和 errors。这样前后端状态才能对齐。
如果保存失败是版本冲突,最稳的是同时展示服务器最新值和用户草稿。小系统可以简单提示重新打开,大系统最好能保留用户修改,让用户决定覆盖、合并或放弃。否则用户会觉得自己的输入突然消失。
路由切换和遮罩关闭要单独处理
用户点击弹窗右上角关闭、点击遮罩、按 Escape、切换路由,这些动作不能都走同一个逻辑。点击保存后的关闭可以清状态;点击取消可能需要二次确认;路由切换可能要保存本地草稿或阻止离开。
如果 dirty 为 true,关闭前应该明确提示。提示文案也要和规则一致:是“放弃修改”还是“暂时关闭并保留草稿”?如果关闭后其实会保留,却提示“修改不会保存”,用户会困惑;如果关闭后会丢弃,却没有提示,就是风险。
Vue Router 的离开守卫可以用于路由切换确认,但不要把所有弹窗逻辑塞进路由守卫。弹窗内部关闭、外部路由变化、父组件销毁,都是不同路径。状态管理层要能处理这些路径,而不是只处理按钮点击。
测试要覆盖动作顺序
弹窗表单 bug 常常不是单个动作错,而是动作顺序复杂后错。测试时至少覆盖:打开编辑 A,修改字段,关闭再打开 A;打开 A 修改后切到 B;保存失败后继续编辑;异步校验晚返回;接口刷新 source;dirty 改回原值。
这些用例看起来细,但能挡住大量线上问题。尤其是后台工具,用户经常长时间编辑、频繁切换对象、在多个弹窗之间来回操作。状态边界不清时,问题会被放大。
还要测弹窗嵌套。比如外层是编辑弹窗,内层是选择联系人弹窗。内层关闭时不能误清外层 draft,外层取消时也要处理内层状态。很多后台页面会在一个弹窗里再打开选择器、上传器、预览器,状态层级一复杂,dirty 判断就容易串。
如果表单里有上传组件,dirty 规则也要特别处理。文件正在上传时关闭弹窗,是取消上传、继续后台上传,还是提示用户等待?上传成功但表单未保存时,附件是否算草稿?保存失败后附件要不要保留?这些问题如果不定义,最后常常变成服务器上残留孤儿文件,或者用户重新打开后附件丢失。
把规则写成 composable,而不是散在组件里
当弹窗表单越来越多时,不要每个组件都手写一套 dirty 逻辑。可以抽一个 composable,比如 useModalDraft,负责 source、draft、dirty、errors、reset、discard、restore、submitSuccess。业务组件只声明对象身份和字段规则。
抽象也要克制。这个 composable 不应该强行覆盖所有业务差异,而是提供清晰的生命周期动作。新增表单、编辑表单、持久草稿表单可以传入不同策略。这样统一的是状态边界,不是把所有表单做成同一个行为。
更重要的是,封装后测试可以集中写。dirty 改回原值、切换对象、异步校验晚返回、关闭保留草稿,这些用例在 composable 层测过,业务组件就少踩很多坑。否则每个弹窗都靠开发者记忆,迟早会漏。
落到团队协作上,composable 还可以把弹窗规则变成统一语言。产品说“这个弹窗关闭要保留草稿”,前端就能对应到 keep 策略;产品说“切换对象必须丢弃”,就对应 discardOnTargetChange。规则越清楚,评审时越容易发现遗漏,而不是等测试阶段才发现某个关闭路径行为不一致。
如果某个表单确实不适合共用封装,也要写明原因。比如它有复杂分步、离线草稿或外部审批流。例外可以存在,但例外要可解释。否则每个页面都说自己特殊,最后状态规则又会散回组件里。
最后给一个判断标准:弹窗关闭时,你应该能说清 draft、dirty、errors 分别会发生什么。如果只能回答“组件会卸载”或“组件会隐藏”,说明规则还没写完。Vue 弹窗先拆 dirty 状态,是为了让表单交互不再靠默认生命周期决定。
