Vue——别再让用户重填表单了!草稿保存与回显的终极解决方案
一次表单草稿功能的填坑之旅解决数据丢失、类型错乱、动态列表消失等典型问题写在前面上周一个朋友找我吐槽“用户反馈说保存的草稿再打开填的内容都不对了动态添加的几行数据全没了现在业务方在群里我三次了…”这个场景是不是很熟悉表单草稿功能看似简单却是前端开发中一个容易“翻车”的地方。今天就来聊聊这个问题分享一套经过验证的解决方案。先看问题长什么样一个典型的业务场景假设我们正在开发一个申请单提交页面包含以下字段申请标题文本申请类型下拉选择费用明细动态表格名称、数量、单价附件清单多文件上传用户填写了3行费用明细后点击“保存草稿”结果再次打开时✅ 标题和类型还在❌ 3行明细只剩1行或者全部丢失❌ 数字变成了字符串合计计算报错❌ 附件列表空了问题复现步骤新建一条申请单添加3行费用明细填写完整点击“保存草稿”返回列表页点击“继续编辑”期望看到刚才填写的3行明细实际明细丢失或数据错乱为什么会出现这个问题经过排查原因主要集中在以下4点1. 动态列表的“身份证”丢了动态添加的表单项如果没有唯一的id标识前端框架Vue/React就无法正确追踪每个列表项的变化。保存再加载时列表可能会被重新初始化。错误写法javascript// 用数组索引作为key - 大忌 items.map((item, index) div key{index}...)2. 数据序列化“缺胳膊少腿”保存草稿时只序列化了部分字段比如忘了处理动态列表的嵌套数据。常见场景javascript// 只保存了外层字段items被忽略了 const saveData { title: formData.title, type: formData.type // items 去哪了 }3. 数据类型“串台”了后端存储和前端交互时数据类型发生了隐式转换。前端类型后端返回问题表现Number“123”数值计算变成字符串拼接Date“2024-01-15T00:00:00Z”日期选择器无法识别Boolean1 或 “true”开关状态错乱4. 渲染时机不对数据被覆盖组件在异步数据返回前就已经渲染了导致初始值覆盖了真实数据。javascript// 错误示范数据还没回来组件已经用空值渲染了 onMounted(() { loadDraft() // 异步加载 loading false // 立即结束loading })解决方案4步根治问题第一步定义统一的数据结构用TypeScript定义好前后端统一的数据格式typescript// 费用明细项 interface ExpenseItem { id: string; // 唯一标识必须 name: string; // 费用名称 quantity: number; // 数量 price: number; // 单价 remark?: string; // 备注 } // 草稿数据结构 interface DraftData { id?: string; title: string; category: string; // 申请类型 items: ExpenseItem[]; // 费用明细 totalAmount: number; // 合计金额 attachments: string[]; // 附件列表 createTime: string; updateTime: string; }第二步创建数据格式化工具统一处理保存前和加载后的数据转换typescript// 数据格式化工具 class DataFormatter { // 保存前把表单数据转成存储格式 static formatForSave(formData: any): DraftData { return { id: formData.id, title: String(formData.title || ), category: String(formData.category || general), // 关键确保items被正确处理 items: (formData.items || []).map((item: any) ({ id: String(item.id || this.generateId()), name: String(item.name || ), quantity: Number(item.quantity) || 0, price: Number(item.price) || 0, remark: item.remark ? String(item.remark) : undefined, })), totalAmount: Number(formData.totalAmount) || 0, attachments: Array.isArray(formData.attachments) ? formData.attachments.map(String) : [], createTime: formData.createTime || new Date().toISOString(), updateTime: new Date().toISOString(), }; } // 加载后把存储数据转成组件需要的格式 static formatForLoad(savedData: DraftData): any { return { ...savedData, // 确保数值类型正确 items: savedData.items.map(item ({ ...item, quantity: Number(item.quantity), price: Number(item.price), })), }; } private static generateId(): string { return ${Date.now()}_${Math.random().toString(36).substr(2, 9)}; } }第三步深拷贝防止引用污染数据在多个地方传递时很容易被意外修改typescript// 简单可靠的深拷贝 function deepCloneT(obj: T): T { if (obj null || typeof obj ! object) return obj; if (obj instanceof Date) return new Date(obj.getTime()) as any; if (Array.isArray(obj)) return obj.map(item deepClone(item)) as any; const cloned {} as T; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { cloned[key] deepClone(obj[key]); } } return cloned; } // 保存时使用 async function saveDraft(formData: any) { // 先深拷贝避免影响正在编辑的数据 const copy deepClone(formData); const toSave DataFormatter.formatForSave(copy); await api.post(/draft/save, toSave); }第四步正确处理组件渲染时机Vue 3 正确写法vuetemplate !-- 关键数据加载完成前显示loading避免空值渲染 -- div v-ifloading classloading加载中.../div div v-else classform-container !-- 申请标题 -- input v-modelformData.title placeholder请输入申请标题 / !-- 申请类型 -- select v-modelformData.category option valuegeneral一般申请/option option valueurgent紧急申请/option /select !-- 费用明细列表 -- div classitems-list div v-foritem in formData.items :keyitem.id classitem-row input v-modelitem.name placeholder费用名称 / input v-model.numberitem.quantity typenumber placeholder数量 / input v-model.numberitem.price typenumber placeholder单价 / span小计: {{ item.quantity * item.price }}/span button clickremoveItem(item.id)删除/button /div button clickaddItem 添加费用明细/button /div div classform-footer span合计: {{ totalAmount }}/span button clicksaveDraft保存草稿/button button clicksubmit提交申请/button /div /div /template script setup import { ref, computed, onMounted } from vue const loading ref(true) const formData ref({ title: , category: general, items: [] }) // 合计金额 const totalAmount computed(() { return formData.value.items.reduce( (sum, item) sum (item.quantity || 0) * (item.price || 0), 0 ) }) // 生成唯一ID const generateId () ${Date.now()}_${Math.random().toString(36).substr(2, 9)} // 添加明细 const addItem () { formData.value.items.push({ id: generateId(), name: , quantity: 1, price: 0 }) } // 删除明细 const removeItem (id) { formData.value.items formData.value.items.filter(item item.id ! id) } // 保存草稿 const saveDraft async () { const dataToSave { ...formData.value, items: formData.value.items.map(item ({ id: item.id, name: item.name, quantity: Number(item.quantity) || 0, price: Number(item.price) || 0, })), totalAmount: totalAmount.value, updateTime: new Date().toISOString(), } try { const res await fetch(/api/draft/save, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(dataToSave) }) if (res.ok) alert(草稿已保存) } catch (err) { console.error(保存失败:, err) } } // 加载草稿 const loadDraft async (draftId) { try { const res await fetch(/api/draft/${draftId}) const data await res.json() // 关键格式化数据确保类型正确 formData.value { title: data.title || , category: data.category || general, items: (data.items || []).map(item ({ id: item.id || generateId(), name: String(item.name || ), quantity: Number(item.quantity) || 0, price: Number(item.price) || 0, })) } } catch (err) { console.error(加载失败:, err) } } // 初始化 onMounted(async () { const urlParams new URLSearchParams(window.location.search) const draftId urlParams.get(draftId) if (draftId) { await loadDraft(draftId) } else { // 新建时添加一个空行提升体验 addItem() } loading.value false // 数据加载完再显示表单 }) /script快速自查清单遇到草稿回显问题时按这个顺序排查序号检查项状态1动态列表的每个项是否有唯一的id☐2保存时是否完整序列化了所有字段☐3数值类型是否在保存/加载时做了Number()转换☐4组件是否在数据返回前就渲染了☐5深拷贝是否用对了☐6后端返回的数据格式是否与前端一致☐调试小技巧在关键节点打印日志对比保存和加载的数据javascript// 保存时 console.log(【保存】原始数据:, JSON.stringify(formData, null, 2)) console.log(【保存】格式化后:, JSON.stringify(formatted, null, 2)) // 加载时 console.log(【加载】后端返回:, JSON.stringify(rawData, null, 2)) console.log(【加载】格式化后:, JSON.stringify(componentData, null, 2))把两份日志对比问题出在哪一目了然。总结表单草稿功能出问题90%是数据格式不一致导致的。记住这4句话动态列表必须有唯一id保存和加载用同一套格式化规则数值类型显式转换不要依赖隐式转换数据回来再渲染别急着展示做到这几点草稿功能基本就稳了。