表单是 Web 应用里用户与系统交互最频繁的地方,而前端表单验证直接决定了录入数据的质量与提交体验。很多团队要么把所有校验都推给后端兜底,要么在提交函数里堆满 if-else,导致代码难维护、错误提示也不友好。本文从浏览器自带的 Constraint Validation API 讲起,逐步过渡到用 zod 做 schema 驱动的校验,并给出在 Vue 3、React 中可直接复用的封装思路。
一、前端表单验证到底解决什么问题
在动手写校验逻辑之前,先明确它的边界:
- 体验层:即时反馈,用户不用等提交后才发现错在哪,减少挫败感。
- 减负层:拦截明显非法输入,减少无效请求,保护后端接口。
- 非安全层:前端校验永远可以被绕过,它只是防线之一,服务端二次校验不可省。
二、浏览器原生 Constraint Validation API
HTML5 自带一组校验属性:required、type、min/max、pattern、minlength/maxlength。浏览器会在提交时自动拦截并弹出气泡,无需写一行 JS。
<form id="signup" novalidate>
<input name="email" type="email" required placeholder="邮箱">
<input name="age" type="number" min="18" max="120" required>
<input name="phone" pattern="1[3-9]\d{9}" required
title="请填写 11 位手机号">
<button type="submit">注册</button>
</form>
当字段变多、还要和接口类型对齐时,光靠属性不够,可以用 JS API 主动控制:checkValidity() 返回布尔值、reportValidity() 触发气泡、validity 对象暴露具体错误原因、setCustomValidity() 写自定义错误。
const form = document.getElementById('signup');
form.addEventListener('submit', (e) => {
if (!form.checkValidity()) {
e.preventDefault();
form.reportValidity(); // 让浏览器弹出内置气泡
}
});
const email = form.email;
if (!email.validity.valid) {
console.log(email.validationMessage); // 浏览器生成的错误文案
}
三、自定义错误提示,告别默认气泡
默认气泡样式不可控、中英文案也粗糙。可以关闭默认气泡,自己渲染错误,并同步无障碍属性:
email.addEventListener('invalid', (e) => {
e.preventDefault(); // 阻止默认气泡
const msg = document.getElementById('email-err');
if (email.validity.valueMissing) msg.textContent = '邮箱不能为空';
else if (email.validity.typeMismatch) msg.textContent = '邮箱格式不正确';
email.setAttribute('aria-invalid', 'true');
});
这里的 aria-invalid 与错误文案关联,正是前端无障碍实战里强调的「让读屏也能读到错误」的做法,二者天然契合。
四、用 zod 做 schema 驱动校验
当字段变多、还要和接口类型对齐时,手写校验极易出错。zod 用一份 schema 同时约束运行时值与 TypeScript 类型,成为表单的单一数据源:
import { z } from 'zod';
const signupSchema = z.object({
email: z.string().email('邮箱格式不正确'),
age: z.coerce.number().min(18, '需年满 18 岁').max(120, '年龄超出范围'),
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
});
const result = signupSchema.safeParse({ email, age, phone });
if (!result.success) {
// result.error.issues 是结构化错误,直接映射到字段即可
result.error.issues.forEach(i => {
errors[i.path[0]] = i.message;
});
}
与单纯做类型校验不同,这里把 schema 同时服务 UI 展示、提交校验和接口入参,避免「前端一套规则、后端另一套规则」的漂移。
五、异步与远程校验:用户名是否被占用
同步规则之外,常需要查服务端确认用户名或邮箱是否已被注册。务必加防抖,避免每次按键都打接口:
let timer;
username.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(async () => {
const taken = await fetch(`/api/exists?u=${username.value}`)
.then(r => r.json());
if (taken) username.setCustomValidity('该用户名已被占用');
else username.setCustomValidity(''); // 清空自定义错误
}, 400);
});
六、在 Vue 3 / React 中落地
把校验逻辑抽成组合式函数,组件只关心字段与规则,不关心校验细节:
// useForm.ts
import { reactive } from 'vue';
import { z } from 'zod';
export function useForm(schema: z.ZodTypeAny, initial: Record<string, any>) {
const model = reactive({ ...initial });
const errors = reactive<Record<string, string>>({});
const validate = () => {
const r = schema.safeParse(model);
Object.keys(errors).forEach(k => delete errors[k]);
if (!r.success) {
r.error.issues.forEach(i => { errors[i.path[0]] = i.message; });
}
return r.success;
};
return { model, errors, validate };
}
React 侧思路一致:用 useState 保存 model 与 errors,在 onBlur 单字段校验、onSubmit 整体校验时调用同一份 schema.safeParse。如果项目已经用了Vue 3 状态管理方案,也可以把表单 model 放进 store 统一管理。
七、可访问性:别让错误信息读屏读不到
校验不止是「显示红字」,还要让辅助技术感知到:
- 输入框加
aria-invalid="true",校验通过再移除。 - 错误文案用
aria-describedby关联到对应输入框。 - 表单整体校验失败时,把焦点移到第一个错误字段,而不是静默滚动。
八、常见坑与最佳实践
- 中文输入法 composition:组字过程中的
input事件会误触发校验,应监听compositionstart/compositionend跳过中间态。 - 校验时机:
onBlur做单字段即时校验,onSubmit做整体校验,体验最顺。 - 服务端兜底:前端再严谨也只是体验层,后端必须再校验一次,绝不信任客户端。
- 别迷信 type=number:范围校验仍要后端复核,且移动端弹出的数字键盘与
min/max并非强制。
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 原生 Constraint Validation | 简单表单、快速上线 | 零依赖、浏览器内置 | 样式难定制、复杂规则乏力 |
| zod 等 schema 库 | 中后台、字段多、需类型对齐 | 单一数据源、可复用 | 需引入依赖、要自己接 UI |
| 框架表单库(RHF / Formik / vee-validate) | 大型表单、复杂联动 | 生态完整、状态管好 | 学习成本、包体积增加 |
九、落地清单
- 简单页面:直接用原生 API,零成本上手。
- 字段较多:引入 zod 统一 schema,UI 与接口共用。
- 错误提示:接
aria-*,照顾无障碍用户。 - 远程校验:务必加防抖,控制请求频率。
- 服务端:永远再校验一次,前端只是体验层。
十、处理中文输入法与防重复提交
在中文环境下,用户用输入法组字时,浏览器会频繁触发 input 事件。如果此时立刻校验,就会出现「拼音还没打完就满屏报红」的尴尬。正确做法是监听 compositionstart 与 compositionend 来标记组字状态,组字期间跳过校验,组字结束后再补一次。除此之外,表单提交后应立即禁用提交按钮或加一个布尔锁,防止网络慢时用户连点造成重复请求,这一点在弱网环境尤其重要。
let composing = false;
const name = document.getElementById('name');
name.addEventListener('compositionstart', () => { composing = true; });
name.addEventListener('compositionend', () => {
composing = false;
validateField(name); // 组字结束后再校验
});
name.addEventListener('input', () => {
if (!composing) validateField(name);
});
let submitting = false;
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (submitting) return; // 防重复提交
if (!validateAll()) return;
submitting = true;
submitBtn.disabled = true;
await send(new FormData(form));
submitting = false;
submitBtn.disabled = false;
});
十一、一个最小可运行示例
把前面分散的知识点串起来:用原生事件触发校验,用 zod 做规则中枢,再用 setCustomValidity 把错误写回原生 UI。下面这段不依赖任何框架,拷贝即可运行,适合作为你项目的起步模板:
import { z } from 'zod';
const schema = z.object({
email: z.string().email('邮箱格式不正确'),
age: z.coerce.number().min(18, '需年满 18 岁'),
});
form.addEventListener('submit', (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(form).entries());
const r = schema.safeParse(data);
if (!r.success) {
r.error.issues.forEach(i => {
const el = form.elements[i.path[0]];
el.setCustomValidity(i.message);
el.reportValidity();
});
return;
}
console.log('校验通过,提交', r.data);
});
十二、给校验规则写单元测试
校验规则本质是纯函数,非常适合写单测。把 schema 抽到独立文件后,用 vitest 对其测试,能防止后续有人改规则时悄悄放过期数据进入系统。这也是「单一数据源」带来的额外红利——同一份 schema 既服务运行时,也服务测试。
import { describe, it, expect } from 'vitest';
import { signupSchema } from './schema';
describe('signupSchema', () => {
it('拒绝非法邮箱', () => {
const r = signupSchema.safeParse({ email: 'a@', age: 20, phone: '13800000000' });
expect(r.success).toBe(false);
});
it('接受合法数据', () => {
const r = signupSchema.safeParse({ email: 'a@b.com', age: 20, phone: '13800000000' });
expect(r.success).toBe(true);
});
});
表单验证没有银弹,但「原生 API 打底、zod 统一规则、框架做封装、无障碍做兜底、服务端做终审」这条链路,基本能覆盖绝大多数业务场景。把这套思路沉淀成团队内部的 useForm 或自定义 hook,新页面就能开箱即用。




