Vue表单草稿回填的时序问题与解决方案
问题背景
在业务开发中,"草稿"功能是常见需求:用户填写表单时可以保存草稿,下次继续编辑时自动回填之前的数据。
最近在开发表单功能时,遇到一个棘手的问题:草稿回填时子表数据被清空。
具体场景:
- 用户在表单中填写了明细列表并保存草稿
- 再次打开草稿继续编辑时,明细列表却是空的
- 其他字段(如类型、金额等)回填正常
问题分析
代码结构
表单使用Vue 3 + Composition API,简化后的代码结构如下:
vue
<script setup> const formData = reactive({ category: '', // 表单分类:类型A/类型B/类型C items: [] // 明细列表 }) // 监听分类变化,重置明细模板 watch(() => formData.category, (newCategory) => { // 根据分类设置不同的明细模板 if (newCategory === 'typeA') { formData.items = [{ name: '', quantity: 0, price: 0 }] } else if (newCategory === 'typeB') { formData.items = [{ title: '', amount: 0, remark: '' }] } else { formData.items = [{ description: '', value: 0 }] } }) // 加载草稿数据 const loadDraftData = async (draftId) => { const res = await api.getDraft(draftId) // 回填表单数据 formData.category = res.data.category // 触发watcher! formData.items = res.data.items // 被watcher覆盖! } </script>问题根因
问题出在数据回填的时序上:
loadDraftData设置formData.category = res.data.category- 这会触发
watch,执行明细重置逻辑 - watcher 将
formData.items重置为空白模板 - 接着执行
formData.items = res.data.items - 但由于 Vue 的响应式更新是异步批量处理的,watcher 的重置可能在赋值之后执行
- 最终结果:明细数据被 watcher 覆盖为空白模板
这是一个典型的watcher 与数据加载的竞态问题。
时序图
text
loadDraftData() | v 设置 category ──────> 触发 watcher(异步队列) | | v v 设置 items watcher 重置 items | | v v 期望:草稿数据 实际:空白模板(被覆盖)解决方案
方案一:Hydrating 标记(推荐)
引入一个标记变量,在数据回填期间跳过 watcher 的重置逻辑:
vue
<script setup> const formData = reactive({ category: '', items: [] }) // 标记:是否正在回填草稿数据 const isHydratingDraft = ref(false) watch(() => formData.category, (newCategory) => { // 草稿回填期间,跳过重置逻辑 if (isHydratingDraft.value) { return } // 正常的分类切换,重置明细模板 if (newCategory === 'typeA') { formData.items = [{ name: '', quantity: 0, price: 0 }] } else if (newCategory === 'typeB') { formData.items = [{ title: '', amount: 0, remark: '' }] } else { formData.items = [{ description: '', value: 0 }] } }) const loadDraftData = async (draftId) => { const res = await api.getDraft(draftId) // 开启回填模式 isHydratingDraft.value = true try { // 回填所有数据 formData.category = res.data.category formData.items = res.data.items // 等待响应式更新完成 await nextTick() } finally { // 关闭回填模式 isHydratingDraft.value = false } } </script>优点:
- 逻辑清晰,易于理解
- 不影响正常的用户交互逻辑
- 可以复用于其他类似场景
方案二:调整赋值顺序 + nextTick
vue
<script setup> const loadDraftData = async (draftId) => { const res = await api.getDraft(draftId) // 先设置 category,等待 watcher 执行完毕 formData.category = res.data.category await nextTick() // 再设置 items,覆盖 watcher 的结果 formData.items = res.data.items } </script>缺点:
- 依赖执行顺序,容易被后续修改破坏
- 如果有多个 watcher 相互影响,nextTick 可能不够
方案三:使用 watchEffect 的 flush: ‘sync’
vue
<script setup> watch(() => formData.category, (newCategory) => { // ... }, { flush: 'sync' }) // 同步执行,立即触发 </script>缺点:
- 同步执行可能影响性能
- 不符合 Vue 推荐的异步更新模式
扩展:通用的 Hydrating 模式
可以封装成一个通用的 composable:
typescript
// useHydrating.ts import { ref, readonly, nextTick } from 'vue' export function useHydrating() { const isHydrating = ref(false) const hydrate = async <T>(fn: () => Promise<T>): Promise<T> => { isHydrating.value = true try { const result = await fn() await nextTick() return result } finally { isHydrating.value = false } } const skipIfHydrating = (fn: () => void) => { if (!isHydrating.value) { fn() } } return { isHydrating: readonly(isHydrating), hydrate, skipIfHydrating } }使用方式:
vue
<script setup> const { isHydrating, hydrate, skipIfHydrating } = useHydrating() watch(() => formData.category, () => { skipIfHydrating(() => { // 重置逻辑 }) }) const loadDraftData = async (draftId) => { await hydrate(async () => { const res = await api.getDraft(draftId) Object.assign(formData, res.data) }) } </script>总结
- 问题本质:Vue 的 watcher 是异步执行的,在数据批量回填时可能产生竞态条件
- 推荐方案:使用
isHydrating标记,在数据回填期间跳过 watcher 的副作用逻辑 - 最佳实践:
- 区分"用户交互触发"和"程序回填触发"两种场景
- watcher 中应该只处理用户交互场景
- 数据回填使用专门的标记来控制
- 命名建议:
hydrating(水合)这个词来自 React/Vue SSR 的概念,表示"将数据填充到已有结构中",非常适合描述草稿回填的场景
本文源于实际项目中的问题修复经验,希望对遇到类似问题的开发者有所帮助。