前端表单验证实战:从原生 Constraint Validation 到 zod 集成

表单是 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,新页面就能开箱即用。

上一篇 数据库分库分表实战:从垂直拆分到分片键设计
下一篇 Rust 实战:所有权、借用与生命周期
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]