复杂表单校验架构:竞态、跨字段依赖和配置驱动校验引擎

去年那篇资料编辑表单的文章把 submit 事件、按钮 type、Constraint Validation API、async-validator 的异步规则、受控非受控这些都过了一遍,那是单表单、字段之间基本独立的场景。这次接手的企业开户表单不一样:二十多个字段,四个分步,五六处字段互相牵制的业务规则,外加一个用户名/统一社会信用代码要实时查重的异步校验。用去年那套"字段配 rules、写清楚 message"的思路直接搬过来,代码是能跑,但很快就在两个地方翻车——异步校验的请求乱序,以及"字段 A 的合法性依赖字段 B"这类规则没地方好好安放。

异步校验的竞态:晚发的请求先回来

用户名查重是最先出问题的地方。规则很直白:失焦或者停止输入 300ms 后,调一次接口确认这个用户名有没有被占用。第一版实现里,validator 函数长这样:

1const validateUsername = (rule, value, callback) => {
2  checkUsernameExists(value).then((exists) => {
3    callback(exists ? new Error('用户名已被占用') : undefined)
4  })
5}

单独测没问题,但只要用户手速快一点、改了两次用户名(比如先输入 zhangsan,觉得不满意又追加成 zhangsan01),偶尔就会出现"最终显示的是被占用",而 zhangsan01 明明是可用的。原因是两次请求先后发出去,但后端返回的顺序不保证和发出顺序一致——网络抖动、负载均衡、甚至只是查重逻辑本身对不同字符串耗时不同,都可能导致先发的 zhangsan 查重请求比后发的 zhangsan01 更晚返回。callback 是谁的 then 先跑到就执行谁的,于是后一次输入的正确结果被前一次过期的结果覆盖掉了。

这个坑本质上是异步校验里最容易被忽略的一类问题:校验函数是幂等的,但"最后一次赋值生效"这个前提在异步场景下不成立,谁的 .then() 先跑到才是最后生效的。解决思路有两条,实际项目里我把它们叠在一起用。

第一条是序号法,给每一次触发校验的请求分配一个自增序号,回调里只认最新序号:

1let usernameCheckSeq = 0
2
3const validateUsername = (rule, value, callback) => {
4  const seq = ++usernameCheckSeq
5  checkUsernameExists(value).then((exists) => {
6    if (seq !== usernameCheckSeq) return // 已经有更新的请求发出了,这次结果作废
7    callback(exists ? new Error('用户名已被占用') : undefined)
8  })
9}

这个办法足够简单,不依赖具体的请求库,只要是个返回 Promise 的异步操作都能这么包一层。缺点是过期请求本身还是发出去了、服务器还是要处理它,只是前端不采信结果,浪费了一次后端算力。

第二条是真正取消请求,用 AbortController

1let usernameCheckController = null
2
3const validateUsername = (rule, value, callback) => {
4  usernameCheckController?.abort()
5  usernameCheckController = new AbortController()
6  const { signal } = usernameCheckController
7
8  fetch(`/api/username-check?name=${encodeURIComponent(value)}`, { signal })
9    .then((res) => res.json())
10    .then(({ exists }) => {
11      callback(exists ? new Error('用户名已被占用') : undefined)
12    })
13    .catch((err) => {
14      if (err.name === 'AbortError') return // 主动取消导致的失败,不当成校验失败处理
15    })
16}

AbortController 是真的把网络请求掐断(如果服务端支持连接中断的话),比序号法更彻底,尤其是查重逻辑本身开销较大的场景(比如后端要查好几张表做唯一性校验),能少打很多没用的请求。但它要求校验函数自己掌握请求的生命周期,如果校验逻辑封装在别的地方(比如放进一个通用的 remoteValidator 工厂函数),传递 signal 就要多一层参数透传,写起来比序号法啰嗦。这次开户表单里两种都用了:用户名和统一社会信用代码这两个查重接口开销大,上了 AbortController;另一个"手机号是否已绑定"的轻量校验图省事,只用了序号法。

还有个容易漏掉的细节:取消上一次请求之后,error 状态该保留旧值还是清空?我一开始选择的是保留上一次校验结果不变,直到新请求真正返回,这样用户界面不会因为频繁输入而闪烁"校验中"到"校验通过"再到"校验中"。但如果用户输入之后长时间没有停下来(一直在改字符),错误提示会一直显示上一个字符串的结果,看起来像是没生效。后来加了一个折中:只要触发了新一轮校验,就先把错误提示清空、把字段状态标成"校验中",等真正结果回来再决定显示什么,这样至少不会显示明显过期的判断。

跨字段联合校验:谁依赖谁,校验顺序怎么定

这次开户表单里"确认密码"这类简单的双字段校验只是最小的例子,真正麻烦的是这几条规则:企业类型选"个体工商户"时,统一社会信用代码字段变成非必填,改成身份证号必填;法定代表人和联系人如果勾选"同一人",联系人的手机号、身份证号自动复用法定代表人的值,同时联系人字段本身的校验规则要跟着放松(不再强制要求单独填写);开户金额字段的上限,取决于企业类型和注册资本两个字段的联动计算,这个上限变化后,已经填的开户金额如果超过新上限要立刻标红。

这类规则如果还是按"字段独立配 rules"的思路去写,很快就会发现规则之间要互相读对方的当前值,而且改一个字段要触发好几个别的字段重新校验。第一版是在每个字段的 validator 里直接引用 this.form.其他字段,能跑,但很快就写成了一张互相纠缠的依赖网——改企业类型的校验函数里去读注册资本,改注册资本的校验函数里又反过来触发开户金额的重新校验,顺序稍微错一点就会出现"改了 A,B 没有跟着刷新"的滞后状态,和去年那篇踩过的"改新密码没联动确认密码"是同一类问题,只是这次纠缠的字段更多、方向更复杂。

后来把这套关系换了个组织方式:不再让每个字段的校验规则里互相引用,而是显式声明一张"字段依赖表",谁的值变化会影响谁的校验或者可见性,全部提前列出来,校验触发的时候按这张表去查、而不是在业务代码里到处埋读取逻辑:

1const fieldDependencies = {
2  companyType: ['creditCode', 'idCard', 'depositLimit'],
3  registeredCapital: ['depositLimit'],
4  sameAsLegalPerson: ['contactPhone', 'contactIdCard'],
5}
6
7function onFieldChange(field, form, revalidate) {
8  form[field] = arguments[2] // 省略具体赋值细节
9  const affected = fieldDependencies[field]
10  if (affected) {
11    affected.forEach((f) => revalidate(f))
12  }
13}

这张表把"谁依赖谁"从散落在各个 validator 函数里的隐式读取,变成了一份可以直接读出来的清单——新人接手这个表单,不需要把每个字段的校验函数都翻一遍才能搞清楚改一个字段会牵连哪些地方,扫一眼这张表就有数了。它也顺带解决了顺序问题:onFieldChange 触发的时候,先完成当前字段自身的赋值和校验,再按依赖表挨个重新校验受影响的字段,不会出现"受影响的字段先跑、依赖的值还是旧的"这种时序错乱。

依赖表这个思路有一个边界要注意:它只处理"字段改变触发别的字段重新校验",处理不了"两个字段互相依赖、形成环"的情况。开户表单里差点就写出一个环——统一社会信用代码校验规则里判断企业类型,企业类型的联动展示又要看统一社会信用代码是否已经填过(用来决定要不要弹出"检测到你可能填反了字段"的提示)。这种环状依赖一旦出现,revalidate 很容易递归调用出不去。发现这个问题之后,把"检测填反"这类弱提示从校验规则里整个拿掉,改成表单级别的一次性 watch,不进依赖表、不参与 revalidate 链路,只在特定时机单独跑一次,避免污染核心校验流程。这条经验后来我总结成一句话:跨字段校验的依赖关系必须是有向无环图,一旦发现要写出环,说明这条规则的性质变了,该拆出去单独处理,而不是硬塞进联合校验体系里。

校验时机的代价:三种策略到底差在哪

去年那篇提过"失焦初校、改正后实时校"这个折中方案,这次在更复杂的表单里,把三种时机的代价摆在一起认真比较了一遍,才发现选择背后其实是三种不同的用户负担,选哪个要看字段本身的特性,不是一刀切。

实时校验(跟着 input 事件走)的代价是"打断感"。用户还没输完就被判定错误,尤其是有最小长度要求的字段(密码、用户名),前几个字符必然是不合法的,此时报错纯粹是噪音。它的收益是反馈最快,适合那些"输入过程本身就有价值"的场景——密码强度条、字符计数器、格式化展示(比如银行卡号每四位加空格),这些不是在做校验判断,只是在做输入过程中的引导展示,误判了也不会让用户觉得"被指责"。

失焦校验的代价是"滞后感"。用户填完一个字段、跳到下一个字段才知道上一个错了,如果字段之间跳转很快(比如用 Tab 键连续跳过好几个字段),错误提示可能出现在用户已经离开好几个字段之后,这时候视线焦点已经不在报错的字段上,用户容易漏看。它的收益是不打断当前输入,是这次开户表单里绝大多数格式类字段(手机号、邮箱、金额)的默认选择。

提交时校验的代价最大——用户一次性看到一整页错误,尤其是分步表单里,可能是"点了下一步才发现这一整步都没填对",挫败感最强。但它也有不可替代的场合:跨字段联合校验里那些依赖好几个字段共同状态的规则(比如"企业类型是个体工商户时,开户金额不能超过法定代表人个人流水的三倍"这种要拉取额外接口数据的规则),如果非要在某个字段失焦时就触发,用户会感觉"我明明只改了一个字段,怎么触发了这么重的一次校验",体验比放到提交时统一检查更差。这类重校验规则,我后来的处理原则是延后到提交前的统一校验里去做,配合去年那篇写过的"强制 touch 所有字段、滚动定位到第一个错误"的手法,把代价最大的场景至少做到"错误可定位"。

三种时机不是非此即彼的选择,一个复杂表单里是混着用的,判断标准落在两个问题上:这条规则本身校验代价(本地正则还是要发请求)、以及打断用户的成本(越靠前越轻的字段越能承受被打断)。开户表单最后落地的分配是:格式类走失焦,强反馈类展示走带阈值的防抖实时校验,重业务规则和跨字段依赖统一挪到分步切换和最终提交前触发。

字段和规则都是配置:动态表单的校验引擎

开户表单还有一个复杂之处是它本身是配置驱动的——同一套表单组件,个体工商户、有限公司、合伙企业三种企业类型渲染出来的字段数量、必填规则都不一样,这些差异是后端下发的一份表单 schema 决定的,不是写死在前端代码里的三份不同表单。这意味着校验规则本身也得跟着 schema 走,不能像前面例子那样写死在组件里。

schema 大致长这个样子,每个字段带上自己的校验规则声明:

1const formSchema = {
2  companyType: 'limited', // 决定下面哪些字段渲染、哪些规则生效
3  fields: [
4    {
5      key: 'companyName',
6      label: '企业名称',
7      rules: [{ required: true, message: '请输入企业名称' }],
8    },
9    {
10      key: 'creditCode',
11      label: '统一社会信用代码',
12      visibleWhen: (form) => form.companyType !== 'individual',
13      rules: [
14        { required: true, message: '请输入统一社会信用代码' },
15        { pattern: /^[0-9A-Z]{18}$/, message: '格式不正确,应为 18 位' },
16      ],
17    },
18    {
19      key: 'idCard',
20      label: '身份证号',
21      visibleWhen: (form) => form.companyType === 'individual',
22      rules: [{ required: true, message: '请输入身份证号' }],
23    },
24  ],
25}

校验引擎要做的事情,是拿着这份 schema 和当前表单值,遍历每个字段,先判断 visibleWhen 决定这个字段这一轮到底算不算数——不可见的字段即使值不合法也不该拦截提交,否则会出现"用户根本看不到这个字段,却提示他没填"的荒谬情况。这一步最初就漏掉了,个体工商户类型下,已经隐藏的统一社会信用代码字段还残留着上一次切换类型之前的校验错误状态,导致明明该提交成功的表单被挡住。修好之后的整体校验函数大致是:

1function validateBySchema(schema, form) {
2  const errors = {}
3
4  schema.fields.forEach((field) => {
5    if (field.visibleWhen && !field.visibleWhen(form)) {
6      return // 不可见字段不参与本轮校验
7    }
8
9    const value = form[field.key]
10    for (const rule of field.rules) {
11      if (rule.required && !value) {
12        errors[field.key] = rule.message
13        break
14      }
15      if (rule.pattern && value && !rule.pattern.test(value)) {
16        errors[field.key] = rule.message
17        break
18      }
19    }
20  })
21
22  return errors
23}

这个引擎目前只覆盖了 requiredpattern 两类最基础的规则,真实项目里还要支撑跨字段规则(前面依赖表那套)、异步规则(用户名查重那套)。混在一起写会让这个函数迅速膨胀成一大坨 if-else,所以后来把规则类型拆成了几种"处理器",引擎本身只负责调度,具体怎么判断丢给对应类型的处理器去做,新增一种规则类型只需要注册一个新的处理器函数,不用碰引擎主体逻辑。这算是这次开户表单校验架构里收益最大的一次调整——它把"配置里能表达什么规则"和"这个规则具体怎么判断"两件事拆开了,后续加新的企业类型、加新的字段联动规则,大多数情况只需要改 schema 本身,不需要再碰校验引擎的代码。

配置驱动这条路径还带来一个连带好处:同一份 schema 理论上服务端也能持有一份等价描述,用于二次校验。这次没有做到前后端共享同一份 JS 规则(后端是 Java 技术栈,规则得用后端语言重写一遍),但至少做到了 schema 里每条规则的 message 和字段 key 与后端约定一致,服务端校验失败时返回的 fieldmessage 能直接复用前端已经搭好的错误展示通道,不用再单独适配一遍。

规则处理器:把"能表达什么"和"怎么判断"拆开

前面那个 validateBySchema 函数只认 requiredpattern,真要塞进跨字段规则和异步规则,直接在这个函数体里加 if 分支是最直觉的做法,但很快就会变成一个几十行、职责混杂的大函数——同步的正则判断、异步的接口查重、依赖别的字段值的联合校验,三种性质完全不同的东西挤在一起,改一种规则的实现细节就要小心翼翼地绕开另外两种。

后来把每种规则类型抽成一个独立的处理器,每个处理器只关心自己这一类规则怎么判断、返回什么形状的结果,引擎本身只负责按 type 分发:

1const ruleHandlers = {
2  required(rule, value) {
3    return value ? null : rule.message
4  },
5  pattern(rule, value) {
6    if (!value) return null
7    return rule.pattern.test(value) ? null : rule.message
8  },
9  cross(rule, value, form) {
10    return rule.validate(form) ? null : rule.message
11  },
12  async remote(rule, value) {
13    const exists = await rule.check(value)
14    return exists ? rule.message : null
15  },
16}
17
18async function runFieldRules(field, form) {
19  for (const rule of field.rules) {
20    const handler = ruleHandlers[rule.type]
21    const error = await handler(rule, form[field.key], form)
22    if (error) return error
23  }
24  return null
25}

新增一种规则类型,只需要往 ruleHandlers 里注册一个新函数,不用碰引擎主体,也不用改已有规则的判断逻辑。这次开户表单后期加了一条"营业执照有效期不能早于今天"的规则,属于纯粹的日期比较,直接在 ruleHandlers 里加了个 dateRange 处理器,改动量只有几行,没有牵动前面已经跑得很稳的 requiredpatterncross 三类。

值得强调的是 cross 类型的规则并不知道自己依赖哪些字段——rule.validate(form) 里访问哪个字段完全是业务代码自己决定的,引擎不做任何限制。这意味着"字段依赖表"和这里的规则处理器是两套独立机制:依赖表负责"谁改了该重新校验谁"这个触发时机的问题,规则处理器负责"具体怎么判断合法"这个逻辑问题,两者配合但不耦合。一开始想把两者合并成一套(比如让 cross 规则自己声明依赖字段,引擎自动生成依赖表),做了一半发现声明式依赖和过程式判断逻辑绑在一起会让 schema 变得很啰嗦,最后还是分开维护,代价是要在两个地方各改一次,但每处改动都很局部,权衡下来比强行合并更划算。

异步校验状态在界面上怎么呈现

规则跑起来之后,界面上还有一层容易被忽略的问题:异步校验存在"校验中"这个中间态,而同步校验没有。字段的错误提示如果只有"通过"和"不通过"两种展示,用户在等待接口返回的这几百毫秒里,看到的要么是上一次的旧结果,要么是一片空白,都容易让人怀疑"是不是卡住了"。

这次给每个走异步规则的字段单独维护了一个 validating 标记,界面上用一个小的加载态图标顶替错误提示的位置:

1async function runFieldRules(field, form, setFieldState) {
2  const asyncRule = field.rules.find((r) => r.type === 'remote')
3  if (asyncRule) {
4    setFieldState(field.key, { validating: true, error: null })
5  }
6
7  for (const rule of field.rules) {
8    const handler = ruleHandlers[rule.type]
9    const error = await handler(rule, form[field.key], form)
10    if (error) {
11      setFieldState(field.key, { validating: false, error })
12      return
13    }
14  }
15
16  setFieldState(field.key, { validating: false, error: null })
17}

这里有个细节容易漏:如果字段里既有同步规则又有异步规则,同步规则应该先跑完、快速拦下明显不合法的输入(比如用户名格式都不对,没必要浪费一次网络请求去查重),只有同步规则全部通过之后才进入异步阶段、才把 validating 标记打开。开户表单最初的实现是不管什么规则一律先把 validating 打开再说,于是用户输入一个格式明显错误的用户名(比如带了空格),界面上却先闪了一下"校验中",几十毫秒后才变成格式错误,体验上多了一次没必要的状态跳变。调整成"同步规则先过滤,异步规则才决定要不要显示校验中"之后,这类字段的报错反馈快了一截,也不再有多余的中间态闪烁。

提交按钮在这类字段还没校验完的时候要不要允许点击,也是需要显式处理的一个点。开户表单里选择的策略是:提交按钮在任意字段处于 validating 状态时禁用,避免用户抢在异步结果落地之前点了提交,导致这条规则形同虚设。这和去年那篇里"按钮永远可点、点击时统一强制校验"的思路不完全一样——那篇处理的是同步校验为主的场景,点了提交立刻能拿到全部结果;这里因为异步查重本身有网络耗时,如果不做这层拦截,用户完全可能在查重接口还没返回时就点了提交,绕过了这条规则。

依赖表和规则处理器怎么保证不出岔子

字段一多、规则一多,光靠手动测试很难覆盖所有组合,尤其是依赖表这种"改一个字段联动好几个字段"的逻辑,最容易在后续加新字段时被无意间破坏——加了一个新字段进依赖表,忘了同步改对应的联动清理逻辑,或者反过来删了一个字段忘记把它从别的字段的依赖列表里摘掉。

这次给依赖表和几条核心的跨字段规则单独写了一版针对纯函数的测试,不牵涉界面渲染,只测"给定一份表单数据,跑一次校验之后拿到的错误集合对不对":

1test('企业类型切到个体工商户后,统一社会信用代码不再是必填项', () => {
2  const form = { companyType: 'individual', creditCode: '' }
3  const errors = validateBySchema(formSchema, form)
4  expect(errors.creditCode).toBeUndefined()
5})
6
7test('同一人勾选后,联系人手机号跟随法定代表人的值', () => {
8  const form = { sameAsLegalPerson: true, legalPersonPhone: '13800000000', contactPhone: '' }
9  applyFieldDependencies('sameAsLegalPerson', form)
10  expect(form.contactPhone).toBe('13800000000')
11})

这两个测试案例覆盖的其实就是前面踩过的两个真实坑——字段隐藏后遗留错误状态、联动赋值没有触发。测试本身没有多复杂,价值在于把"这条规则该有的行为"用代码固定下来,后续谁改了依赖表或者规则处理器,这两个断言过不了就能立刻发现,不用等到测试环境里手工点出同样的现象才反应过来。跨字段校验这类逻辑一旦交给纯函数去描述(校验函数只依赖传入的 form 对象,不牵扯组件生命周期和 DOM),写测试的成本其实很低,是这次开户表单里少数几个"值得提前投入"的地方之一。

在 Vue 2.6 里落地:依赖表怎么接进 watch

前面的依赖表和 revalidate 是抽象出来的思路,具体接到这次团队的 Vue 2.6 项目里,触发时机自然就落在了 watch 上——Vue 的响应式系统本身就是一套"谁变化了通知谁"的依赖收集机制,跟依赖表要解决的问题在方向上是一致的,没必要另起一套观察者模式。

比较省事的做法是把依赖表转成一份 watch 配置,字段变化时按表去调用对应字段的重新校验:

1export default {
2  data() {
3    return {
4      form: {
5        companyType: 'limited',
6        registeredCapital: '',
7        creditCode: '',
8        idCard: '',
9        depositLimit: '',
10      },
11      fieldDependencies: {
12        companyType: ['creditCode', 'idCard', 'depositLimit'],
13        registeredCapital: ['depositLimit'],
14      },
15    }
16  },
17  watch: {
18    'form.companyType'(val) {
19      this.revalidateFields(this.fieldDependencies.companyType)
20    },
21    'form.registeredCapital'(val) {
22      this.revalidateFields(this.fieldDependencies.registeredCapital)
23    },
24  },
25  methods: {
26    revalidateFields(fields) {
27      if (!fields) return
28      fields.forEach((key) => {
29        this.$refs.form.validateField(key)
30      })
31    },
32  },
33}

这里用的是 el-form 自带的 validateField(key),只重新校验单个字段、不牵动整个表单,代价比全表单校验小得多,适合"改一个字段联动两三个字段"这种轻量场景。但这种手写 watch 的方式有个明显的缺点:依赖关系散落在 watch 选项里,每加一个需要被依赖的字段就要多写一段几乎一样的样板代码,和前面提到"依赖表要保持可读、集中"的初衷有点背道而驰。这次的折中处理是把 fieldDependencies 这份配置和 watch 的注册逻辑分开——配置本身保持纯数据、可以单独测试,watch 只是运行时消费这份配置的其中一种方式,理论上换成 Composition API 的 watch 函数或者别的响应式方案,配置数据本身不用改。

sameAsLegalPerson 这类需要联动赋值(不只是重新校验,还要把值复制过去)的字段,处理方式类似,只是 watch 回调里除了触发校验,还要顺手把值同步过去:

1watch: {
2  'form.sameAsLegalPerson'(val) {
3    if (val) {
4      this.form.contactPhone = this.form.legalPersonPhone
5      this.form.contactIdCard = this.form.legalPersonIdCard
6    }
7    this.revalidateFields(['contactPhone', 'contactIdCard'])
8  },
9  'form.legalPersonPhone'(val) {
10    if (this.form.sameAsLegalPerson) {
11      this.form.contactPhone = val
12    }
13  },
14},

这里藏着一个容易漏掉的联动方向问题:勾选"同一人"那一刻要同步一次,之后如果用户又回去修改了法定代表人的手机号,联系人手机号也要跟着变——这是两个不同的触发源(勾选框本身、以及法定代表人手机号字段)共同指向同一个目标字段,如果只处理了勾选框那一个 watch,会出现"勾选的时候同步了一次,之后改法定代表人手机号联系人却没跟着变"的不一致,这次就是先漏了这一层,测试时被评审同事点出来才补上第二个 watch

把这套引擎接进 el-form 的 rules

async-validator 本身认的是 validator(rule, value, callback) 这套签名,前面写的 runFieldRules 是自己的一套规则处理器,两者签名不一样,接入 el-form 时需要一层适配,而不是把 el-formrules 整个换掉——团队里其他表单还在用标准的 el-form-item + rules 写法,这个开户表单如果换一套完全不同的校验体系,会让同一个项目里出现两套互不相通的表单写法,后续维护成本反而更高。

适配层做的事情很简单,就是把 schema 里某个字段的规则集合包装成一个 async-validator 认识的 validator 函数:

1function toElementRule(field) {
2  return {
3    validator: async (rule, value, callback) => {
4      const error = await runFieldRules(field, currentForm())
5      callback(error ? new Error(error) : undefined)
6    },
7    trigger: field.trigger || 'blur',
8  }
9}
10
11const elementRules = {}
12formSchema.fields.forEach((field) => {
13  elementRules[field.key] = [toElementRule(field)]
14})

这样一来,el-form-item 那边的写法完全不用变,:rules="elementRules" 照常绑定,内部实际调用的已经是自己那套支持异步、跨字段、动态可见性判断的引擎。这个适配层的价值在于把"这次开户表单需要更强的校验能力"这件事封装在了内部,对使用这个表单组件的其他代码(分步导航、提交按钮)来说,感知到的还是一个标准的 el-form 实例,validate()validateField()clearValidate() 这些方法都能照常调用,不需要额外学一套新 API。

visibleWhen 返回 false 的字段,在这层适配里也要对应处理成 el-form 认识的方式——el-form-item 本身有 v-if 控制渲染,但如果只是控制了渲染、没有同步清掉 el-form 内部维护的这个字段的校验状态,会重现前面提过的"隐藏字段残留错误"的问题。这次统一在字段隐藏时调用一次 this.$refs.form.clearValidate(field.key),让 el-form 自己的状态和实际可见性保持同步,避免两边各记一份账、互相打架。

状态管理:分步表单比单页表单更容易乱

单页表单里"未输入、校验中、校验失败、提交中"这几种状态已经够绕,分步表单还多一个维度——每一步自己的校验状态之外,还有"这一步整体是否可以进入下一步"和"之前的步骤是否因为联动规则重新变得不合法"这两层。开户表单里企业类型是第一步选的,统一社会信用代码是第二步填的,如果用户在第三步又返回第一步把企业类型从"有限公司"改成"个体工商户",第二步里已经填好的统一社会信用代码这时候要么隐藏、要么改成非必填,但当时用户已经走到第三步,不会再回头看第二步一眼。

这种场景如果放任不管,会出现"第二步的过期错误状态跟着用户走到最后一步,提交时诡异地报出一个用户根本看不到的字段错误"。开户表单最终的处理是:分步切换(不管前进还是后退)时都会重新跑一次全表单范围的 validateBySchema,而不是只校验当前这一步的字段;每一步的"下一步"按钮只关心当前可见字段是否通过,但提交按钮永远看全表单范围的校验结果,包括那些当前不在视野内的步骤。这个设计的代价是切步骤时会有一次略高于单字段校验的开销(跑一遍全表单的 schema 遍历),但换来的是任何时候提交,报错都是当下真实成立的状态,不会有过期规则遗留下来的诡异提示,这一点在多步骤表单里比单纯的性能开销更值得优先保证。

分步表单里错误定位也比单页麻烦一层。单页表单滚动到第一个报错字段就够了,分步表单如果提交时发现错误落在第二步,用户当前却停留在第四步,简单的 scrollToField 不管用——目标字段所在的步骤根本没有渲染在 DOM 里。这次的处理是先跳步骤、再滚动,两步分开做:

1function focusFirstError(errors, schema, goToStep) {
2  const errorKeys = Object.keys(errors)
3  if (!errorKeys.length) return
4
5  const firstErrorField = schema.fields.find((f) => f.key === errorKeys[0])
6  goToStep(firstErrorField.step) // 先把分步表单切到错误字段所在的那一步
7
8  requestAnimationFrame(() => {
9    // 等这一步的 DOM 渲染出来之后再定位滚动,不能和 goToStep 同步执行
10    scrollToField(errorKeys[0])
11  })
12}

goToStep 触发的是一次组件重渲染,如果紧接着同步调用 scrollToField 去找目标字段的 DOM 节点,大概率还没渲染出来,滚动会落空。这里用 requestAnimationFrame 让滚动动作排到下一帧,等 Vue 完成这一轮渲染之后再执行,是这次调试时反复试出来的最小可行方案——一开始用 setTimeout(fn, 0) 也能凑效,但在慢一点的机器上偶尔还是会抢在渲染前面,requestAnimationFrame 更贴近"等一帧画面画完"这个真实需求,比固定延时更可靠。

校验规则的执行顺序为什么也要设计

依赖表解决了"谁的变化触发谁重新校验",但同一个字段身上如果挂了好几条规则,规则本身跑的顺序也会影响用户看到的报错内容。开户表单里注册资本字段挂了三条规则:必填、必须是正数、不能低于对应企业类型的最低注册资本要求。如果这三条规则乱序执行,可能出现用户什么都没填、却先看到"不能低于最低注册资本要求"这种奇怪的报错——因为空值在数值比较里可能被隐式转换成 0,直接触发了最后一条规则,而更基础的"必填"反而没有优先报出来。

解决办法很朴素,就是给规则本身分层,越基础的判断放得越靠前,一旦某一层没通过就不再往后跑,前面 runFieldRules 里那个 for 循环遇到 error 立刻 return 就是这个用意。规则声明的时候,顺序本身就是优先级,required 永远排在最前面,格式类规则排中间,涉及业务比较或者要发请求的规则排最后:

1{
2  key: 'registeredCapital',
3  rules: [
4    { type: 'required', message: '请输入注册资本' },
5    { type: 'pattern', pattern: /^\d+(\.\d{1,2})?$/, message: '请输入正确的金额格式' },
6    {
7      type: 'cross',
8      validate: (form) => Number(form.registeredCapital) >= minCapitalByType(form.companyType),
9      message: '注册资本低于该企业类型的最低要求',
10    },
11  ],
12}

这条经验看着简单,但在这次联合校验规则越堆越多之后成了一个必须显式遵守的约定——如果哪次加新规则时随手插到了数组前面,报错的优先级就会被打乱,用户第一眼看到的错误信息可能是最不重要的那条。后来在代码评审里专门加了一条检查项:新增规则默认追加到数组末尾,除非有明确理由要提前拦截,否则不要插队。

这套架构解决的其实是同一类问题

异步结果乱序、跨字段依赖、动态 schema、规则处理器分层、执行顺序——单独看是五个不同的技术点,但共同的根源只有一个:这次开户表单里,"字段的合法性"不再是一个静态、孤立的判断,而是随时间变化(异步结果可能过期)、随其他字段变化(跨字段依赖)、随运行时配置变化(schema 驱动)的动态结果。去年那篇资料编辑表单碰到的问题基本都能归约成"这个字段自己对不对",这次遇到的问题基本都要多问一句"对的标准本身会不会变、什么时候变、变了之后谁需要知道"。校验架构设计到最后,其实是在给"合法性"这件事建立一套可追踪的更新机制,而不是简单地给每个字段挂一条 rule.test(value)