8.2 表单验证:分层校验与提交时序 本节摘要:验证分两层——字段级即时反馈(blur 或 input 时机)与提交级整体把关(async-validator 是事实标准)。本节从手写验证讲到规则库集成,覆盖错误信息组织、错误回填、防重复提交与提交竞态这些真实工程里躲不开的问题。 学习目标 阅读完本节,你应当能够: 设计字段级与提交级的双层验证结构; 手写声明式规则并理解 async-validator 的规则形态来源; 处理提交时序:防重、竞态取消、服务端错误回填; 决定"何时校验"的时机策略而非一律实时校验。
本节摘要:验证分两层——字段级即时反馈(blur 或 input 时机)与提交级整体把关(async-validator 是事实标准)。本节从手写验证讲到规则库集成,覆盖错误信息组织、错误回填、防重复提交与提交竞态这些真实工程里躲不开的问题。
阅读完本节,你应当能够:

四个常见时机,各有代价与体验:
| 时机 | 触发点 | 体验 | 适用 |
|---|---|---|---|
| input 实时 | 每键一次 | 反馈最快但吵 | 用户名重复等远程检查(要防抖) |
| blur 失焦 | 离开字段 | 安静且及时 | 大多数字段的默认选择 |
| submit 提交 | 点提交时 | 一次看全错 | 密码强度、跨字段一致性 |
| 变化防抖 | 停顿几百毫秒 | 平衡 | 长文本、格式类检查 |
一刀切实时校验是体验反模式——用户打完名字的第一个字就被标红"至少两个字",这种界面谁都暴躁。我的默认策略:短字段 blur 校验,联动与跨字段校验放提交时,远程检查一律防抖加失焦兜底。
先不用库,把结构写对:
const rules = { username: [ { required: true, message: '请输入用户名', trigger: 'blur' }, { min: 2, max: 20, message: '长度 2 到 20 个字符', trigger: 'blur' }, { validator: (rule, value) => /^[\w-]+$/.test(value), message: '仅限字母数字下划线连字符', trigger: 'blur' } ], age: [ { required: true, message: '请输入年龄', trigger: 'blur' }, { validator: (r, v) => v >= 0 && v <= 150, message: '年龄超出合理范围', trigger: 'blur' } ] }; const errors = reactive({}); function validateField(field) { errors[field] = rules[field] .filter((rule) => { if (rule.validator) return !rule.validator(rule, form[field]); if (rule.required) return !String(form[field]).trim(); if (rule.min) return String(form[field]).length < rule.min; return false; }) .map((rule) => rule.message); }
<input v-model="form.username" @blur="validateField('username')"> <p v-for="msg in errors.username" :key="msg" class="error">{{ msg }}</p>
这套"规则数组 + 触发时机 + 消息"的形态,正是 Element Plus、Ant Design Vue 等组件库表单验证的通用形态——它们内置的都是 async-validator 这个库。理解手写版,组件库的 rules 属性就不再是黑盒。
Element Plus 的表单,同一套规则换个挂法:
<template> <el-form :model="form" :rules="rules" ref="formRef"> <el-form-item label="用户名" prop="username"> <el-input v-model="form.username" /> </el-form-item> <el-button @click="submit">提交</el-button> </el-form> </template> <script setup> import { ref, reactive } from 'vue'; const formRef = ref(); const form = reactive({ username: '' }); const rules = { username: [ { required: true, message: '请输入用户名', trigger: 'blur' } ] }; async function submit() { await formRef.value.validate(); // 整体校验,不通过会 reject 并显示全部错误 await api.save(form); } </script>
三个易错点:form-item 的 prop 必须与 model 里的字段名一致(错误定位靠它对应);整体校验要 await 它返回的 Promise,别只调用不等待;自定义 validator 返回 Promise 即支持异步规则(重名检查就在这里做,记得防抖)。
防重复提交——网络慢时用户连点,产生两条订单:
const submitting = ref(false); async function submit() { if (submitting.value) return; // 第二次点击直接挡掉 submitting.value = true; try { await formRef.value.validate(); await api.save(form); } finally { submitting.value = false; // 无论成败都解锁 } }
按钮同时绑 :disabled="submitting",视觉与逻辑双保险。
竞态取消——表单里带联想搜索时,快速输入触发多个请求,慢的先发后到,覆盖了新结果。解法是序号守卫:
let searchSeq = 0; async function searchRelated(keyword) { const seq = ++searchSeq; const res = await api.suggest(keyword); if (seq !== searchSeq) return; // 过期响应直接丢弃 suggestions.value = res.list; }
服务端错误回填——客户端校验拦不住全部(唯一性、余额),提交被拒后要把服务端错误映射回字段:
async function submit() { try { await api.save(form); } catch (err) { if (err.fieldErrors) { for (const [field, messages] of Object.entries(err.fieldErrors)) { errors.value[field] = messages; // 结构与本地验证一致,展示复用 } } } }
错误信息结构统一成"字段到消息数组"的映射,本地与远端错误就能共用一套展示,这是表单组件设计里最值钱的一个约定。
字段几十个的表单(开户、理赔)要分区甚至分步。分步验证的要点是按步校验而非全量校验:第一步只验第一步的字段(把 rules 按步拆组),最后一步提交时做全量兜底——用户不必为最后一页的错误回头改第一页。分区保存(草稿)则把"部分字段合法"定义为合法,提交时才收紧,这需要与服务端约定草稿接口的宽松校验规则。
再强调一个和团队协作有关的实践:验证规则要成为前后端的共享契约,而不是前端的自娱自乐。同样的长度限制、格式要求、取值范围,如果只在 Vue 里写一遍,后端再散着写一遍,两边迟早漂移——某次需求改动只改了一侧,另一侧就成了摆设或误伤。可行做法是把规则定义抽出为一份独立的共享描述(字段、约束、消息),前端用它驱动表单校验,后端用它做入库前校验,漂移在源头就被锁住。这属于第 11 章分层规范的延伸,放在表单语境下提出来,是因为它最容易在表单模块里落地。
表单这条反向通道走完,下一章回到主线:让整条渲染管线跑得更快。