类型安全的表单验证
00:00
构建类型安全的表单验证系统
概述
表单验证是前端应用中最常见的交互逻辑之一。传统的表单验证方案中,字段名、验证规则和错误信息之间缺乏类型关联,容易出现字段名拼写错误、验证规则与字段类型不匹配等问题。通过 TypeScript 的泛型、条件类型和映射类型,可以构建类型安全的表单验证系统,确保字段名、值类型、验证规则和错误信息都有精确的类型约束。
基础概念
表单 Schema:定义表单字段及其验证规则的数据结构。每个字段包含类型信息、验证规则和错误消息模板。
字段类型映射:将表单 Schema 转换为字段值类型,确保表单数据的类型安全。
验证规则:每个字段的验证函数列表,接收字段值并返回验证结果。验证规则的参数类型与字段值类型关联。
错误类型:验证失败时返回的错误信息,键名与表单字段对应,错误信息类型为字符串或字符串数组。
快速上手
基础表单类型
// 定义表单字段类型
interface FormSchema {
username: string;
email: string;
age: number;
agree: boolean;
}
// 从 Schema 生成表单数据类型
type FormData<T> = { [K in keyof T]: T[K] };
// 从 Schema 生成错误类型
type FormErrors<T> = {
[K in keyof T]?: string[];
};
// 从 Schema 生成 touched 类型
type FormTouched<T> = {
[K in keyof T]?: boolean;
};
验证函数
// 验证规则类型
type ValidationRule<T> = (value: T) => string | null;
// 通用验证规则
const required =
(message = '此字段为必填项'): ValidationRule<any> =>
(value) =>
value == null || value === '' ? message : null;
const minLength =
(min: number, message?: string): ValidationRule<string> =>
(value) =>
value.length < min ? (message ?? `最少需要 ${min} 个字符`) : null;
const maxLength =
(max: number, message?: string): ValidationRule<string> =>
(value) =>
value.length > max ? (message ?? `最多允许 ${max} 个字符`) : null;
const min =
(minimum: number, message?: string): ValidationRule<number> =>
(value) =>
value < minimum ? (message ?? `最小值为 ${minimum}`) : null;
const max =
(maximum: number, message?: string): ValidationRule<number> =>
(value) =>
value > maximum ? (message ?? `最大值为 ${maximum}`) : null;
const pattern =
(regex: RegExp, message: string): ValidationRule<string> =>
(value) =>
!regex.test(value) ? message : null;
const email =
(message = '请输入有效的邮箱地址'): ValidationRule<string> =>
(value) =>
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? message : null;
详细用法
类型安全的表单验证器
// 表单验证器
class FormValidator<T extends Record<string, any>> {
private rules: { [K in keyof T]?: ValidationRule<T[K]>[] } = {};
// 添加字段的验证规则
field<K extends keyof T>(key: K, ...validators: ValidationRule<T[K]>[]): this {
this.rules[key] = validators;
return this;
}
// 验证单个字段
validateField<K extends keyof T>(key: K, value: T[K]): string[] {
const fieldRules = this.rules[key] ?? [];
return fieldRules.map((rule) => rule(value)).filter((error): error is string => error !== null);
}
// 验证整个表单
validate(data: FormData<T>): FormErrors<T> {
const errors: FormErrors<T> = {};
for (const key in this.rules) {
const fieldErrors = this.validateField(key, data[key]);
if (fieldErrors.length > 0) {
errors[key] = fieldErrors;
}
}
return errors;
}
// 检查表单是否有效
isValid(data: FormData<T>): boolean {
const errors = this.validate(data);
return Object.keys(errors).length === 0;
}
}
// 使用
interface RegisterForm {
username: string;
email: string;
age: number;
agree: boolean;
}
const registerValidator = new FormValidator<RegisterForm>()
.field('username', required(), minLength(3), maxLength(20))
.field('email', required(), email())
.field('age', required(), min(1), max(150))
.field('agree', required('请同意服务条款'));
// 验证
const errors = registerValidator.validate({
username: 'ab',
email: 'invalid',
age: -1,
agree: false,
});
// { username: ['最少需要 3 个字符'], email: ['请输入有效的邮箱地址'], age: ['最小值为 1'], agree: ['请同意服务条款'] }
异步验证
// 异步验证规则
type AsyncValidationRule<T> = (value: T) => Promise<string | null>;
class AsyncFormValidator<T extends Record<string, any>> {
private syncRules: { [K in keyof T]?: ValidationRule<T[K]>[] } = {};
private asyncRules: { [K in keyof T]?: AsyncValidationRule<T[K]>[] } = {};
field<K extends keyof T>(
key: K,
syncValidators: ValidationRule<T[K]>[],
asyncValidators?: AsyncValidationRule<T[K]>[]
): this {
this.syncRules[key] = syncValidators;
if (asyncValidators) this.asyncRules[key] = asyncValidators;
return this;
}
async validateField<K extends keyof T>(key: K, value: T[K]): Promise<string[]> {
// 先执行同步验证
const syncErrors = (this.syncRules[key] ?? [])
.map((rule) => rule(value))
.filter((error): error is string => error !== null);
if (syncErrors.length > 0) return syncErrors;
// 同步验证通过后执行异步验证
const asyncErrors = await Promise.all((this.asyncRules[key] ?? []).map((rule) => rule(value)));
return asyncErrors.filter((error): error is string => error !== null);
}
async validate(data: FormData<T>): Promise<FormErrors<T>> {
const errors: FormErrors<T> = {};
for (const key in { ...this.syncRules, ...this.asyncRules }) {
const fieldErrors = await this.validateField(key, data[key]);
if (fieldErrors.length > 0) {
errors[key] = fieldErrors;
}
}
return errors;
}
}
// 使用:用户名唯一性检查
const uniqueUsername: AsyncValidationRule<string> = async (value) => {
const response = await fetch(`/api/users/check?username=${value}`);
const { available } = await response.json();
return available ? null : '用户名已被占用';
};
const validator = new AsyncFormValidator<RegisterForm>()
.field('username', [required(), minLength(3)], [uniqueUsername])
.field('email', [required(), email()]);
条件验证
// 条件验证:根据其他字段的值决定是否验证
type ConditionalRule<T> = {
dependsOn: keyof T;
condition: (value: T[keyof T]) => boolean;
rules: ValidationRule<any>[];
};
class ConditionalFormValidator<T extends Record<string, any>> {
private rules: { [K in keyof T]?: ValidationRule<T[K]>[] } = {};
private conditionalRules: ConditionalRule<T>[] = [];
field<K extends keyof T>(key: K, ...validators: ValidationRule<T[K]>[]): this {
this.rules[key] = validators;
return this;
}
when<K extends keyof T>(
dependsOn: K,
condition: (value: T[K]) => boolean,
fieldRules: { [P in keyof T]?: ValidationRule<T[P]>[] }
): this {
for (const [field, validators] of Object.entries(fieldRules)) {
if (validators) {
this.conditionalRules.push({
dependsOn,
condition: condition as any,
rules: validators,
});
}
}
return this;
}
validate(data: FormData<T>): FormErrors<T> {
const errors: FormErrors<T> = {};
// 基础验证
for (const key in this.rules) {
const fieldErrors = (this.rules[key] ?? [])
.map((rule) => rule(data[key]))
.filter((error): error is string => error !== null);
if (fieldErrors.length > 0) errors[key] = fieldErrors;
}
// 条件验证
for (const rule of this.conditionalRules) {
if (rule.condition(data[rule.dependsOn])) {
const fieldErrors = rule.rules
.map((r) => r(data[Object.keys(this.rules).find(() => true) as keyof T]))
.filter((error): error is string => error !== null);
if (fieldErrors.length > 0) {
// 合并条件验证错误
}
}
}
return errors;
}
}
常见场景
动态表单
// 动态表单:字段在运行时确定
interface DynamicField {
name: string;
type: 'text' | 'number' | 'email' | 'select';
label: string;
required: boolean;
rules: ValidationRule<any>[];
options?: { label: string; value: string }[];
}
class DynamicFormValidator {
private fields: DynamicField[] = [];
addField(field: DynamicField): this {
this.fields.push(field);
return this;
}
validate(data: Record<string, any>): Record<string, string[]> {
const errors: Record<string, string[]> = {};
for (const field of this.fields) {
const value = data[field.name];
const fieldErrors = field.rules
.map((rule) => rule(value))
.filter((error): error is string => error !== null);
if (fieldErrors.length > 0) {
errors[field.name] = fieldErrors;
}
}
return errors;
}
getDefaults(): Record<string, any> {
return Object.fromEntries(
this.fields.map((field) => [field.name, field.type === 'number' ? 0 : ''])
);
}
}
注意事项
- 验证时机:表单验证应在适当的时机执行(提交时、字段失焦时、实时验证等)。过于频繁的验证会影响用户体验和性能。
- 错误信息国际化:验证错误信息应支持国际化。将错误消息模板与验证逻辑分离,方便翻译。
- 性能优化:异步验证应添加防抖(debounce),避免频繁请求服务器。可以使用 AbortController 取消过期的验证请求。
- 类型安全与运行时:TypeScript 的类型安全仅在编译时有效。运行时仍需验证数据格式,特别是来自 API 的数据。
进阶用法
Zod Schema 集成
import { z } from 'zod';
// 使用 Zod 定义表单 Schema
const registerSchema = z
.object({
username: z.string().min(3, '最少3个字符').max(20, '最多20个字符'),
email: z.string().email('请输入有效的邮箱地址'),
age: z.number().min(1, '年龄必须大于0').max(150, '年龄不能超过150'),
password: z.string().min(8, '密码至少8位'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: '两次密码不一致',
path: ['confirmPassword'],
});
type RegisterFormData = z.infer<typeof registerSchema>;
// 验证
function validateWithZod(data: unknown) {
const result = registerSchema.safeParse(data);
if (!result.success) {
// 将 Zod 错误转换为表单错误格式
const errors: Record<string, string[]> = {};
for (const issue of result.error.issues) {
const path = issue.path.join('.');
if (!errors[path]) errors[path] = [];
errors[path].push(issue.message);
}
return { success: false, errors };
}
return { success: true, data: result.data };
}