Appearance
表单验证与错误处理
学习目标
通过本章学习,你将掌握:
- 客户端验证实现
- 服务端验证策略
- 错误消息展示
- 实时验证技术
- 多步表单验证
- 自定义验证规则
- 验证库集成
- 用户体验优化
第一部分:验证基础
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. 异步:检查唯一性完善的验证和错误处理是优秀表单的基础!