表单验证完整实现:从原生原理拆解到工程化落地
表单验证这事,说简单是真简单——一个required属性、一段正则、一个if判断就完事。但说复杂也真复杂,我接手过的项目里,因为表单验证没做好导致线上出事故的案例一只手数不过来。有的是用户提交了非法数据直接打爆后端接口,有的是前后端校验规则不一致导致用户填了半天最后提交失败,还有的是密码校验写得稀烂,用户改个邮箱都要触发一堆诡异的联动报错。
这篇文章我不打算讲那种"5分钟学会表单验证"的入门教程,而是把我这些年在前端项目里沉淀下来的表单验证完整实现方案拆开揉碎讲清楚。从最初的原生实现,到规则数据结构的设计,再到第三方库的工程化选型,最后把实战中遇到的那些坑一个个列出来。无论是刚入门想建立完整认知的新手,还是已经在项目里被表单折磨得焦头烂额的老手,这篇文章应该都能给你一些启发。
1. 表单验证的本质与设计思路拆解
1.1 验证的四个层次:合法性、有效性、一致性、安全性
在做任何表单验证之前,必须先想清楚一个问题:你到底在验证什么?很多同学上来就写正则,邮箱正则写一个、手机号正则写一个,然后完事大吉。这种思路其实是把表单验证简单化了,真正的表单验证至少包含四个层次。
合法性是最基础的,就是验证用户输入的内容在格式上是否符合预期。邮箱要有@符号,手机号要是 11 位数字,日期要符合YYYY-MM-DD的格式,这些属于合法性验证。我在实际项目里见过太多因为正则写得太宽松或者太严格导致的问题,宽松了垃圾数据进去了,严格了把正常用户挡在外面。
有效性比合法性高一个层次,它验证的是这个值在业务上能不能用。举个例子,用户注册时填写的用户名,格式合法了(比如4到16位字符),但这个名字已经被别人注册了,这就是有效性问题。再比如用户提交的优惠券码格式完全正确,但已经过期了,这也是有效性验证。有效性验证通常需要和业务逻辑交互,甚至需要调用后端接口来判断。
一致性验证则比较特殊,它关心的是多个字段之间的关系。经典的场景是"确认密码"要和"密码"一致,还有"所在城市"要根据"所在省份"联动,"结束日期"不能早于"开始日期"。这类验证用简单的单字段校验是做不了的,需要设计成跨字段的规则。
安全性验证是很多人最容易忽略的。我在安全应急响应里看到过不少因为表单验证不到位被攻击的案例。比如用户在评论框里提交了恶意代码,比如用户在上传头像时传了一个伪装成图片的木马文件,比如在搜索框里输入了超长的字符串直接把后端内存打爆。前端表单验证在安全性这个层面能做的有限,但至少要做好长度限制、内容过滤、特殊字符处理这些基础工作。
想清楚了这四个层次,再去设计你的验证逻辑,就会比拿到需求就开写要靠谱得多。
1.2 前端验证与后端验证的分工:体验归前端,安全归后端
这是我在团队里反复强调的一个原则:前端验证是为了用户体验而生,后端验证才是安全性的守门员。前端的验证可以跳过(改掉页面的 JS 或者直接用 curl 提交),所以它永远不能替代后端验证。
前端验证的目标是在用户填表的那一刻给出即时反馈,让用户在提交之前就知道自己填得对不对,免去了提交后被弹回来重新填写的痛苦。后端验证的目标则是防御一切非法数据,不管数据是从哪个渠道来的,都必须在服务端进行严格的校验,确保只有合法数据才能进入业务逻辑和数据库。
在工程实践中,我推荐的做法是前后端共享一套验证规则的定义。最简单的方案是后端定义好校验规则,通过接口下发,前端渲染表单时动态应用这些规则。复杂一点的方案是用 JSON Schema 作为一种标准,前端校验引擎和后端校验引擎都基于同一份 Schema 执行校验。这样设计前后端规则永远是一致的,不会出现前端说手机号合格、后端却拒绝提交的尴尬。
1.3 验证时机策略:onChange、onBlur 与 onSubmit 的三段式设计
验证时机是表单体验的分水岭。用好了,用户觉得这个表单很聪明;用不好,用户觉得这个表单在找茬。
我在项目里的默认策略是"三段式":未触碰过的字段不校验,触碰后离开时做单字段校验(onBlur),提交时做全量校验(onSubmit),校验通过后如果用户继续修改则实时校验(onChange)。这个策略的核心思想是不打扰。用户刚开始填表时,表单是安静的,不会因为在邮箱框里打了一个字符就弹出一排红字。但当用户填完一个字段准备进入下一个字段时,就需要给出及时反馈,告诉用户刚才填的内容是否正确。最后在提交时,把所有字段一次性校验一遍,已经标红的地方保持标红,没标红的地方如果有错误也要立刻呈现出来。
onChange 实时校验是个双刃剑,用得好体验极佳,用不好就会出现"用户在输入过程中报错信息疯狂跳变"的糟糕体验。我这里的经验是,onChange 校验只在字段已经被标记为"已提交过"之后才启用,也就是用户点过提交按钮之后,如果表单还有错误,此时每个字段的输入变化都要实时重新校验,让用户看到错误在一点点消失,这种正向反馈对完成表单非常有帮助。
2. 原生实现一套可用的表单验证方案
2.1 规则声明式设计:把校验逻辑与业务逻辑分离
如果你只是需要一个通用的校验库,直接引入第三方即可,比如 async-validator 或者 yup。但如果你的业务比较复杂,或者你想真正理解表单验证的原理,那我强烈建议你至少手写一遍核心逻辑。
我手写方案时,首先做的事情是定义一套声明式的规则描述结构。所谓声明式,就是让开发者通过配置来描述"这个字段该怎么校验",而不是在 JS 里写满if...else...。
const rules = { username: [ { required: true, message: '请输入用户名', trigger: 'blur' }, { min: 4, max: 16, message: '用户名长度需在4到16个字符之间', trigger: 'blur' }, { pattern: /^[a-zA-Z0-9_-]+$/, message: '用户名只能包含字母、数字、下划线和连字符', trigger: 'blur' } ], email: [ { required: true, message: '请输入邮箱地址', trigger: 'blur' }, { type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' } ], password: [ { required: true, message: '请输入密码', trigger: 'blur' }, { min: 8, max: 32, message: '密码长度需在8到32个字符之间', trigger: 'blur' }, { validator: (rule, value) => { if (!/[a-z]/.test(value) || !/[A-Z]/.test(value) || !/\d/.test(value)) { return Promise.reject('密码需包含大小写字母和数字'); } return Promise.resolve(); }, trigger: 'blur' } ], confirmPassword: [ { required: true, message: '请再次输入密码', trigger: 'blur' }, { validator: (rule, value, callback, formData) => { if (value !== formData.password) { return Promise.reject('两次输入的密码不一致'); } return Promise.resolve(); }, trigger: 'blur' } ] };这种结构的好处是非常直观:一个字段对应一个规则数组,每条规则包含校验条件(required、min、max、pattern、validator)和错误信息,以及触发时机。规则是纯数据,可以在任意地方被引用,甚至可以序列化传输到后端共享。
2.2 校验引擎实现:核心循环与错误收集机制
有了规则定义,就需要一个校验引擎来执行。这个引擎的核心逻辑其实很简单:遍历表单的所有字段,对每个字段按顺序执行它的规则数组,一旦遇到校验失败就记录错误信息,并且可以选择是立即终止该字段的后续规则还是继续执行——我推荐的方式是默认终止,因为如果第一条规则就已经失败了,后面的规则大概率也是徒劳,用户只需要看到最重要的那一条错误即可。
真正的校验引擎代码我简化后大概是这样的:
class FormValidator { constructor(rules, formData) { this.rules = rules; this.formData = formData; this.errors = {}; } async validateField(fieldName, trigger) { const fieldRules = this.rules[fieldName]; if (!fieldRules) return true; const value = this.formData[fieldName]; for (const rule of fieldRules) { if (trigger && rule.trigger && rule.trigger !== trigger) continue; const isValid = await this.executeRule(rule, value, fieldName); if (!isValid) { this.setError(fieldName, rule.message); return false; } } this.clearError(fieldName); return true; } async validateAll() { const fields = Object.keys(this.rules); let isValid = true; for (const field of fields) { const fieldValid = await this.validateField(field); if (!fieldValid) isValid = false; } return isValid; } async executeRule(rule, value, fieldName) { if (rule.required && (value === undefined || value === null || value === '')) { return false; } if (rule.min && typeof value === 'string' && value.length < rule.min) return false; if (rule.max && typeof value === 'string' && value.length > rule.max) return false; if (rule.pattern && typeof value === 'string' && !rule.pattern.test(value)) return false; if (rule.validator) { try { await rule.validator(rule, value, null, this.formData); return true; } catch (e) { // validator 中 reject 的错误信息可覆盖默认 message if (typeof e === 'string') { this.setError(fieldName, e); } return false; } } return true; } setError(field, message) { if (!this.errors[field]) this.errors[field] = []; this.errors[field].push(message); } clearError(field) { delete this.errors[field]; } getFieldError(field) { return this.errors[field] ? this.errors[field][0] : ''; } }这套引擎的设计可以支撑从"简单必填"到"异步校验"到"跨字段联动校验"的大部分场景。本质上它是把校验逻辑抽象成了一个个独立的规则单元,然后按顺序执行、聚合结果。
2.3 错误信息呈现交互:不骚扰、不遗漏、可恢复
交互体验是表单验证的灵魂。同一个校验逻辑,不同的呈现方式给用户的感受完全不同。
我的默认方案是在每个表单字段下方预留一行错误信息的位置,字体用红色(或者符合设计规范的颜色),字号稍小,字段边框在出错时变红,通过aria-describedby属性建立错误信息与输入框的关联,方便读屏软件播报。
这里有几个值得注意的细节。第一,错误信息不能挤占布局空间,否则表单会出现"跳动"的视觉问题——字段下方一会儿有红字一会儿没有,整个页面跟着上下抖动。我的方案是始终预留一行空间,没有错误时显示为空白,有错误时填入文案,保证布局稳定。第二,错误信息的措辞也很重要,"请输入有效的邮箱地址" 比 "邮箱格式错误" 更能让用户理解该怎么做,这种微小的文案差异对转化率的影响有时候比你想象的大得多。第三,错误信息最好支持定位——用户点击提交按钮后,如果有未通过的字段,页面要自动滚动到第一个错误字段并聚焦,让用户知道问题出在哪里。
3. 工程化方案的选型与实践:从原生到第三方库的迁移路径
3.1 主流验证方案对比:async-validator、yup、zod、joi
自己手写验证引擎是理解原理的最好方式,但在实际项目落地时,我通常不会直接使用自己写的裸方案,因为工程化需要的远不止"校验一下"这么简单。项目要求你处理异步校验的竞态、错误信息的国际化、与组件库的深度集成、开发时的类型提示,这些如果全部自己实现,要投入的精力会远超预期。
目前主流的前端表单验证方案大致有这几类。async-validator是蚂蚁金服开源的校验库,antd 和 Element UI 这些组件库内部都在用,API 风格和我在上面写的声明式规则很像,学习成本低,文档齐全。yup是纯函数式的 schema 校验库,API 设计非常优雅,可以直接和react-hook-form配合使用,写起来有一种"声明即所得"的畅快感。zod则是近几年崛起的新贵,最大的卖点是 TypeScript 类型推导极其强大,一个 schema 既能当运行时校验器,又能当静态类型定义,真正做到了一次定义、处处使用。joi是老牌的 Node.js 校验库,生态成熟,但在浏览器端体积偏大,更适合服务端用。
我用一张表把它们的核心差异列出来:
| 方案 | 依赖体积 | TypeScript 支持 | 异步校验 | 生态集成 | 适用场景 |
|---|---|---|---|---|---|
| async-validator | 较小 | 中等 | 支持但较原始 | 与 antd 深度融合 | 中后台、MVC 框架 |
| yup | 中等 | 较好 | 原生支持 | 与 react-hook-form 搭配极佳 | React 技术栈为主 |
| zod | 小 | 极强(类型推导) | 原生支持 | 与 tRPC、react-hook-form 均有适配 | 全栈 TS 项目、对类型安全有执念的团队 |
| joi | 较大 | 好 | 原生支持 | 服务端生态成熟 | 纯 Node 端、API 参数校验 |
如果你在项目里有任何框架层面的限制,比如你的组件库是 antd,那 async-validator 就是最顺滑的选择,因为它已经被内置在各种组件库内部,不需要再引入一套新东西。如果你的项目是 React 技术栈且没有历史包袱,我推荐react-hook-form配合zod的组合,这套方案我用下来在体验和性能上都很满意。
3.2 工程化配置一个 React 表单验证的完整案例
这里我给出一个 React + react-hook-form + zod 的完整配置文件,这是一个注册页面,包含用户名、邮箱、密码和确认密码,并且集成了异步的"用户名是否已被注册"校验。
import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; // 定义表单数据类型 type RegisterForm = { username: string; email: string; password: string; confirmPassword: string; }; // 定义校验规则 const registerSchema = z.object({ username: z .string() .min(4, '用户名至少4个字符') .max(16, '用户名最多16个字符') .regex(/^[a-zA-Z0-9_-]+$/, '用户名只能包含字母、数字、下划线、连字符'), email: z.string().email('请输入有效的邮箱地址'), password: z .string() .min(8, '密码至少8个字符') .regex(/[a-z]/, '密码需包含小写字母') .regex(/[A-Z]/, '密码需包含大写字母') .regex(/\d/, '密码需包含数字'), confirmPassword: z.string() }).refine((data) => data.password === data.confirmPassword, { message: '两次输入的密码不一致', path: ['confirmPassword'] }); function RegisterForm() { const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<RegisterForm>({ resolver: zodResolver(registerSchema), defaultValues: { username: '', email: '', password: '', confirmPassword: '' } }); // 异步校验用户名是否可用 const validateUsernameAvailable = async (username: string) => { if (!username) return true; // 模拟接口校验,实际项目中替换为真实请求 const res = await fetch(`/api/check-username?name=${encodeURIComponent(username)}`); return res.ok; }; return ( <form onSubmit={handleSubmit((data) => console.log('提交数据', data))}> <div> <input {...register('username', { validate: validateUsernameAvailable ? undefined : undefined })} /> {errors.username && <p>{errors.username.message}</p>} </div> <div> <input {...register('email')} /> {errors.email && <p>{errors.email.message}</p>} </div> <div> <input type="password" {...register('password')} /> {errors.password && <p>{errors.password.message}</p>} </div> <div> <input type="password" {...register('confirmPassword')} /> {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>} </div> <button type="submit" disabled={isSubmitting}> {isSubmitting ? '提交中...' : '注册'} </button> </form> ); }这个方案的优点在于:校验规则被声明为一个zodschema,和组件逻辑彻底分离,可以单独进行单元测试;zodResolver自动完成数据校验并映射到errors对象,代码量大幅减少;类型安全上,useForm<RegisterForm>让所有字段和取值都具备完整的类型提示,改字段名时会让所有引用它的地方统统报错,再也不用手动查找替换。
3.3 手写方案还是引入第三方库:我在什么情况下怎么选
关于手写还是引库,每个团队都有不同的考量。我这里基于实际项目经验给出一个选择维度。
如果项目只有一两个表单,且字段简单(最多几个必填加一个邮箱格式),我会选择手写一套轻量的验证逻辑,配合组件库的 rules 属性,控制在 50 行以内解决问题,不需要引入任何额外的依赖。
如果项目表单较多但形态类似(都是几个字段、几个校验规则),我会封装一个通用的表单验证器,作为内部基础组件沉淀下来,这既是前面我分析的那套原生引擎的工程化升级版,也为后续项目建立统一的基础设施。
如果项目里表单形态多种多样,有动态表单项、有跨字段联动、有异步校验、有无障碍需求,那么直接引入成熟的第三方方案是唯一合理的选择。在 React 生态我用 react-hook-form + zod,在 Vue 生态我用 VeeValidate + yup,在 antd 项目里我用 async-validator。这些方案在性能、可维护性、社区案例上都已经过了大量生产环境的考验。
我的建议是:不要为了炫技而手写,也不要不加分析就引库。表单验证的本质是规则引擎,而规则引擎的正确性是靠大量测试和真实场景打磨出来的,第三方库的价值不仅仅是省代码,更是它的测试覆盖和社区踩坑经验。你引入的不只是一段代码,而是一群人的经验。
4. 实战中高频踩坑问题与排查技巧实录
4.1 前后端规则不一致引发的幽灵问题
先说一个真实踩坑案例。之前做一个电商项目,前端用正则校验手机号是/^1[3-9]\d{9}$/,可以正常通过。但后端校验逻辑写的是验证 11 位数字且以 1 开头。用户在后端测试时发现有个手机号19912345678前端能过,后端能过,但后面发现这个号段不是有效号段,被运营商限制,于是所有走了这套规则的用户在真正下单时全部失败。最后排查发现是前端规则比后端多过滤了一些号段,而规则又不透明,各改各的,导致两边维护出了断层。
这个问题的最佳解法就是我在前面提到过的——前后端共享一套规则。如果无法做全端共享,至少要在后端保留完整的校验,前端只做基础校验和体验增强,永远不要让前端规则成为数据的最终判断者。我后来在项目里推动了一套规范:任何字段的校验规则都以服务端接口文档为准,前端的每个校验规则必须标注对应的文档编号,两个版本出现分歧时直接以文档和接口实际行为为准。
4.2 异步校验的竞态问题:防抖与请求顺序控制
异步校验是表单验证里最容易被忽略又最容易出 bug 的环节。比如用户名是否唯一,需要防抖后发送请求给后端判断。防抖本身还好解决,真正麻烦的是请求竞态。用户在用户名框里先输入了 "nice",又快速改成 "nonice",前面两个请求都发了出去。如果第一个请求比第二个请求返回还慢,那么第二次校验结果会被第一次覆盖,导致页面显示"该用户名已被注册",哪怕 "nonice" 是合法的。
解决方案有两个层面。第一个层面是请求层面的竞态控制,最简单的实现是使用一个递增的 request id,每次发起新请求时记录当前 id,只有在响应时 id 仍然是最新的才更新校验结果,否则丢弃响应。第二个层面是引入防抖,在用户停止输入 300-500 毫秒后才发起请求,可以大幅减少请求频率。
let currentCheckToken = 0; const checkUsername = (value) => { const token = ++currentCheckToken; debounce(async () => { try { const result = await fetch(`/api/check/${value}`); if (token === currentCheckToken) { applyResult(value, result); } } catch (e) { if (token === currentCheckToken) { handleError(e); } } }, 300)(); };这个"当前 token"模式我在多个项目里反复使用,简单可靠,可以处理所有类似的异步竞态问题。如果你的项目里已经有 RxJS,也可以考虑用它来解决,但为了一个小小的竞态问题引入 RxJS 我一般不建议。
4.3 中文输入法组合态问题:拼音输入一半就触发了校验
输入 "cheng" 拼音,准备选字成 "程" 时,输入框里的 value 会在拼音阶段就变成 "cheng",如果此时有正则校验规则要求只能输入中文,sale 会立刻报错,用户体验直接炸裂。这个问题在手机和 PC 上都会出现,PC 端前端一般使用compositionstart和compositionend事件来解决。
核心思路是:在输入法组合状态时,暂停所有基于值变化的校验(onChange 校验),等组合状态结束后再触发校验。在 React 中,大部分表单校验库已经内置了对这个问题的处理,但如果你用的是原生手写的校验方案,需要注意自己加上这段逻辑。
我在项目里的默认策略是给输入框绑定compositionstart和compositionend事件,用标志变量记录当前的组合状态,校验函数在组合状态期间直接跳过非必选校验。这个细节看着不起眼,但对于面向中文用户的产品是必须处理的基础问题。
4.4 无障碍与可访问性:仅仅变红是不够的
表单验证的错误提示不能只依赖红色边框和文字颜色。对色弱、色盲以及使用读屏软件的用户来说,如果错误只靠颜色传达,等于没有提示。
正确的做法是在错误状态下同时用多种方式传达信息。边框颜色变化之外,还需要在输入框的aria-invalid上标注true,并且通过aria-describedby指向错误信息的元素。错误信息本身也不是随便一段文字就完了,它应该是可被读屏软件聚焦和播报的,通常用一个div或者p标签承载,并加上role="alert"。
我见过很多团队会说"产品没提无障碍需求,先不做",但表单验证的无障碍是个例外,因为它直接关联用户能否完成核心操作。我会把无障�碍作为表单验证的默认强制要求,而不是一个可选项。
4.5 必填字段的空白值检查:空格、零宽字符等藏匿问题
用户在一个必填字段里输入一串空格,这个值算空还是不算空?我的回答是算空。但很多校验库的required规则只检查值是否为''或者undefined,会让包含空格的字符串直接通过。
解决方法是给required规则扩展一个内置逻辑:在判断必填时先去除首尾空格,如果trim()之后是空字符串则视为未填写。同理,对于max长度的校验,也应该基于去空格后的值计算,否则用户输入" 12345 ",按非空格长度是 5,但按原始长度则是 11,直接拦住正常的填写。还是那句话,细节决定体验。
还有一类更隐蔽的问题:零宽字符。某些复制粘贴的内容里会夹带\u200b、\u200c这类不可见字符,用户看起来填的是 "abcde",但实际保存下来的是 "a\u200bb\u200ccde",在后端匹配时对不上。解决方式的思路是:在表单提交前做一次统一的数据清洗,把所有输入字段做normalize处理,把可见字符之外的内容过滤掉。
4.6 性能调优:高频验证导致输入卡顿
有些表单会在 onChange 阶段做全字段全规则的重跑,这是一种性能陷阱。用户每点一下键盘,所有字段的规则都重新执行一遍,特别是包含异步校验时,会直接导致输入卡顿、请求爆炸。
性能优化的核心是减少校验粒度和执行范围。第一层:只在字段自身触发校验时执行该字段的规则,不做全量重跑;第二层:跨字段规则在依赖字段变化时才触发,比如"确认密码"只在密码字段改变时也需要同步校验,这通过监听依赖字段实现;第三层:对于计算开销较大的校验(比如正则表达式复杂、需要处理长文本),可以启用防抖。
另外,正则本身写得不好也容易引起性能问题。这里举一个反例:(\d+)+$这种嵌套量词的正则被称为"灾难性回溯"模式,一旦用户输入了一个长字符串且不满足匹配,正则引擎会指数级地尝试所有分支,浏览器直接卡死。我见过一个真实例子是用户在搜索框里输入了 30 个字符,浏览器卡了整整一分钟。排查下来发现校验正则是/^(\w+)*$/,这就是教科书式的性能炸弹。解决方案是用更智能的写法,比如/^\w*$/,或者使用正向环视来消除歧义。在这里我建议每个前端开发都把正则的安全性列入审查清单,宁可保守写也不要写那种花哨但脆弱的表达式。
5. 校验规则的可维护性与测试策略
5.1 规则复用与模块化:从表单到字段级规则库
当项目越来越大,你会发现很多校验规则在多个表单里反复出现:手机号、邮箱、密码强度、身份证号(按掩码处理)、公司统一社会信用代码、金额范围、日期范围等等。每出现一次就复制粘贴一份,会导致同一规则的实现分散在多处,后面如果有人修了一个 bug 但只改了一处,就会留下隐患。
我的做法是把常用规则收敛为一个独立的规则模块,比如src/utils/validation/rules.ts,集中导出所有通用规则,它们直接是 zod schema 或 async-validator 规则对象,这样各表单直接引用即可。同时每个规则旁边配上简要的说明注释和参考的接口文档编号(如果它对应后端某个校验)。
举一个简单的模块化示例:
// src/utils/validation/rules.ts import { z } from 'zod'; export const phoneRule = z .string() .regex(/^1[3-9]\d{9}$/, '请输入有效的手机号'); export const emailRule = z .string() .email('请输入有效的邮箱地址'); export const passwordRule = z .string() .min(8, '密码至少8个字符') .regex(/[a-z]/, '需包含小写字母') .regex(/[A-Z]/, '需包含大写字母') .regex(/\d/, '需包含数字') .max(32, '密码最长32个字符'); export const cnNameRule = z .string() .min(2, '姓名至少2个字符') .max(20, '姓名最多20个字符') .regex(/^[\u4e00-\u9fa5·]+$/, '请输入中文姓名(可包含中间圆点)');表单使用时就是组合这些基础规则,再加上自己业务上特有的规则。这样既消除了重复,又保持了灵活性。
5.2 对校验逻辑进行单元测试:不给规则留不透明地带
表单验证的规则属于纯函数逻辑,是最容易做单元测试的部分,但恰恰是最容易被跳过的部分。很多团队觉得"校验嘛,随便测测就行",结果到了生产环境才发现某些规则边界没覆盖到,用户数据进不来或者非法数据漏过去,都是事故级问题。
我用 vitest 或 jest 对规则做参数化测试,比如测试手机号规则时,把合法值和非法值放进一个表格里逐一断言。这样一旦规则有修改,测试会立刻全部暴露行为差异。一套良好覆盖的校验测试,相当于给你的表单规则建立了一份可执行的文档。
5.3 持续维护校验规则:接口变更触发规则同步
最后一个建议,也是个流程建议:表单校验规则要跟着接口定义走。后端接口升级时,只要你改了 DTO 的字段约束,就必然会带动前端校验规则一起修改。如果前后端没有这个契约意识,接口已经允许一种新的格式了,前端还在拦着用户不让提交,用户体验的伤害是直接的。
我手头的做法是:后端在修改接口时,在变更日志里明确列出校验规则的变化点,前端负责表单的同学看到后必须同步更新前端规则,并在 MR 描述里注明对应的接口变更文档链接。这个规范看似繁琐,但它能避免掉我在实战中遇到的最大的坑:规则不一致。规则不一致是表单验证领域里代价最高的 bug,因为它通常是静默发生的,直到某个用户提交被莫名其妙地拒掉,才被发现问题。
6. 我对表单验证这件事的体会
做了这么多年前端,表单验证从来不是最炫酷的那部分工作,但绝对是最直接影响业务结果的部分。用户能不能完成注册、能不能顺利下单、能不能正确提交一条信息,都压在表单这扇门上。而验证规则的正确性、反馈的及时性、交互的友好性,决定了这扇门是轻松推开还是把人挡在外面。
从我自己的经验来看,做好表单验证的核心不在于用了多厉害的库,也不在于手写了一套多有设计感的引擎,而在于你用没把用户的体验当成第一优先级。一个不打扰用户、又在需要时精准给出帮助、还能在背后保护系统的验证体系,远比一个追求把所有规则一次性验证完且报错信息密集弹窗的方案要难设计。
如果这篇文章只留一个观点,我会说:表单验证的表面是技术实现,本质是规则管理。规则清晰、反馈及时、边界可控、前后端一致——把这几件事做扎实了,表单验证想不出彩都难。
最后分享一个我在实际项目中养成的习惯:每次实现完一套表单验证,我都会自己把整个流程走一遍,模拟五种角色——正常用户、粗心用户(填错格式)、刁钻用户(尝试绕过前端限制)、无障碍用户(依赖键盘和读屏软件)、慢网络用户(来回切换卡顿环境)。把这五类人走完,表单验证的品质基本就有了保障。你也可以试试看。