本文系统介绍了使用 vee-validate 结合 zod 进行表单状态管理的解决方案,通过对比传统声明式表单验证的局限性(需手动声明响应式变量/校验规则),重点展示了该方案通过高阶组件与组合式 API 实现表单初始化的灵活性、利用 zod 强大的类型校验体系(支持复杂场景如文件名唯一性校验)、提供两种错误反馈机制(提交时全局校验与实时字段级校验),以及多步骤表单的字段值保留策略,最终实现更简洁高效的表单开发范式。

vee-validate 表单状态管理

为什么要使用 vee-validate?

我们先来看看常规情况下的表单,参考 NaiveUI,使用的是声明式规则和 kv 绑定

1
2
3
4
5
6
7
8
9
10
11
<n-form ref="formRef" :model="model" :rules="rules">
<n-form-item path="age" label="年龄">
<n-input v-model:value="model.age" @keydown.enter.prevent />
</n-form-item>
</n-form>
<!-- 声明式规则 -->
const rules: FormRules = { age: [ { required: true, validator(rule:
FormItemRule, value: string) { if (!value) { return new Error('需要年龄') } else
if (!/^\d*$/.test(value)) { return new Error('年龄应该为整数') } else if
(Number(value) < 18) { return new Error('年龄应该超过十八岁') } return true },
trigger: ['input', 'blur'] } ], }

声明式规则的好处是易拓展,可以随时在对应的组件上修改,但是坏处是写起来非常不方便:

  • 首先得声明响应式变量作为表单值 v-model 的绑定,有些时候还要声明未定义变量
1
2
const formData = ref({ name: "", age: 18 });
const formData = ref({ description: undefined });
  • 其次,对于校验,即使是判断是否最小,也得手写一部分规则
1
2
3
4
5
validator(rule: FormItemRule, value: string) {
if (!value) {
return new Error('需要年龄')
}
}

那有没有什么轻度抽象,能解决上述问题的存在呢?

答案是有的,那就是 vee-validate + zod 的双重方案,不依赖组件原生的实现

vee-validate + zod

  • vee-validate 是 base on typescript 的,提供了高阶组件和组合式 API,它可以和任何的组件库集成

  • zod 则是一个校验器,提供了丰富且灵活校验方式,适用于表单验证等场景

多种方式初始化表单和提供响应式值

  • vee-validate 有多种方式初始化表单和提供响应式值,不再需要声明未定义情况
1
2
3
4
5
6
7
8
9
10
11
12
const { values, defineField } = useForm({
validationSchema: toTypedSchema(
z.object({
name: z.string().min(1).desribe("名称"),
})
),
});

const [name] = defineField("name");

// or in subcomponent
const { value: name } = useField<string>("name");

zod 自带校验规则

  • zod 自带校验规则,以及提供更强大的规则定义
1
2
3
const schema = z.object({
name: z.string().min(1).desribe("名称"),
});

zod 有更强大的规则

  • zod 有更强大的规则,比如对所有文件名称进行校验
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const schema = z.object({
fieldInfos:z.array({
file:z.instanceof(File),
name:z.string()
})
}).superRefine(({fieldInfos},context)=>({
const fileNames = fieldInfos.map(i=>i.name);
fieldInfos.forEach((fieldInfo,index)=>{
const hasExist = fileNames.include(fieldInfo.name);
if(hasExist){
context.addIssue({
code:z.ZodIssueCode.custom,
message: '文件名已存在',
path: ['fileInfos', index, 'name'],
})
}
})

}))

错误的 UI 响应

  • 对于错误的 UI 响应,有两种方式
  1. 提交时报错,利用 handleSubmit 的第二个参数注入错误回调处理

示例:

1
2
3
4
5
6
7
8
9
// handleSubmit提交中会自动触发一次校验,若失败会执行失败回调,反之才会执行内部提交
const submit = handleSubmit((values)=>{
yourApi.post({...values})
// 重置表单,or 销毁组件
},({errors})=>{
console.log(errors)
})

<form @submit={submit}>
  1. 提交前报错,借助 defineField 的第二个 props,或者借助 useField 的 error

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 不推荐使用 因为要显示错误信息一般是表单控件,我们有更好的解决办法
const [name, nameProps] = defineField("name", (state) => ({
props: {
validationStatus: state.errors[0] ? ("error" as const) : undefined,
feedback: state.errors[0],
},
}));

// 表单组件抽象 更方便的解决办法
const { value, errorMessage } = useField(() => props.name);
<NFormItem
:label="props.label"
:showLabel="!!props.label"
:required="props.required"
:validationStatus="errorMessage ? 'error' : 'success'"
:feedback="errorMessage"
:showFeedback="!!errorMessage"
>
// 下面绑定value
</NFormItem>

多步骤表单

如果组件是多步骤表单,可能涉及到多个状态的组件切换,这个时候需要设置 keepValuesOnUnmount 控制单个字段卸载时的保留

1
2
3
4
5
6
7
8
9
10
11
12
<script setup>
// 多步骤表单配置
const { handleSubmit } = useForm({
keepValuesOnUnmount: true, // 保留已填写但当前不可见的字段值
});
</script>

<template>
<Step1 v-if="step === 1" /> <!-- 输入姓名 -->
<Step2 v-if="step === 2" /> <!-- 输入地址 -->
<!-- 切换步骤时,前一步的值会被保留 -->
</template>