Skip to content

表单组件校验体系重构

这次整理的对象来自 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test。和前几轮一样,我们不直接修改这个独立 demo,而是把其中最有长期价值的核心逻辑整理成一份文档化重构方案。

这个 demo 里最值得提炼的部分,是 form.vueform-item.vueinput.vueemitter.js 组成的简易表单校验体系。它其实已经具备了一个组件库表单系统的雏形:表单容器、字段注册、规则校验、失焦触发、重置和整体验证。

这次主要参考了下面这些文件:

  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/form/form.vue
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/form/form-item.vue
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/input/input.vue
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/mixins/emitter.js
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/views/form.vue

原实现已经很接近组件库雏形

这套实现虽然是 Vue 2 风格,但设计思路其实非常好:

  • iForm 负责保存字段实例并统一校验
  • iFormItem 负责关联字段值、提取规则和展示错误
  • iInput 负责把输入和 blur 事件通知给上层字段
  • emitter.js 负责跨层级分发事件

这意味着它已经不是普通业务表单,而是在尝试搭一套“可复用的基础表单组件系统”。这正是最值得沉淀的地方。

目前的主要问题不在功能,而在通信方式

原实现最大的问题并不是不能用,而是通信模型有些隐式。

iInput 通过 dispatch('iFormItem', 'on-form-change')dispatch('iFormItem', 'on-form-blur') 往上通知,iFormItem 再通过 dispatch('iForm', 'on-form-item-add')iForm 注册自己。这种模式在 Vue 2 里很常见,但会带来几个维护成本:

  • 组件之间靠名字和事件名约定通信,不够显式
  • 重构组件名时容易把通信链路改断
  • 类型和事件参数很难被静态约束
  • 表单项与输入组件的关系更像“约定耦合”,不是清晰契约

如果按 Vue 3 的最佳实践重写,这套系统更适合升级成 provide/inject + composable + 显式字段注册 的模式。

推荐的重构边界

更适合现在沉淀的结构可以拆成下面几层:

  • components/form/BaseForm.vue:表单容器,负责上下文提供与统一验证
  • components/form/BaseFormItem.vue:字段项,负责 label、错误提示和字段规则
  • components/form/BaseInput.vue:基础输入控件,只负责值和事件
  • composables/useForm.ts:表单实例管理、字段注册、整体验证
  • composables/useFormField.ts:单字段状态、规则计算和事件触发
  • composables/useFieldValidation.ts:字段级校验逻辑
  • types/form.ts:表单上下文、字段实例和规则类型

这样拆分以后,输入组件不再需要知道 iFormItem 的名字,字段项也不再依赖事件分发 mixin,整套系统会从“组件间约定”升级成“上下文注入 + 明确接口”。

先稳定表单领域模型

原始实现里的字段实例是直接把整个组件实例塞进 fields 数组中,这在 demo 里没问题,但在更大的系统里,最好还是定义一层明确接口。

ts
export type FormTrigger = 'blur' | 'change' | ''

export interface FormRule {
  required?: boolean
  type?: 'string' | 'email'
  message: string
  trigger?: FormTrigger | FormTrigger[]
}

export interface FieldState {
  prop: string
  validate: (trigger?: FormTrigger) => Promise<string | null>
  reset: () => void
}

export interface FormContext<Model extends Record<string, unknown>> {
  model: Model
  rules: Partial<Record<keyof Model, FormRule[]>>
  registerField: (field: FieldState) => void
  unregisterField: (prop: string) => void
}

有了这层模型以后,BaseFormBaseFormItem 之间的关系就不再依赖组件实例细节,而是围绕一个稳定接口协作。

把表单实例能力抽成 useForm

原始 form.vue 有两个关键能力:resetFields()validate()。这两个能力非常值得保留,但更适合抽到 composable 中,让组件只负责提供上下文。

ts
import { reactive, shallowRef } from 'vue'
import type { FieldState, FormContext, FormRule } from '../types/form'

export function useForm<Model extends Record<string, unknown>>(
  model: Model,
  rules: Partial<Record<keyof Model, FormRule[]>>
) {
  const fieldMap = reactive(new Map<string, FieldState>())
  const validating = shallowRef(false)

  function registerField(field: FieldState) {
    fieldMap.set(field.prop, field)
  }

  function unregisterField(prop: string) {
    fieldMap.delete(prop)
  }

  async function validate() {
    validating.value = true

    try {
      const results = await Promise.all(
        [...fieldMap.values()].map(field => field.validate(''))
      )

      return results.every(result => !result)
    } finally {
      validating.value = false
    }
  }

  function resetFields() {
    fieldMap.forEach(field => field.reset())
  }

  const context: FormContext<Model> = {
    model,
    rules,
    registerField,
    unregisterField
  }

  return {
    context,
    validating,
    validate,
    resetFields
  }
}

这个版本最重要的变化不是“代码从组件挪到了 composable”,而是字段注册和整体验证被稳定成了清晰 API,后续不管接表单库还是业务表单都能复用。

单字段逻辑更适合收口到 useFormField

原始 form-item.vue 里混合了很多职责:取值、提规则、判断是否必填、监听输入事件、执行校验、显示错误、保存初始值。这些都是真实需要的,但全塞在一个组件里会越来越难维护。

可以把字段逻辑抽到一个专门的 composable 中。

ts
import { computed, onBeforeUnmount, onMounted, shallowRef } from 'vue'
import type { FormContext, FormRule, FormTrigger } from '../types/form'
import { validateFieldValue } from './useFieldValidation'

export function useFormField<Model extends Record<string, unknown>>(
  form: FormContext<Model>,
  prop: keyof Model,
  label?: string
) {
  const validateState = shallowRef<'idle' | 'validating' | 'success' | 'error'>('idle')
  const validateMessage = shallowRef('')
  const initialValue = shallowRef(form.model[prop])

  const rules = computed<FormRule[]>(() => {
    return (form.rules[prop] ?? []) as FormRule[]
  })

  const isRequired = computed(() => {
    return rules.value.some(rule => rule.required)
  })

  async function validate(trigger: FormTrigger = '') {
    validateState.value = 'validating'
    const message = await validateFieldValue(rules.value, form.model[prop], trigger)
    validateMessage.value = message ?? ''
    validateState.value = message ? 'error' : 'success'
    return message
  }

  function reset() {
    form.model[prop] = initialValue.value as Model[keyof Model]
    validateState.value = 'idle'
    validateMessage.value = ''
  }

  onMounted(() => {
    form.registerField({
      prop: String(prop),
      validate,
      reset
    })
  })

  onBeforeUnmount(() => {
    form.unregisterField(String(prop))
  })

  return {
    label,
    isRequired,
    validateState,
    validateMessage,
    validate
  }
}

这样 BaseFormItem 就能从“大型实现组件”变成“字段状态的展示壳”。

校验规则应该从组件里再拆一层

原始实现直接在 form-item.vue 中创建 AsyncValidator 并执行校验。这个做法可行,但当规则类型变多以后,字段组件会越来越重。

把校验执行单独抽成 useFieldValidation 更合适。

ts
import AsyncValidator from 'async-validator'
import type { FormRule, FormTrigger } from '../types/form'

function normalizeTrigger(trigger: FormRule['trigger']) {
  if (!trigger) {
    return []
  }

  return Array.isArray(trigger) ? trigger : [trigger]
}

export async function validateFieldValue(
  rules: FormRule[],
  value: unknown,
  trigger: FormTrigger
) {
  const filteredRules = rules.filter(rule => {
    const triggers = normalizeTrigger(rule.trigger)
    return !trigger || triggers.length === 0 || triggers.includes(trigger)
  })

  if (!filteredRules.length) {
    return null
  }

  const validator = new AsyncValidator({
    value: filteredRules
  })

  try {
    await validator.validate({ value }, { firstFields: true })
    return null
  } catch (error: any) {
    return error?.errors?.[0]?.message ?? '校验失败'
  }
}

这一层的价值在于,字段组件以后不必知道 async-validator 的细节,未来替换实现时也不会影响 UI 层。

输入组件应该只负责值和原生事件

原始 input.vuedispatchchangeblur 往上抛,这是为了让 form-item 自动触发校验。这个思路本身没问题,但在 Vue 3 里更推荐显式契约,而不是依赖组件名查找。

更好的方式是让 BaseInput 只暴露 v-model 和原生事件,由 BaseFormItem 或消费侧决定何时触发校验。

vue
<script setup lang="ts">
const model = defineModel<string>({ default: '' })

const emit = defineEmits<{
  blur: [event: FocusEvent]
  change: [value: string]
}>()

function handleInput(event: Event) {
  const value = (event.target as HTMLInputElement).value
  model.value = value
  emit('change', value)
}
</script>

<template>
  <input
    :value="model"
    type="text"
    @input="handleInput"
    @blur="emit('blur', $event)"
  >
</template>

当输入组件职责被收窄以后,它就能被表单场景和非表单场景共同复用,不必始终绑定在 FormItem 体系里。

provide/inject 比事件分发更适合这类系统

原始实现已经在 form.vue 里用到了 provide,但后续通信主要还是靠 Emitter。如果按 Vue 3 重写,更推荐把整套表单上下文都通过注入完成。

ts
import type { InjectionKey } from 'vue'
import type { FormContext } from '../types/form'

export const formContextKey = Symbol('form-context') as InjectionKey<FormContext<Record<string, unknown>>>

BaseForm.vue 里提供上下文:

vue
<script setup lang="ts">
import { provide } from 'vue'
import { formContextKey } from '../../composables/formContext'
import { useForm } from '../../composables/useForm'
import type { FormRule } from '../../types/form'

const props = defineProps<{
  model: Record<string, unknown>
  rules: Partial<Record<string, FormRule[]>>
}>()

const form = useForm(props.model, props.rules)
provide(formContextKey, form.context)

defineExpose({
  validate: form.validate,
  resetFields: form.resetFields
})
</script>

<template>
  <form>
    <slot />
  </form>
</template>

这种方式的好处很直接:

  • 字段组件通过上下文拿到表单实例,不再依赖组件名
  • 结构更显式,更适合类型化和测试
  • 通信链路在代码里一眼能看出来

页面消费方式也会更清晰

原始 views/form.vue 的消费方式已经很简洁,这个方向是对的。重构后,页面层仍然应该保持轻薄,只负责声明 modelrules 和提交行为。

vue
<script setup lang="ts">
import { reactive, ref } from 'vue'
import BaseForm from '../components/form/BaseForm.vue'
import BaseFormItem from '../components/form/BaseFormItem.vue'
import BaseInput from '../components/form/BaseInput.vue'

const formRef = ref<InstanceType<typeof BaseForm> | null>(null)

const formModel = reactive({
  name: '',
  mail: ''
})

const formRules = {
  name: [{ required: true, message: '用户名不能为空', trigger: 'blur' }],
  mail: [
    { required: true, message: '邮箱不能为空', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
  ]
}

async function handleSubmit() {
  const valid = await formRef.value?.validate()
  window.alert(valid ? '提交成功' : '表单校验失败')
}
</script>

<template>
  <BaseForm ref="formRef" :model="formModel" :rules="formRules">
    <BaseFormItem label="用户名" prop="name">
      <BaseInput v-model="formModel.name" />
    </BaseFormItem>

    <BaseFormItem label="邮箱" prop="mail">
      <BaseInput v-model="formModel.mail" />
    </BaseFormItem>
  </BaseForm>

  <button type="button" @click="handleSubmit">提交</button>
</template>

可以看到,页面层依然保持了和原 demo 一样的易用性,但底层实现已经从事件约定变成了更稳固的上下文系统。

这次重构真正沉淀下来的模式

这个 demo 最值得学的地方,不是“怎么做一个能校验的表单”,而是“怎么把一个业务表单逐步升级成组件库级表单体系”。

这次真正沉淀下来的经验有五个:

  • 先把字段、规则、上下文这些核心概念类型化
  • 把整体验证和字段注册收口到 useForm
  • 把单字段的展示与校验状态收口到 useFormField
  • 把校验执行从字段组件里再拆出一层
  • provide/inject 代替隐式事件分发,降低耦合度

以后再整理类似的“基础组件库 demo”时,可以优先检查这几个问题:

  • 字段注册是不是还依赖组件实例和事件名
  • 输入组件是不是还耦合了表单系统内部通信
  • 校验规则是不是还直接塞在展示组件里执行
  • 页面层能不能只声明 modelrules

如果这些问题还没有理顺,这套基础组件通常就还没有真正成型。

共 20 个模块,1301 篇 Markdown 文档。