| name | forms-validation |
| description | 实现表单 / 校验 / 提交 / 错误处理时使用。覆盖 React Hook Form + Zod / TanStack Form / Formik / VeeValidate / Formily / Angular Forms。融合受控/非受控 + Schema 校验 + 异步校验 + 防重复提交。 |
表单与校验(Forms & Validation)
参考来源:React Hook Form 官方、Zod 官方、VeeValidate 官方、Form Design Patterns(Adam Silver)。
适用场景
- 各种表单(登录 / 注册 / 配置 / 创建 / 编辑)
- 复杂校验(依赖字段 / 异步 / 后端校验)
- 多步表单(向导)
- 动态字段(数组 / 嵌套)
- 文件上传
- 草稿保存
核心原则
1. Schema-first
定义 Zod / Yup schema → 自动生成类型 + 校验
2. 表单库性能优于 useState
useState 表单 → 每字段输入全表单重渲染
RHF / VeeValidate → 局部更新
3. 校验时机
- onChange:实时(性能差)
- onBlur:失焦(推荐)
- onSubmit:提交时
4. 错误信息友好
不写"VALIDATION_FAILED",写"邮箱格式不正确"
5. 防重复提交
isSubmitting 期间禁用按钮
或:去重 / 幂等键
6. 后端校验是兜底
前端校验只是体验,必须后端再校验
7. 字段级 vs 表单级错误
字段级:邮箱格式
表单级:服务器返回的"用户名已存在"
React Hook Form + Zod(推荐)
基础表单
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const formSchema = z.object({
email: z.string().email('请输入有效邮箱'),
password: z.string().min(8, '密码至少 8 个字符').max(64, '密码不超过 64 个字符'),
remember: z.boolean().optional(),
});
type FormData = z.infer<typeof formSchema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
setError,
} = useForm<FormData>({
resolver: zodResolver(formSchema),
mode: 'onBlur',
defaultValues: { email: '', password: '', remember: false },
});
const onSubmit = async (data: FormData) => {
try {
await api.login(data);
navigate('/dashboard');
} catch (err) {
if (err.code === 'INVALID_CREDENTIALS') {
setError('root', { message: '邮箱或密码错误' });
} else {
setError('root', { message: '登录失败,请稍后重试' });
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="email">邮箱</label>
<input id="email" type="email" {...register('email')} aria-invalid={!!errors.email} />
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password">密码</label>
<input id="password" type="password" {...register('password')} aria-invalid={!!errors.password} />
{errors.password && <p role="alert">{errors.password.message}</p>}
</div>
<label>
<input = {('')} />
记住我
{errors.root && {errors.root.message}}
{isSubmitting ? '登录中...' : '登录'}
);
}
复杂校验(依赖 / 异步)
const registerSchema = z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: '密码不一致',
path: ['confirmPassword'],
})
.refine(
async (data) => {
const res = await api.checkUsernameAvailable(data.username);
return res.available;
},
{
message: '用户名已被占用',
path: ['username'],
}
);
动态字段(数组)
import { useFieldArray } from 'react-hook-form';
const orderSchema = z.object({
customer: z.string().min(1),
items: z.array(
z.object({
productId: z.number().int().positive(),
quantity: z.number().int().positive().max(100),
})
).min(1, '至少 1 个商品'),
});
function OrderForm() {
const { control, register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(orderSchema),
defaultValues: { customer: '', items: [{ productId: 0, quantity: 1 }] },
});
const { fields, append, remove } = useFieldArray({ control, name: 'items' });
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
remove(index)}>删除
))}
append({ productId: 0, quantity: 1 })}>
添加商品
提交
);
}
多步表单
function MultiStepForm() {
const [step, setStep] = useState(0);
const methods = useForm<FullFormData>({ resolver: zodResolver(fullSchema) });
const stepSchemas = [stepOneSchema, stepTwoSchema, stepThreeSchema];
const next = async () => {
const valid = await methods.trigger(stepFields[step]);
if (valid) setStep(s => s + 1);
};
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
{step === 0 && <StepOne />}
{step === 1 && <StepTwo />}
{step === 2 && <StepThree />}
<div>
{step > 0 && <button onClick={() => setStep(s => s - 1)}>上一步</button>}
{step < 2 && <button type="button" onClick={next}>下一步}
{step === 2 && 提交}
);
}
文件上传
const fileSchema = z.object({
avatar: z.instanceof(FileList)
.refine(files => files.length > 0, '请选择文件')
.refine(files => files[0]?.size <= 5 * 1024 * 1024, '文件不超过 5MB')
.refine(files => ['image/jpeg', 'image/png'].includes(files[0]?.type), '仅支持 JPG/PNG'),
});
function UploadForm() {
const { register, handleSubmit, watch } = useForm({ resolver: zodResolver(fileSchema) });
const file = watch('avatar')?.[0];
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input type="file" accept="image/*" {...register('avatar')} />
{file && < = = />}
上传
);
}
TanStack Form(新一代,类型极致)
import { useForm } from '@tanstack/react-form';
function MyForm() {
const form = useForm({
defaultValues: { email: '', password: '' },
onSubmit: async ({ value }) => {
await api.login(value);
},
validators: {
onChange: ({ value }) => formSchema.safeParse(value).error,
},
});
return (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
<form.Field
name="email"
validators={{
onBlur: z.string().email('请输入邮箱'),
}}
children={(field) => (
<>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.errors.length > 0 && <span>{field.state.meta.errors[0]}</>}
)}
/>
</form>
);
}
Vue 3 + VeeValidate
<script setup lang="ts">
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';
const schema = toTypedSchema(z.object({
email: z.string().email('请输入邮箱'),
password: z.string().min(8, '密码至少 8 个字符'),
}));
const { defineField, handleSubmit, errors, isSubmitting } = useForm({
validationSchema: schema,
});
const [email, emailAttrs] = defineField('email');
const [password, passwordAttrs] = defineField('password');
const onSubmit = handleSubmit(async (values) => {
await api.login(values);
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<input v-model="email" v-bind="emailAttrs" type="email" />
<p v-if="errors.email">{{ errors.email }}</p>
<input v-model="password" v-bind="passwordAttrs" type="password" />
<p v-if="errors.password">{{ errors.password }}</p>
<button :disabled="isSubmitting">登录</button>
</form>
</template>
Vue 3 + Formily(企业级,复杂表单)
import { createForm } from '@formily/core';
import { FormProvider, FormItem, Input, Submit } from '@formily/antdv';
const form = createForm();
const schema = {
type: 'object',
properties: {
email: {
type: 'string',
title: '邮箱',
required: true,
'x-validator': [{ format: 'email' }],
'x-component': 'Input',
},
},
};
Angular Reactive Forms
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email" type="email" />
@if (form.get('email')?.errors?.['email']) {
<p>请输入邮箱</p>
}
<input formControlName="password" type="password" />
<button [disabled]="form.invalid || submitting()">登录</button>
</form>
`,
})
export class LoginComponent {
fb = inject(FormBuilder);
submitting = signal(false);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
async onSubmit() {
if (this.form.invalid) return;
this.submitting.set(true);
try {
await this.authService.login(..);
} {
..();
}
}
}
SvelteKit Form Actions
<!-- routes/login/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props();
</script>
<form method="POST" use:enhance>
<input name="email" type="email" required />
{#if form?.errors?.email}<p>{form.errors.email}</p>{/if}
<input name="password" type="password" required />
<button>登录</button>
</form>
<!-- routes/login/+page.server.ts -->
<script lang="ts">
import { fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export const actions = {
default: async ({ request }) => {
const formData = Object.fromEntries(await request.formData());
const result = schema.safeParse(formData);
if (!result.success) {
return fail(400, { errors: result.error.flatten().fieldErrors });
}
try {
await login(result.data);
} catch {
return fail(401, { message: '邮箱或密码错误' });
}
throw redirect(303, '/dashboard');
},
};
</script>
Schema 库对比
| 库 | 大小 | TS | 性能 | 推荐 |
|---|
| Zod | 11KB | 强 | 中 | ⭐⭐⭐⭐⭐ |
| Yup | 28KB | 中 | 中 | ⭐⭐⭐ |
| Valibot | 1KB | 强 | 高 | ⭐⭐⭐⭐⭐ |
| Joi | 145KB | 弱 | 中 | ⭐⭐ |
| ArkType | - | 极强 | 高 | ⭐⭐⭐⭐ |
校验时机选择
onChange(每字符):
✅ 适合:密码强度提示、计数
❌ 不适合:异步校验(频繁请求)、长表单
onBlur(失焦):
✅ 适合:通用校验、邮箱格式
❌ 不适合:实时反馈
onSubmit(提交时):
✅ 适合:简单表单、确认操作
❌ 不适合:需要立即反馈
混合(推荐):
- 第一次提交后切到 onChange
- 默认 onBlur
防重复提交
const { handleSubmit, formState: { isSubmitting } } = useForm();
<button disabled={isSubmitting}>提交</button>
const onSubmit = useCallback(
throttle(async (data) => {
await api.submit(data);
}, 1000),
[]
);
const idempotencyKey = useMemo(() => crypto.randomUUID(), []);
const onSubmit = async (data) => {
await api.createOrder(data, { headers: { 'Idempotency-Key': idempotencyKey } });
};
草稿保存
function useDraft<T>(formId: string, watch: () => T) {
const [restored, setRestored] = useState(false);
const data = watch();
useEffect(() => {
const saved = localStorage.getItem(`draft:${formId}`);
if (saved && !restored) {
setRestored(true);
}
}, [formId]);
useEffect(() => {
if (restored) {
localStorage.setItem(`draft:${formId}`, JSON.stringify(data));
}
}, [data, restored, formId]);
const clearDraft = () => localStorage.removeItem(`draft:${formId}`);
return { clearDraft };
}
工作流程
1. 设计表单 schema(Zod)
- 字段 + 校验规则
2. 选择表单库(RHF / TanStack Form / VeeValidate)
3. 实现表单
- 受控 vs 非受控
- 校验时机
- 错误展示
4. 防重复提交(isSubmitting / 幂等键)
5. 处理提交错误
- 字段级 vs 表单级
- 业务错误友好提示
6. 草稿保存(如长表单)
7. 测试
- 校验规则
- 提交成功 / 失败
- 防重复
配套模板
templates/forms-checklist.md — 表单清单 + 校验规则 + 错误处理 + 测试
质量自检
□ Schema-first(Zod / Valibot)
□ 类型自动派生
□ 受控 / 非受控明确
□ 校验时机合理
□ 错误信息友好(用户语言)
□ 防重复提交
□ 异步校验防抖
□ Loading / Error 状态
□ 必填用 *
□ 错误用 aria-invalid
□ 标签关联(label htmlFor)
□ 焦点管理(首个错误聚焦)
□ 键盘可操作
□ 移动端友好(type / inputmode)
□ 草稿保存(如长表单)
□ 后端校验兜底
常见坑
- 不防重复提交——重复创建
- 错误信息技术化——"VALIDATION_FAILED" 而非"邮箱格式不正确"
- 校验只在前端——直接调 API 仍可绕过
- onChange 校验性能差——大表单卡顿
- 不用表单库——useState 表单全表单重渲染
- focus 不到首个错误——长表单用户找不到
- type=email 不验证——浏览器原生不严格
- 没 noValidate——浏览器默认 UI 干扰
- 复选框 / 单选错处理——RHF 用法不对
- 文件上传不限制类型 / 大小——后端被攻击
- 无草稿保存——长表单刷新就丢
- disabled 按钮无说明——用户不知道为啥
- 多步表单不能后退——体验差
- iOS 缩放问题——input 字体 < 16px
与其他 skill 的协作
上游:
ui-ux-designer 工作流 → 表单设计 / 状态
api-designer → 字段约束 / 错误码
下游:
data-fetching → 提交 mutation
state-management → 表单状态
accessibility-implementation → ARIA / 焦点
testing-frontend → 表单测试
相关参考
- 项目根
references/frontend-tech-stack-guide.md — 表单库