Skip to content

表单验证与错误处理

学习目标

通过本章学习,你将掌握:

  • 客户端验证实现
  • 服务端验证策略
  • 错误消息展示
  • 实时验证技术
  • 多步表单验证
  • 自定义验证规则
  • 验证库集成
  • 用户体验优化

第一部分:验证基础

1.1 客户端验证

jsx
'use client';

import { useState } from 'react';

function ContactForm() {
  const [errors, setErrors] = useState({});
  
  const validateField = (name, value) => {
    const newErrors = { ...errors };
    
    switch (name) {
      case 'email':
        if (!value) {
          newErrors.email = '邮箱不能为空';
        } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
          newErrors.email = '邮箱格式不正确';
        } else {
          delete newErrors.email;
        }
        break;
      
      case 'name':
        if (!value) {
          newErrors.name = '姓名不能为空';
        } else if (value.length < 2) {
          newErrors.name = '姓名至少2个字符';
        } else {
          delete newErrors.name;
        }
        break;
      
      case 'message':
        if (!value) {
          newErrors.message = '留言不能为空';
        } else if (value.length < 10) {
          newErrors.message = '留言至少10个字符';
        } else {
          delete newErrors.message;
        }
        break;
    }
    
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    
    const formData = new FormData(e.target);
    let isValid = true;
    
    for (const [name, value] of formData.entries()) {
      if (!validateField(name, value)) {
        isValid = false;
      }
    }
    
    if (!isValid) {
      return;
    }
    
    const result = await submitContact(formData);
    
    if (!result.success) {
      setErrors(result.errors || {});
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>姓名</label>
        <input
          name="name"
          onBlur={(e) => validateField('name', e.target.value)}
        />
        {errors.name && <span className="error">{errors.name}</span>}
      </div>
      
      <div>
        <label>邮箱</label>
        <input
          name="email"
          type="email"
          onBlur={(e) => validateField('email', e.target.value)}
        />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>
      
      <div>
        <label>留言</label>
        <textarea
          name="message"
          onBlur={(e) => validateField('message', e.target.value)}
        />
        {errors.message && <span className="error">{errors.message}</span>}
      </div>
      
      <button type="submit">提交</button>
    </form>
  );
}

1.2 服务端验证

javascript
'use server';

export async function submitContact(formData) {
  const name = formData.get('name');
  const email = formData.get('email');
  const message = formData.get('message');
  
  const errors = {};
  
  if (!name || typeof name !== 'string') {
    errors.name = '姓名不能为空';
  } else if (name.length < 2) {
    errors.name = '姓名至少2个字符';
  } else if (name.length > 50) {
    errors.name = '姓名不能超过50个字符';
  }
  
  if (!email || typeof email !== 'string') {
    errors.email = '邮箱不能为空';
  } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    errors.email = '邮箱格式不正确';
  }
  
  if (!message || typeof message !== 'string') {
    errors.message = '留言不能为空';
  } else if (message.length < 10) {
    errors.message = '留言至少10个字符';
  } else if (message.length > 1000) {
    errors.message = '留言不能超过1000个字符';
  }
  
  if (Object.keys(errors).length > 0) {
    return {
      success: false,
      errors
    };
  }
  
  await db.contacts.create({
    data: { name, email, message }
  });
  
  return {
    success: true,
    message: '提交成功!'
  };
}

1.3 双重验证

jsx
// Server Action
'use server';

function validateEmail(email) {
  if (!email) return '邮箱不能为空';
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return '邮箱格式不正确';
  }
  return null;
}

function validatePassword(password) {
  if (!password) return '密码不能为空';
  if (password.length < 8) return '密码至少8个字符';
  if (!/[A-Z]/.test(password)) return '密码必须包含大写字母';
  if (!/[a-z]/.test(password)) return '密码必须包含小写字母';
  if (!/[0-9]/.test(password)) return '密码必须包含数字';
  return null;
}

export async function register(formData) {
  const email = formData.get('email');
  const password = formData.get('password');
  
  const errors = {};
  
  const emailError = validateEmail(email);
  if (emailError) errors.email = emailError;
  
  const passwordError = validatePassword(password);
  if (passwordError) errors.password = passwordError;
  
  if (Object.keys(errors).length > 0) {
    return { success: false, errors };
  }
  
  const existingUser = await db.users.findUnique({
    where: { email }
  });
  
  if (existingUser) {
    return {
      success: false,
      errors: { email: '邮箱已被注册' }
    };
  }
  
  await db.users.create({
    data: {
      email,
      password: await hashPassword(password)
    }
  });
  
  return { success: true };
}

// Client Component
'use client';

import { useActionState } from 'react';

// 共享验证逻辑
function validateEmailClient(email) {
  if (!email) return '邮箱不能为空';
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return '邮箱格式不正确';
  }
  return null;
}

function RegisterForm() {
  const [state, formAction] = useActionState(register, { success: false });
  const [clientErrors, setClientErrors] = useState({});
  
  const handleBlur = (e) => {
    const { name, value } = e.target;
    
    if (name === 'email') {
      const error = validateEmailClient(value);
      setClientErrors(prev => ({
        ...prev,
        email: error
      }));
    }
  };
  
  return (
    <form action={formAction}>
      <div>
        <label>邮箱</label>
        <input
          name="email"
          onBlur={handleBlur}
        />
        {(clientErrors.email || state.errors?.email) && (
          <span className="error">
            {clientErrors.email || state.errors?.email}
          </span>
        )}
      </div>
      
      <div>
        <label>密码</label>
        <input
          name="password"
          type="password"
        />
        {state.errors?.password && (
          <span className="error">{state.errors.password}</span>
        )}
      </div>
      
      <button type="submit">注册</button>
    </form>
  );
}

第二部分:使用验证库

2.1 Zod集成

javascript
// lib/validations.js
import { z } from 'zod';

export const contactSchema = z.object({
  name: z.string()
    .min(2, '姓名至少2个字符')
    .max(50, '姓名不能超过50个字符'),
  email: z.string()
    .email('邮箱格式不正确'),
  message: z.string()
    .min(10, '留言至少10个字符')
    .max(1000, '留言不能超过1000个字符')
});

export const postSchema = z.object({
  title: z.string()
    .min(3, '标题至少3个字符')
    .max(200, '标题不能超过200个字符'),
  content: z.string()
    .min(100, '内容至少100个字符')
    .max(10000, '内容不能超过10000个字符'),
  tags: z.array(z.string())
    .max(5, '最多5个标签')
    .optional()
});

export const userSchema = z.object({
  email: z.string()
    .email('邮箱格式不正确'),
  password: z.string()
    .min(8, '密码至少8个字符')
    .regex(/[A-Z]/, '密码必须包含大写字母')
    .regex(/[a-z]/, '密码必须包含小写字母')
    .regex(/[0-9]/, '密码必须包含数字'),
  name: z.string()
    .min(2, '姓名至少2个字符')
    .max(50, '姓名不能超过50个字符')
});

// Server Action
'use server';

import { contactSchema } from '@/lib/validations';

export async function submitContact(formData) {
  const rawData = {
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message')
  };
  
  const result = contactSchema.safeParse(rawData);
  
  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors
    };
  }
  
  await db.contacts.create({
    data: result.data
  });
  
  return {
    success: true,
    message: '提交成功!'
  };
}

2.2 客户端Zod验证

jsx
'use client';

import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { contactSchema } from '@/lib/validations';

function ContactFormWithHookForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm({
    resolver: zodResolver(contactSchema)
  });
  
  const onSubmit = async (data) => {
    const formData = new FormData();
    Object.entries(data).forEach(([key, value]) => {
      formData.set(key, value);
    });
    
    const result = await submitContact(formData);
    
    if (result.success) {
      alert('提交成功!');
    }
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>姓名</label>
        <input {...register('name')} />
        {errors.name && (
          <span className="error">{errors.name.message}</span>
        )}
      </div>
      
      <div>
        <label>邮箱</label>
        <input {...register('email')} />
        {errors.email && (
          <span className="error">{errors.email.message}</span>
        )}
      </div>
      
      <div>
        <label>留言</label>
        <textarea {...register('message')} />
        {errors.message && (
          <span className="error">{errors.message.message}</span>
        )}
      </div>
      
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '提交中...' : '提交'}
      </button>
    </form>
  );
}

2.3 自定义验证规则

javascript
import { z } from 'zod';

const customPasswordSchema = z.string()
  .min(8, '密码至少8个字符')
  .refine(
    (password) => /[A-Z]/.test(password),
    '密码必须包含大写字母'
  )
  .refine(
    (password) => /[a-z]/.test(password),
    '密码必须包含小写字母'
  )
  .refine(
    (password) => /[0-9]/.test(password),
    '密码必须包含数字'
  )
  .refine(
    (password) => /[^A-Za-z0-9]/.test(password),
    '密码必须包含特殊字符'
  );

const uniqueEmailSchema = z.string()
  .email('邮箱格式不正确')
  .refine(
    async (email) => {
      const user = await db.users.findUnique({
        where: { email }
      });
      return !user;
    },
    '邮箱已被注册'
  );

const passwordMatchSchema = z.object({
  password: z.string().min(8, '密码至少8个字符'),
  confirmPassword: z.string()
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: '两次密码不一致',
    path: ['confirmPassword']
  }
);

第三部分:实时验证

3.1 输入时验证

jsx
'use client';

import { useState } from 'react';

function RealTimeValidation() {
  const [formData, setFormData] = useState({
    email: '',
    password: ''
  });
  
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  
  const validateField = (name, value) => {
    let error = '';
    
    switch (name) {
      case 'email':
        if (!value) {
          error = '邮箱不能为空';
        } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
          error = '邮箱格式不正确';
        }
        break;
      
      case 'password':
        if (!value) {
          error = '密码不能为空';
        } else if (value.length < 8) {
          error = '密码至少8个字符';
        }
        break;
    }
    
    setErrors(prev => ({
      ...prev,
      [name]: error
    }));
  };
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
    
    if (touched[name]) {
      validateField(name, value);
    }
  };
  
  const handleBlur = (e) => {
    const { name, value } = e.target;
    
    setTouched(prev => ({
      ...prev,
      [name]: true
    }));
    
    validateField(name, value);
  };
  
  return (
    <form>
      <div>
        <label>邮箱</label>
        <input
          name="email"
          value={formData.email}
          onChange={handleChange}
          onBlur={handleBlur}
          className={errors.email && touched.email ? 'error' : ''}
        />
        {errors.email && touched.email && (
          <span className="error">{errors.email}</span>
        )}
      </div>
      
      <div>
        <label>密码</label>
        <input
          name="password"
          type="password"
          value={formData.password}
          onChange={handleChange}
          onBlur={handleBlur}
          className={errors.password && touched.password ? 'error' : ''}
        />
        {errors.password && touched.password && (
          <span className="error">{errors.password}</span>
        )}
      </div>
      
      <button
        type="submit"
        disabled={Object.values(errors).some(e => e)}
      >
        提交
      </button>
    </form>
  );
}

3.2 防抖验证

jsx
'use client';

import { useState, useCallback } from 'react';

function useDebounce(callback, delay) {
  const [timeoutId, setTimeoutId] = useState(null);
  
  return useCallback((...args) => {
    if (timeoutId) {
      clearTimeout(timeoutId);
    }
    
    const id = setTimeout(() => {
      callback(...args);
    }, delay);
    
    setTimeoutId(id);
  }, [callback, delay, timeoutId]);
}

function DebouncedValidation() {
  const [email, setEmail] = useState('');
  const [emailError, setEmailError] = useState('');
  const [checking, setChecking] = useState(false);
  
  const checkEmailAvailability = async (email) => {
    if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      setEmailError('邮箱格式不正确');
      setChecking(false);
      return;
    }
    
    setChecking(true);
    
    const response = await fetch(`/api/check-email?email=${email}`);
    const data = await response.json();
    
    setEmailError(data.available ? '' : '邮箱已被注册');
    setChecking(false);
  };
  
  const debouncedCheck = useDebounce(checkEmailAvailability, 500);
  
  const handleChange = (e) => {
    const value = e.target.value;
    setEmail(value);
    debouncedCheck(value);
  };
  
  return (
    <div>
      <label>邮箱</label>
      <input
        value={email}
        onChange={handleChange}
        placeholder="your@email.com"
      />
      {checking && <span className="info">检查中...</span>}
      {!checking && emailError && (
        <span className="error">{emailError}</span>
      )}
      {!checking && email && !emailError && (
        <span className="success">邮箱可用</span>
      )}
    </div>
  );
}

3.3 异步验证

javascript
// Server Action
'use server';

export async function checkEmailAvailability(email) {
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return { available: false, error: '邮箱格式不正确' };
  }
  
  const user = await db.users.findUnique({
    where: { email }
  });
  
  return {
    available: !user,
    error: user ? '邮箱已被注册' : null
  };
}

// Client Component
'use client';

function AsyncValidation() {
  const [email, setEmail] = useState('');
  const [validation, setValidation] = useState({ checking: false });
  
  const handleBlur = async () => {
    if (!email) return;
    
    setValidation({ checking: true });
    
    const result = await checkEmailAvailability(email);
    
    setValidation({
      checking: false,
      available: result.available,
      error: result.error
    });
  };
  
  return (
    <div>
      <input
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        onBlur={handleBlur}
      />
      
      {validation.checking && (
        <span className="info">验证中...</span>
      )}
      
      {!validation.checking && validation.error && (
        <span className="error">{validation.error}</span>
      )}
      
      {!validation.checking && validation.available && (
        <span className="success">邮箱可用</span>
      )}
    </div>
  );
}

第四部分:错误展示

4.1 内联错误

jsx
'use client';

function InlineErrors() {
  const [state, formAction] = useActionState(submitForm, { errors: {} });
  
  return (
    <form action={formAction}>
      <div className="form-field">
        <label>邮箱</label>
        <input
          name="email"
          className={state.errors?.email ? 'error' : ''}
        />
        {state.errors?.email && (
          <span className="error-message">
            {state.errors.email}
          </span>
        )}
      </div>
      
      <div className="form-field">
        <label>密码</label>
        <input
          name="password"
          type="password"
          className={state.errors?.password ? 'error' : ''}
        />
        {state.errors?.password && (
          <span className="error-message">
            {state.errors.password}
          </span>
        )}
      </div>
      
      <button type="submit">提交</button>
    </form>
  );
}

/* CSS */
.form-field input.error {
  border-color: #dc3545;
}

.error-message {
  color: #dc3545;
  font-size: 0.875rem;
  margin-top: 0.25rem;
  display: block;
}

4.2 顶部错误摘要

jsx
'use client';

function ErrorSummary() {
  const [state, formAction] = useActionState(submitForm, { errors: {} });
  
  const errorCount = Object.keys(state.errors || {}).length;
  
  return (
    <form action={formAction}>
      {errorCount > 0 && (
        <div className="error-summary">
          <h3>表单包含 {errorCount} 个错误:</h3>
          <ul>
            {Object.entries(state.errors).map(([field, error]) => (
              <li key={field}>
                <a href={`#${field}`}>{error}</a>
              </li>
            ))}
          </ul>
        </div>
      )}
      
      <div id="email">
        <label>邮箱</label>
        <input name="email" />
        {state.errors?.email && (
          <span className="error">{state.errors.email}</span>
        )}
      </div>
      
      <div id="password">
        <label>密码</label>
        <input name="password" type="password" />
        {state.errors?.password && (
          <span className="error">{state.errors.password}</span>
        )}
      </div>
      
      <button type="submit">提交</button>
    </form>
  );
}

4.3 Toast通知

jsx
'use client';

import { toast } from 'react-hot-toast';

function ToastNotifications() {
  const handleSubmit = async (formData) => {
    const result = await submitForm(formData);
    
    if (result.success) {
      toast.success('提交成功!');
    } else if (result.errors) {
      Object.values(result.errors).forEach(error => {
        toast.error(error);
      });
    }
  };
  
  return (
    <form action={handleSubmit}>
      <input name="email" />
      <input name="password" type="password" />
      <button type="submit">提交</button>
    </form>
  );
}

注意事项

1. 客户端和服务端都要验证

javascript
// ❌ 只有客户端验证是不安全的
// 用户可以绕过客户端验证

// ✅ 双重验证
// 客户端:快速反馈
// 服务端:安全保障

2. 提供清晰的错误消息

javascript
// ❌ 不好
"Invalid input"

// ✅ 好
"邮箱格式不正确,请输入有效的邮箱地址"

3. 保留用户输入

jsx
// ✅ 验证失败后保留用户输入
<input 
  name="email"
  defaultValue={state.fields?.email}
/>

常见问题

Q1: 客户端验证和服务端验证哪个更重要?

A: 都重要。客户端提供即时反馈,服务端确保安全。

Q2: 何时进行验证?

A:

  • onChange: 实时反馈
  • onBlur: 失焦时验证
  • onSubmit: 提交前验证

Q3: 如何处理多语言错误消息?

A: 使用i18n库,将错误消息key传递给翻译函数。

总结

验证最佳实践

✅ 客户端和服务端双重验证
✅ 提供清晰的错误消息
✅ 实时反馈用户输入
✅ 保留用户已输入数据
✅ 使用验证库标准化
✅ 异步验证防抖处理
✅ 显示验证状态
✅ 可访问性友好

验证策略

1. 客户端:快速反馈
2. 服务端:安全保障
3. 实时:提升体验
4. 防抖:减少请求
5. 异步:检查唯一性

完善的验证和错误处理是优秀表单的基础!