渲染出控件只是第一步,真正棘手的是数据:Schema 描述的字段要能初始化成 model,用户改动后要能回传给页面层,嵌套结构(一个字段里还有子表单)还要正确递归。本章围绕 setForm 递归初始化、modelValue 的双向绑定、watch 深度监听回写,以及为什么当前阶段用显式 emit 而非 defineModel() 更稳,讲清楚 Schema 表单的数据链路。
setForm 递归把 Schema 初始化为 model 的实现modelValue 作为统一数据出口的设计watch(model, { deep: true }) 把改动回传给页面层emit 比 defineModel() 更稳Schema 是一个字段数组,深层可能还有 schema 子结构(子表单)。启动时需要根据这份结构生成初始 model:setForm 遍历每一项,如果该项有 schema 子结构,就递归生成子对象;否则把 item.value 作为初值写入对应 field 的 key。递归保证了无论表单嵌套多少层,初始数据对象都能一次性建好,页面层拿到的就是一个完整、可直接 v-model 绑定的对象。
function setForm(arr: FormSchema[], level = 0) {
const obj: Record<string, any> = {}
arr.forEach((item, index) => {
if (!item.field) item.field = `${level}-${index}`
if (item.schema?.length) {
obj[item.field] = setForm(item.schema, level + 1)
} else if ("value" in item) {
obj[item.field] = item.value
} else {
obj[item.field] = undefined
}
})
return obj
}不是每份 Schema 都会认真写 field,尤其是纯展示型或分隔型项。如果 field 缺失就直接拿它当 model 的 key,会出错或互相覆盖。兜底策略是当 item.field 为空时,用 ${level}-${index} 这类"层级-序号"自动生成唯一 key。这样既能保证 model 结构始终完整,也避免了一处漏写 field 就导致整表初始化崩溃。属于防御性编程,不是鼓励不写 field。
VForm 对外暴露的数据不应该散落在各个 VFormItem 内部,而应该收敛到一个 modelValue。每个 VFormItem 通过 v-model="model[item.field]" 绑定到自己那一份数据,所有字段的改动最终都落在同一个 model 对象上。页面层只需要关心 modelValue,不关心内部字段怎么渲染。这种"多入口写、单出口读"的设计,让表单对外的契约保持简单。
用户在每个控件里输入时,model 内部对应 key 的值会变化。为了让页面层实时拿到最新值,VForm 用 watch(model, () => emit("update:modelValue", ...), { deep: true }) 监听整个对象。deep: true 关键,因为改动发生在嵌套对象的属性上,浅监听监听不到。回写时机放在 watch 回调里,保证任何字段变化都能触发一次对外更新。
watch(
model,
() => emit("update:modelValue", formValue.value),
{ deep: true }
)defineModel() 是 Vue 3.4 引入的更简洁的双向绑定语法,如今已稳定,理论上可以省掉手写 emit。但当前阶段它仍有心智负担:与 v-model 多绑定的配合方式、以及团队成员对其心智模型的熟悉度,都还没到可以无脑替换的程度。显式 const emit = defineEmits() + watch 回写虽然多几行代码,但行为完全可控、可读性高、调试时一眼能看清数据出口。等团队熟悉后再切 defineModel() 更稳妥。
当某个字段自身又是一组字段(如"收货地址"包含省/市/详细地址),在 Schema 上用 schema 嵌套表达,并在 model 上生成嵌套对象,是阶段上合理的做法。它让复杂对象天然成为树状结构,渲染和初始化都能递归处理。代价是提交前可能需要扁平化(见下一章),但"先让结构成立"比"一开始就追求扁平"更符合组件库渐进式演进的节奏。
要分清两件事:setForm 负责"第一次构建 model 时填什么",是初始化链路;v-model + watch 负责"运行时用户改了怎么同步出去",是绑定链路。两者不能混为一谈——初始化只跑一次,绑定持续整个生命周期。很多表单 bug 源于把默认值逻辑写进了绑定逻辑,或反过来。保持两条链路的边界清晰,是后续加校验、加联动时不出错的前提。
deep: true 的 watch 会在 model 任意深层属性变化时触发回调,字段很多时回调频率可能不低。对于绝大多数后台表单(几十个字段量级),这个开销完全可以忽略;但若表单达到几百个字段且高频联动,就需要考虑防抖回写或按需触发。当前阶段不必过早优化,先保证数据链路正确,性能问题等真实规模出现再用更精细的回写策略解决。
双向绑定不只是"页面层拿数据",还能反向驱动:当某字段变化需要联动显示/隐藏另一个字段时,可在 watch(model, ...) 里读变化字段,再改 schema 中对应项的 hidden 或 disabled。因为 model 是唯一数据源,联动逻辑集中在数据层而非散落在模板,复杂表单的状态流转反而更可控。这也再次印证"单出口"设计对可维护性的价值。
把本章串起来:schema → setForm 生成嵌套 model → 每个 VFormItem 用 v-model 绑定自己那一份 → 用户改动触发 watch(model, {deep}) → emit("update:modelValue", formValue) 把扁平值回传页面层。这条链路里,model 是内部真相、formValue 是对外契约、单向 watch 是同步桥梁。理解这三者在链路中的角色,后续接入校验和扁平化才不会打乱数据流向。
把这一章最容易出错的地方列成清单,写表单时逐条对照:
field 必须有兜底命名,否则 model 的 key 会互相覆盖schema 必须递归初始化,只在顶层 setForm 会得到残缺 modeldeep: true,否则嵌套属性变化监听不到setForm)与运行时链路(watch)不要混写,边界清晰才好调试emit 而非 defineModel(),行为可控、可读性强这份清单的本质是提醒:表单的数据流一旦乱,表现就是"页面层拿不到值""改了不回写""嵌套丢字段"这类难查的 bug。把链路拆成"建树 / 绑定 / 回写"三段,每段单一职责,绝大部分坑都不会出现。
还有一个容易被忽略的点:双向绑定不意味着页面层可以随意 mutate 内部 model。对外只暴露 formValue(扁平快照),内部 model 的变更统一经由控件 v-model 入口,这样数据流向始终单向可追踪。一旦允许外部直接改 model,嵌套结构与扁平值就会失同步,bug 会藏得很深。
顺带一提,初始化(setForm)生成的 model 和运行时绑定的是同一个引用,所以 watch 才能感知到嵌套变化;如果你在别处浅拷贝了一份 model 再绑,深度监听就会失效。引用一致性是整条数据链路能跑通的前提,任何"为了方便"而做的拷贝都要先想清楚这一点。
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 嵌套表单初始值缺失 | 只在顶层 setForm 没递归 | 遇到 schema 子结构递归生成 |
| 漏写 field 导致 key 冲突 | 没做兜底命名 | 用 level-index 自动补 key |
| 页面层拿不到实时改动 | 没用 deep 监听嵌套对象 | watch(model, cb, { deep: true }) |
是否该用 defineModel() 纠结 | 团队不熟悉、倾向显式数据流 | 当前阶段显式 emit 更可控 |
| 默认值逻辑和绑定逻辑互相干扰 | 初始化与运行时链路混写 | 拆成 setForm 与 watch 两条 |
| 复杂对象提交结构太深 | 嵌套 model 直接提交 | 提交前做扁平化(见下一篇) |
| 联动显隐逻辑散落模板 | 没利用单数据源集中处理 | 在 watch(model) 里集中改 schema |