表单组件校验体系重构
这次整理的对象来自 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test。和前几轮一样,我们不直接修改这个独立 demo,而是把其中最有长期价值的核心逻辑整理成一份文档化重构方案。
这个 demo 里最值得提炼的部分,是 form.vue、form-item.vue、input.vue 和 emitter.js 组成的简易表单校验体系。它其实已经具备了一个组件库表单系统的雏形:表单容器、字段注册、规则校验、失焦触发、重置和整体验证。
这次主要参考了下面这些文件:
11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/form/form.vue11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/form/form-item.vue11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/input/input.vue11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/mixins/emitter.js11Vue学习/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 里没问题,但在更大的系统里,最好还是定义一层明确接口。
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
}有了这层模型以后,BaseForm 和 BaseFormItem 之间的关系就不再依赖组件实例细节,而是围绕一个稳定接口协作。
把表单实例能力抽成 useForm
原始 form.vue 有两个关键能力:resetFields() 和 validate()。这两个能力非常值得保留,但更适合抽到 composable 中,让组件只负责提供上下文。
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 中。
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 更合适。
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.vue 用 dispatch 把 change 和 blur 往上抛,这是为了让 form-item 自动触发校验。这个思路本身没问题,但在 Vue 3 里更推荐显式契约,而不是依赖组件名查找。
更好的方式是让 BaseInput 只暴露 v-model 和原生事件,由 BaseFormItem 或消费侧决定何时触发校验。
<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 重写,更推荐把整套表单上下文都通过注入完成。
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 里提供上下文:
<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 的消费方式已经很简洁,这个方向是对的。重构后,页面层仍然应该保持轻薄,只负责声明 model、rules 和提交行为。
<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”时,可以优先检查这几个问题:
- 字段注册是不是还依赖组件实例和事件名
- 输入组件是不是还耦合了表单系统内部通信
- 校验规则是不是还直接塞在展示组件里执行
- 页面层能不能只声明
model和rules
如果这些问题还没有理顺,这套基础组件通常就还没有真正成型。
