第 12 章 · 表单校验与复杂状态管理
本章目标:掌握 Vue(VeeValidate / Element Plus rules) 与 React(React Hook Form + Zod) 的表单校验体系;理解 Pinia / Zustand / Context 选型;实现多步骤表单、动态字段与异步校验;完成可上线的 「商品上架表单」 完整示例。
学时建议:4~5 小时(含 90 分钟跟练)
前置:完成本模块 ch01~ch03(Vue)或 ch04~ch05(React)、ch11(UI 组件库)。
12.1 为什么表单是后台项目的「深水区」
| 痛点 | 原生 JS | 框架 + 校验库 |
|---|---|---|
| 字段联动 | 手写 onchange 链式更新 | 声明式 watch / useWatch |
| 错误展示 | 手动维护 errors 对象 | 库自动绑定 meta.errors |
| 提交防重复 | 自己管 loading 标志 | isSubmitting 内置 |
| 复杂嵌套 | 深层对象路径易错 | fieldArray / useFieldArray |
行业案例 · 贤紫优选商城:商户上架一件 SKU 需填写基础信息、规格矩阵、运费模板、资质图片共 40+ 字段;2025 年后台从「提交后后端报错再改」迁移到「前端 schema 校验 + 异步 SKU 查重」,表单提交失败率从 23% 降至 4% 以下。
12.2 Vue:Element Plus 表单 rules
12.2.1 基础 rules 模式
<script setup lang="ts">
import { reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
interface ProductForm {
name: string
price: number | null
categoryId: string
}
const formRef = ref<FormInstance>()
const form = reactive<ProductForm>({ name: '', price: null, categoryId: '' })
const rules: FormRules<ProductForm> = {
name: [
{ required: true, message: '请输入商品名称', trigger: 'blur' },
{ min: 2, max: 60, message: '长度 2~60 字', trigger: 'blur' },
],
price: [
{ required: true, message: '请输入售价', trigger: 'blur' },
{
validator: (_r, v, cb) =>
v != null && v > 0 ? cb() : cb(new Error('售价须大于 0')),
trigger: 'blur',
},
],
categoryId: [{ required: true, message: '请选择类目', trigger: 'change' }],
}
async function onSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
// 调用 ch13 axios 封装提交
}
</script>
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="商品名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="售价" prop="price">
<el-input-number v-model="form.price" :min="0" :precision="2" />
</el-form-item>
<el-form-item label="类目" prop="categoryId">
<el-select v-model="form.categoryId" placeholder="请选择">
<el-option label="数码" value="cat-digital" />
<el-option label="家居" value="cat-home" />
</el-select>
</el-form-item>
<el-button type="primary" @click="onSubmit">提交上架</el-button>
</el-form>
</template>
trigger | 适用 |
|---|---|
blur | 文本、数字失焦校验 |
change | 下拉、开关、日期即时校验 |
自定义 validator | 跨字段、业务规则 |
12.2.2 动态 rules
类目为「食品」时追加「保质期」必填:
import { computed } from 'vue'
const rules = computed<FormRules>(() => ({
...baseRules,
...(form.categoryId === 'cat-food'
? { shelfLife: [{ required: true, message: '请填写保质期(天)' }] }
: {}),
}))
模板中配合 v-if="form.categoryId === 'cat-food'" 渲染表单项,规则与 UI 同步出现。
12.3 Vue:VeeValidate 4 + Zod(推荐 TS 项目)
npm install vee-validate @vee-validate/zod zod
// schemas/product.ts
import { z } from 'zod'
export const productStep1Schema = z.object({
name: z.string().min(2).max(60),
price: z.number().positive('售价须大于 0'),
sku: z.string().regex(/^[A-Z0-9-]{6,20}$/, 'SKU 格式不正确'),
})
export type ProductStep1 = z.infer<typeof productStep1Schema>
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { productStep1Schema } from '@/schemas/product'
const { defineField, errors, handleSubmit, isSubmitting } = useForm({
validationSchema: toTypedSchema(productStep1Schema),
})
const [name] = defineField('name')
const [price] = defineField('price')
const [sku] = defineField('sku')
const onSubmit = handleSubmit(async (values) => {
await api.createProductDraft(values)
})
</script>
| 对比 | Element rules | VeeValidate + Zod |
|---|---|---|
| 与 UI 库耦合 | 强(el-form) | 弱,可换 Ant Design Vue |
| Schema 复用 | 难 | 前后端共享 Zod(ch13) |
| 学习成本 | 低(已在用 Element) | 中,适合 TS 团队 |
建议:贤紫后台已用 Element Plus 的页面继续 rules;新模块或需与后端共享校验时引入 VeeValidate。
12.4 React:React Hook Form + Zod
npm install react-hook-form @hookform/resolvers zod
// schemas/product.ts — 可与 Vue 共用同一份 zod
import { z } from 'zod'
export const productSchema = z.object({
name: z.string().min(2).max(60),
price: z.coerce.number().positive(),
specs: z.array(z.object({ key: z.string(), value: z.string() })).min(1),
})
export type ProductFormValues = z.infer<typeof productSchema>
const { control, register, handleSubmit, formState: { errors, isSubmitting } } =
useForm<ProductFormValues>({ resolver: zodResolver(productSchema) })
const { fields, append, remove } = useFieldArray({ control, name: 'specs' })
// Form.Item + register / Controller 绑定;append/remove 管理动态规格行
性能要点:RHF 默认非受控,大表单少重渲染;Ant Design 需 Controller 绑定 value/onChange。
12.5 状态管理选型:Pinia vs Zustand vs Context
状态复杂度
▲
│
Pinia / Zustand ────┤ 全局:用户、购物车、多步骤表单草稿
│
Context ────────────┤ 主题、locale、轻量共享(<5 字段)
│
组件本地 state ─────┤ 单页表单、弹窗开关
│
└──────────────────────► 跨组件深度
| 方案 | Vue | React | 适用 |
|---|---|---|---|
| 组件内 | ref / reactive | useState | 单表单、无跨页 |
| Context | provide/inject | createContext | 主题、权限快照;避免大对象频繁更新 |
| Pinia | 官方推荐 | — | 用户会话、商品草稿、步骤进度 |
| Zustand | 可用但少见 | 轻量首选 | 中等全局状态,无 Provider 嵌套 |