{T}

VFormItem 事件透传与实例暴露

概述

表单渲染、绑定、校验都就绪后,下一个复杂度来自"表单项级别的事件和实例能力怎么向外暴露"。本章把表格里做过的事件透传、实例方法透传思路迁移到表单:整表级事件(validate)直接手写 defineEmits 更合适;单项级用 FormItemInstance 暴露 clearValidate 等局部能力;用回调式 itemRef 把单个实例主动上交页面层;并讨论插槽扁平化、watch 持续同步实例的鲁棒性细节。

学习目标

  • 理解表单透传复杂度来自"层级细"而非事件多
  • 掌握整表级 validate 事件直接手写 emit 的取舍
  • 区分 FormInstance(整表)与 FormItemInstance(单项)两个维度
  • defineExpose 暴露单个 FormItem 的局部能力
  • 用回调式 itemRef 把实例主动上交页面层
  • 掌握 watch(formItemRef) 持续同步实例优于 onMounted 只传一次
  • 掌握 labelSlot / errorSlot 在模板里的插槽写法
  • 了解多个 itemRef 在页面层如何收集管理
  • 通过自定义 label/error 实战理解插槽价值

一、表单透传复杂度来自层级细

表格透传面对的是"一张表"这一个粒度,而表单透传落在 Form(整表)和 FormItem(单项)两个层级。页面层既可能想感知整表校验状态,也可能想单独清掉某个字段的错误。粒度更细,但事件数量未必更多。这一节的重点不是加控件,而是补齐表单的"行为层"和"外部可控能力"——组件体系真正迈向可复用,靠的正是这些细粒度出口。

二、整表级 validate 直接手写 emit 更合适

Form 的事件其实不多,最典型的就是 validate。不像 Table 那样有一大套批量事件需要工具函数批量转发,这里直接 defineEmits 显式声明反而更清晰:

ts
const emit = defineEmits<{
  "update:modelValue": [value: Record<string, any>]
  validate: [prop: string, isValid: boolean, message: string]
}>()

工具函数不是越多越好,场景小就直接写。表单和表格不同,不能因为前面在表格里用了批量工具,这里就机械照搬——透传方案应服从复杂度,而不是形式统一。

三、FormInstance 与 FormItemInstance 是两个维度

Element Plus 的 FormInstance 提供整表操作(validateresetFieldsscrollToField),而 FormItemInstance 提供单个字段操作(validateresetFieldclearValidatevalidateStatevalidateMessage)。两者层级完全不同:前者作用于整张表,后者只作用于某个字段。单项实例非常适合字段级精细控制,例如只清掉"手机号"这一个字段的错误,而不打扰其他字段。Schema Form 的实例暴露会比普通表单更细一层。

四、defineExpose 暴露单个 FormItem 局部能力

延续 VTable 的经验,VFormItem 也用 defineExpose 把实例能力上交。但透出的目标从"整张表实例"变成"单个 FormItem 的局部实例"。页面层拿到某个 schema 项对应的 FormItemInstance 后,就能调用它的 clearValidatevalidateState 做精准控制。只有当单项实例真正可被外部拿到,复杂业务里"按字段干预"才成为可能。

ts
const formItemRef = ref<FormItemInstance>()

defineExpose({
  ...exposes
})

五、回调式 itemRef 把实例主动上交

与其让页面层自己维护一大堆 ref 去硬猜哪个对应哪个字段,不如在 Schema 项上定义一个 itemRef 回调函数,由 VFormItem 在自身实例就绪时主动把它交出去:

ts
itemRef?: (instance: FormItemInstance | undefined) => void

props.item.itemRef?.(formItemRef.value) 一句就把"schema 项 → 对应实例"的映射自动建立起来。这很像 React 里常见的 callback ref 思路,比模板里堆 ref 灵活得多,尤其适合 Schema 驱动这种"项数量不固定"的场景。

六、watch 持续同步实例优于 onMounted 只传一次

最初可能写在 onMounted 里传一次实例,但动态表单里节点可能重建、item 可能切换、条件渲染可能导致引用变化,只传一次外层拿到的就可能过期。改成 watch(formItemRef, () => props.item.itemRef?.(formItemRef.value), { immediate: true }),只要实例引用变化就重新同步。对实例透传这类能力,持续同步通常比只传一次更稳,这是典型的"为鲁棒性而改写法"的工程选择。

七、插槽扁平化与单项展示定制

FormItem 的核心插槽有 defaultSlotlabelSloterrorSlot 三个。当前阶段插槽数量不多,把它们直接扁平挂在 Schema 上(item.labelSlot / item.errorSlot),比再包一层 slots 对象更直观易写。引入这三个插槽意味着 Schema Form 已不只是定制控件内部,而是能定制 FormItem 自己的展示层(自定义 label 样式、自定义错误展示、插入图标说明),正式进入"高级可配置表单"范围。

Vue SFC
<el-form-item ref="formItemRef" v-bind="props.item">
  <template v-if="props.item.labelSlot" #label>
    <component :is="props.item.labelSlot" />
  </template>
  <template v-if="props.item.errorSlot" #error="{ error }">
    <component :is="props.item.errorSlot" :error="error" />
  </template>
</el-form-item>

八、多个 itemRef 在页面层如何收集管理

当表单有多个字段都需要外部控制时,页面层可以在自己的作用域里维护一个 Map<string, FormItemInstance>,每个 schema 项的 itemRef 回调把 (field, instance) 写进这个 Map。这样点击"重置手机号"按钮时,就能 map.get("phone")?.clearValidate() 精准操作。用 Map 而非一堆独立 ref,既适配字段数量不固定的 Schema,也让"按字段取实例"的语义一目了然。

九、自定义 label/error 实战理解插槽价值

插槽的价值在复杂表单里才显出来:例如给某个字段的 label 加一个问号提示图标(用 labelSlot 包一个 el-tooltip),或把错误提示换成带图标的红色横幅(用 errorSlot 拿到 error 渲染自定义组件)。这些定制如果写死在 VFormItem 里会污染通用组件,而通过 Schema 上的插槽字段注入,则保持了组件通用、定制外置。这正是 Schema Form 走向"高级可配置"的关键一步。

十、事件与实例暴露边界清单

把本章透传相关的边界固化,避免乱用 ref 或事件名漂移:

  • 整表事件(validate)数量少,直接 defineEmits 手写比批量工具更清晰
  • FormInstanceFormItemInstance 是两个维度,不要混用
  • 单项实例用 defineExpose 暴露,页面层拿的是"某个字段的局部能力"
  • 实例上交用回调式 itemRef,比模板堆 ref 更适配字段数量不固定的 Schema
  • 实例同步用 watch 持续同步,不要只在 onMounted 传一次
  • 插槽(labelSlot/errorSlot)当前阶段扁平挂载即可,不必提前包 slots 对象

透传层的复杂度来自"层级细"而非"事件多"。把整表与单项两个层级的出口都设计清楚,复杂业务里"按字段感知状态、按字段干预"才成为可能,表单系统也才真正迈过"渲染器"阶段。


常见问题

问题原因解决方案
页面层感知不到校验变化只透传了值没透传 validateel-form 上监听并转发 validate
拿不到单个字段实例没给 schema 项提供实例出口itemRefVFormItem 回传
slot 结构越写越深难读过早再包一层 slots 对象当前阶段扁平化定义更合适
mounted 回传一次后实例过期只传一次没持续同步watch(formItemRef) 持续同步
多个字段实例难管理模板里堆了一堆独立 ref页面层用 Map 按 field 归集
中间层默认值不一致VForm/VFormItem 默认没对齐统一各层默认策略

延伸阅读