表单校验这活儿,前端十个人里至少有八个人写过。早些年我做后台管理系统,一个录入页恨不得塞二十个字段,产品提的需求永远是“用户输完立刻给提示,别等提交了才跳红”。一开始我也用 change 事件、blur 事件手动调校验函数,写着写着就发现代码变成一锅粥:校验状态散落在 data 里,触发时机靠人肉记忆,改一处漏一处。后来换用 Vue 的 computed(计算属性)重写整块校验逻辑,代码量直接砍半,而且出错的概率低了很多。这篇文章我就把这个“用计算属性写表单实时校验”的思路、模式、坑和完整实操一次性讲透。
先说清楚这东西是什么、能干什么:实时校验,指的是用户在输入框里敲字的同时,页面就能立刻判断当前内容合不合规,并把提示信息挂在输入框下面。computed 做这件事的核心逻辑,是把“用户输入的数据”当作原料,把“这一项的校验结果”当作派生产物——数据一变,校验结果自动跟着刷新,不需要你手动去通知谁。适合谁看?正在用 Vue 2 / Vue 3 写表单页的开发者,尤其是被各种“提交时才校验”“失焦时才校验”搞到想骂人的同学。看完这篇文章,你会拿到一套可以直接抄进业务代码的写法,以及我踩过的好几个坑。
1. 为什么说计算属性天生适合做实时校验
1.1 先想清楚:实时校验的本质是什么
很多人一上来就写代码,其实没想明白实时校验到底在干什么。拆开看,它只有三步:用户输入触发数据变化 → 根据这块数据算出一个“是否合法”的结论 → 界面根据结论显示错误提示或通过标记。这中间最关键的不是“怎么校验”,而是“怎么让结论跟着数据自动变”。
这就是 computed 的看家本领。Vue 的 computed 是个“派生状态”系统:你声明一个计算属性,它内部依赖了哪些响应式数据,Vue 就帮你盯住哪些数据。依赖的数据一变,计算属性自动重算,模板里用到它的地方跟着更新。换成表单场景,就是说你只需要在 computed 里写清楚“给定 email 的值,返回邮箱格式对不对”,剩下“什么时候重算、什么时候刷新界面”这些脏活累活,框架全包了。
在这个模型里,用户输入是“因”,校验结果是“果”。你完全不用关心“果”是哪个时刻被算出来的,只要保证“因”一变,“果”必变。这就是声明式编程的舒服之处:你的注意力从“触发时机”转移到“数据关系”上。
1.2 为什么不推荐用 methods 或 watch
我知道很多老代码是用 methods + input 事件做的,大概长这样:
function onEmailInput(e) { email.value = e.target.value if (!email.value) { emailError.value = '邮箱不能为空' } else if (!emailRegex.test(email.value)) { emailError.value = '邮箱格式不正确' } else { emailError.value = '' } }这段代码的毛病不是“不能跑”,而是“状态太多,容易失去同步”。emailError 是个独立变量,它的值必须靠你在每个可能改变 email 的地方去手动维护。今天用户只通过 input 事件改 email,那没问题;明天你在某个下拉框里选了“用注册邮箱自动填充”,就得记得再调一次 onEmailInput。漏一次,界面上就会出现“数据已经填好了,错误提示还挂着”的尬局。
watch 会比 methods 好一点,因为它至少是自动触发的:
watch(email, (value) => { // 校验并赋值 error })但 watch 的核心语义是“当数据变化时,去执行一段副作用”。注意“副作用”这三个字——你在 watch 里要做的事情是“改另一个变量的值”,这本身就是把同步逻辑拆成了两步。而且 watch 不会回传值,校验结果还是得先放到某个响应式变量里,模板再去读这个变量。多中间一层,就多一个出错的可能。
computed 的语义就纯粹得多:它不是“去做什么”,而是“等于什么”。校验结果不是被某段代码“写”出来的,而是从现有数据中“推导”出来的。一个数据源(email),对应一个推论(emailError),中间没有中间变量、没有“某段代码忘了执行”的空间。模板里读 emailError 的时候,它必然已经是基于当前 email 的最新结果。
1.3 计算属性的缓存机制,在表单场景里帮了大忙
computed 还有一个经常被忽视的优点是缓存。只要它依赖的响应式数据没变,多次读取 computed 的值不会重复执行内部的校验函数。
这一点对表单页特别实用。一个字段的错误信息,可能同时被模板里的错误提示、按钮的禁用状态、字段边框颜色三处读取。如果你用的是 methods,那每次渲染都得把校验函数重新跑一遍;字段多、校验规则复杂时,这种重复计算会真真切切地拖慢页面。而 computed 第一次算完就把结果缓存了,同一轮渲染里多次读取,拿到的都是同一个值,直到依赖数据真的发生变化。
注意:computed 的缓存是基于“响应式依赖有没有变化”的。如果你在 computed 里读了一个普通变量,或者用了类似 Math.random()、Date.now() 这种每次调用都返回新值的东西,缓存就失效了,甚至会引起异常。这一点后面我会专门讲。
2. 核心设计模式:一个字段一个计算属性,一个整体一个计算属性
2.1 基本骨架:把校验结果当成一项“派生数据”
把单字段校验改写成 computed,模式非常固定。以邮箱为例:
const email = ref('') const emailError = computed(() => { const value = email.value.trim() if (!value) return '邮箱不能为空' if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '邮箱格式不正确' return '' })模板里只需要写:
<input v-model="email" type="email" placeholder="请输入邮箱" /> <p v-if="emailError" class="error">{{ emailError }}</p>这里最基本的逻辑是:computed 返回的字符串本身就是错误消息。为空字符串表示“通过”,非空字符串表示“不通过,并给出具体原因”。模板里一句 v-if 就能控制提示显隐,完全不需要额外维护一个布尔标志。
很多同学问:为什么不用 { valid: true/false } 或 true/false?我的经验是,字符串包含的信息量更大。它既是“校验是否通过”的标志,又是“为什么不通过”的说明。你无非是做个微调:
const emailStatus = computed(() => { const value = email.value.trim() if (!value) return { valid: false, message: '邮箱不能为空' } if (!emailRegex.test(value)) return { valid: false, message: '邮箱格式不正确' } return { valid: true, message: '' } })这种写法适合一个字段可能有多条错误,或者你需要区分“空值”“格式错”“已被占用”等不同错误类型的场景。返回对象的话,模板里读 {{ emailStatus.message }},v-if 里读 emailStatus.valid,也很直观。
2.2 多个字段时,别把所有逻辑塞进一个计算属性
表单不可能只有一个字段。新手最容易犯的错误是:把所有校验逻辑集中到一个 computed 里,一个计算属性把整个表单的错误对象全算出来。
// 反面教材 const formErrors = computed(() => { const errors = {} if (!username.value) errors.username = '用户名为空' if (!email.value) errors.email = '邮箱为空' if (password.value.length < 6) errors.password = '密码太短' return errors })这种写法的最大问题不是“不能跑”,而是“依赖粒度太粗”。computed 的依赖跟踪是字段级别的——只要 formErrors 内部读到了 username、email、password 中的任何一个变化,整个 formErrors 全部重算。字段少的时候感觉不出来,字段一旦超过十个,用户每敲一个字符,二十个字段的校验逻辑全跑一遍,性能就开始打折扣。
更合理的做法是“一个字段对应一个 computed”。每个字段的校验函数彼此独立,谁发生变化,只有谁对应的那个 computed 会重算,其余字段的校验结果直接从缓存里拿:
const usernameError = computed(() => { const value = username.value.trim() if (!value) return '用户名不能为空' if (value.length < 3) return '用户名至少3个字符' return '' }) const emailError = computed(() => { // ... }) const passwordError = computed(() => { // ... })独立 computed 的另一个好处是模板里引用更直观。你不需要写 formErrors.username 这种穿了两层衣服的表达式,直接读 usernameError,语义清清楚楚。等字段多了,再考虑用 defineComponent 或 setup 函数把“一个字段的校验”封装成一个小函数,避免循环复制粘贴。
2.3 整体提交状态:用一个总计算属性汇总结果
单字段的校验结果都有了,接下来最自然的需求是:所有字段都通过时,提交按钮才能点击。这个“整体状态”也是一个派生结果,非常适合用 computed:
const canSubmit = computed(() => { return !usernameError.value && !emailError.value && !passwordError.value })然后提交按钮直接绑:
<button :disabled="!canSubmit">提交</button>你注意到没有,整个过程从“用户输入”到“按钮禁用态”,中间没有任何人手动去调用函数、赋值变量。数据的流向是单向且自动的:输入 → 响应式数据 → 计算属性 → 模板。这就是 computed 做校验最舒服的地方——你根本不用考虑“什么时候去校验”,只需要关心“校验规则是什么”。
如果你用了 2.1 里的对象格式,canSubmit 也可以这样写:
const canSubmit = computed(() => { return [usernameStatus, emailStatus, passwordStatus].every(item => item.valid) })哪种都行,核心是:所有校验结论汇总成一个布尔值,给“提交”这个动作当守门员。我个人的习惯是,哪怕只有一个字段,也会先把 canSubmit 写出来,因为几乎所有表单都会从一个字段慢慢加到一个字段以上,提前把“汇总”层留好,后面加字段只用补一个 computed 和一行汇总条件。
3. 实操记录:手写一个带实时校验的用户注册表单
3.1 先定义校验规则,再把校验函数写干净
这一段我不讲空理论,直接复盘一个真实的注册表单。字段有四个:用户名、邮箱、密码、确认密码。需求是:用户一输入就实时校验,不满足规则就在输入框下方给提示,全部合法后提交按钮才能点。
第一步,把校验规则单独抽出来。这样做的目的是避免把正则、长度判断、空值判断全堆在页面组件里,后续要是接入表单设计器、动态脚本,规则部分换成 JSON 配置也很方便:
const rules = { username: [ { required: true, message: '用户名不能为空' }, { min: 3, message: '用户名至少3个字符' }, { max: 12, message: '用户名最多12个字符' } ], email: [ { required: true, message: '邮箱不能为空' }, { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '邮箱格式不正确' } ], password: [ { required: true, message: '密码不能为空' }, { min: 6, message: '密码至少6个字符' } ] }第二步,写一个通用的“按规则校验”函数。它的职责很简单:给一个值和一组规则,返回第一条违规的提示文字;全部通过,返回空字符串:
function validate(value, ruleList) { for (const rule of ruleList) { if (rule.required && !String(value).trim()) return rule.message if (rule.min && String(value).length < rule.min) return rule.message if (rule.max && String(value).length > rule.max) return rule.message if (rule.pattern && !rule.pattern.test(String(value))) return rule.message } return '' }这么做的好处是,每个表单字段的 computed 只是用参数把规则“喂”进去,逻辑简洁、没有重复:
const username = ref('') const email = ref('') const password = ref('') const confirmPassword = ref('') const usernameError = computed(() => validate(username.value, rules.username)) const emailError = computed(() => validate(email.value, rules.email)) const passwordError = computed(() => validate(password.value, rules.password)) const confirmPasswordError = computed(() => { if (!confirmPassword.value) return '请再次输入密码' if (confirmPassword.value !== password.value) return '两次密码不一致' return '' })3.2 什么时候显示错误?用“交互过才提示”避免开局全红
这里有一个高频需求要处理:如果页面打开就把所有错误全部显示出来,用户体验非常差。用户还没开始填,满屏红字先砸过来,谁看了都烦。常规做法是“用户操作过这个字段,才显示这个字段的错误”。
实现方式是在 data 里记录“哪些字段已经失焦过”:
const blurTouched = ref({ username: false, email: false, password: false, confirmPassword: false }) function handleBlur(field) { blurTouched.value[field] = true }模板里就把显示条件从v-if="usernameError"改成v-if="usernameError && blurTouched.username":
<input v-model="username" @blur="handleBlur('username')" /> <p v-if="usernameError && blurTouched.username" class="error"> {{ usernameError }} </p>这样用户没碰过的字段不会出现红字,一旦他离开某个字段(失焦),这个字段的错误就开始实时显示。他回来改了,错误信息也会随着输入自动更新,这是“失焦后实时校验”和“失焦时校验一次”最大的区别。
提示:这里有一个细节容易被忽略。
blurTouched的状态和“用户是否修改过”“校验是否通过”是两码事。blurTouched 只负责“是否展示”,校验通过不通过交给 computed 管。这样职责分离之后,改展示逻辑不会影响校验逻辑,改校验规则也不会弄坏展示时机。
3.3 汇总提交状态,顺便把密码强度也做成 computed
四个字段的错误都有了,整体状态就一行:
const canSubmit = computed(() => { return !usernameError.value && !emailError.value && !passwordError.value && !confirmPasswordError.value })模板里:
<button type="submit" :disabled="!canSubmit">注册</button>到这里,基础版的实时校验表单已经能用了。但既然说“高质量验证逻辑”,我还想多提一个延伸场景:除了“对错”这种二元判断,computed 也特别适合做“程度”类提示,比如密码强度。这个不是简单的正确或错误,而是根据密码内容推导出一个级别:
const passwordStrength = computed(() => { const value = password.value if (!value) return 0 let score = 0 if (value.length >= 6) score++ if (/[A-Z]/.test(value) && /[a-z]/.test(value)) score++ if (/\d/.test(value)) score++ if (/[^A-Za-z0-9]/.test(value)) score++ return score }) const strengthLabel = computed(() => { const map = ['', '弱', '中', '强', '极强'] return map[passwordStrength.value] })你看,同样的“输入 → 推导 → 输出”三步,既能做校验,又能做强度提示。这也是我劝大家习惯用 computed 的原因:表单页有一大堆这类“跟输入有关、但不是输入本身”的状态,与其用变量一个个存,不如全用 computed 推导,代码会越写越清爽。
3.4 完整模板:表单主体长什么样
给一个完整的模板片段,方便直接对照:
<form @submit.prevent="handleSubmit"> <div class="form-item"> <label>用户名</label> <input v-model="username" @blur="handleBlur('username')" /> <p v-if="usernameError && blurTouched.username" class="error"> {{ usernameError }} </p> </div> <div class="form-item"> <label>邮箱</label> <input v-model="email" type="email" @blur="handleBlur('email')" /> <p v-if="emailError && blurTouched.email" class="error"> {{ emailError }} </p> </div> <div class="form-item"> <label>密码</label> <input v-model="password" type="password" @blur="handleBlur('password')" /> <p v-if="passwordError && blurTouched.password" class="error"> {{ passwordError }} </p> <p>密码强度:{{ strengthLabel }}</p> </div> <div class="form-item"> <label>确认密码</label> <input v-model="confirmPassword" type="password" @blur="handleBlur('confirmPassword')" /> <p v-if="confirmPasswordError && blurTouched.confirmPassword" class="error"> {{ confirmPasswordError }} </p> </div> <button type="submit" :disabled="!canSubmit">注册</button> </form>到这里,一个“输入即校验、交互后才展示错误、全部合法才能提交”的注册表单,核心逻辑已经完整了。整体代码量不大,但每一块的职责都非常清晰:data 只存用户输入和交互状态,computed 只做校验推导,模板只做展示。
注意:这里我没提“提交时的最终兜底校验”。因为提交时重新校验一遍的写法跟 computed 是同一套逻辑,可以在 handleSubmit 里把 blurTouched 全部置为 true,让所有字段的错误都展示出来,再把 canSubmit.value 的判断拿到 submit 里拦截一次。这样既做到“体验好”(实时提示),也保证“绝对安全”(提交时再兜底一次)。
4. 常见问题与踩坑排查
4.1 computed 里用 Math.random() / Date.now():缓存跟你翻脸
有一次我在一个“动态验证码校验”的页面上,想在 computed 里生成一个带时间戳的提示语:
const tip = computed(() => { return `${emailError.value} 当前时间:${Date.now()}` })看起来没毛病,结果页面上“当前时间”这四个字永远不变。原因就是 computed 的缓存机制:它只在依赖的数据(emailError)变化时才重新计算,Date.now() 不是响应式数据,Vue 根本不知道它变了。
这不是 computed 的 bug,是使用姿势的问题。computed 适合“根据响应式数据推导状态”,不适合“每次读取都执行新逻辑”。遇到这种“要最新时间”“要随机数”“要防抖节流后拿结果”的需求,老老实实换成 watch + 响应式变量,或者用 methods。记住这条边界,能省下不少排查时间。
4.2 在 computed 里修改其他响应式数据:小心死循环
Vue 有个经典的警告:Computed property ... was assigned to but it has no setter或者“Infinite update loop”。这通常是你在 computed 的 getter 里尝试改另一个响应式变量:
// 反面教材 const usernameError = computed(() => { if (!username.value) { errorCount.value++ // 试图记录错误次数 return '用户名不能为空' } return '' })为什么危险?computed 是“由依赖推导结果”,如果你在推导过程中又去改依赖(或者其他响应式数据),就会触发新一轮更新,新一轮更新又可能触发这个 computed 重算,形成死循环。Vue 检测到无限循环会直接报错。
正确的做法是:computed 内部保持“纯净”——只读响应式数据,不做任何赋值操作、不发请求、不改 DOM、不调用会影响状态的函数。如果确实需要“记录错误次数”这种副作用,请把这些逻辑放到 watch 里去。
4.3 异步校验(比如“用户名是否已被注册”)不能放进 computed
表单校验里有一类非常常见的需求:检查用户名在服务器上是否已被占用。这种校验涉及网络请求,是异步的。而 computed 的求值必须是同步的,它返回一个值就完事了,不可能“等请求回来再告诉你结果”。
所以“用户名唯一性校验”的真实写法一般是:用户输入 → watch 监听用户名 → 防抖 300ms → 发请求 → 把结果写进 serviceError 变量 → 模板显示。这个逻辑用 computed 搞不定,不是写法问题,而是模型不匹配。computed 管“确定性的数据推导”,异步状态必须手动管理。
const username = ref('') const usernameTaken = ref(false) watch(username, async (value) => { if (!value) return const res = await fetch(`/api/check-username?name=${value}`) usernameTaken.value = (await res.json()).taken })校验规则上,usernameError只负责格式和必填这类同步校验,服务端返回的“已被注册”当成另一个独立状态来展示。做设计时先分清楚“同步规则”和“异步请求”的边界,整个表单的复杂度才控制得住。
4.4 所有错误提示都被打断:计算属性的依赖树要细粒度
前面提到过“别把所有字段的校验塞进一个 computed”,这里用一个性能场景验证一下。假设表单有 20 个字段,你把 20 条校验规则全部放到一个formErrorscomputed 里。用户每敲一个字符,这个 computed 依赖的 20 个 ref 里只要有一个变化,整棵校验树重跑一遍。如果每条校验都要做正则、长度判断、格式判断,一轮下来就是 20 次计算。用户敲得快一点,页面跟着卡顿,体验很糟糕。
拆成 20 个独立 computed 后,用户敲 username,只有 usernameError 重算,剩下 19 个从缓存里直接拿结果。这个优化不需要你额外写任何“缓存代码”,全靠框架本身的功能,代价仅仅是把 validate 调用拆开。遇到性能问题时,先看 computed 的拆分粒度,往往比换库、加缓存有效得多。
4.5 清空表单时容易遗漏“交互状态”
表单里总会有个“重置”或“清空”按钮。清空所有输入值是容易的:
function resetForm() { username.value = '' email.value = '' password.value = '' confirmPassword.value = '' }但如果你把 blurTouched 忘了重置,就会出现诡异现象:字段已经全部清空了,页面上的红色错误信息还挂着——因为 blurTouched 仍然是 true,v-if="usernameError && blurTouched.username"里第一个条件因为 username 清空而成立(“用户名不能为空”),第二个条件又没被重置,所以错误照样显示。
正确做法是 reset 时把“输入值”和“交互状态”一起清掉:
function resetForm() { username.value = '' email.value = '' password.value = '' confirmPassword.value = '' blurTouched.value = { username: false, email: false, password: false, confirmPassword: false } }这也是“输入数据”和“展示状态”分离后必然会遇到的问题:你要清两样东西,少了一样就会出问题。反过来,如果 validation 结果全是用 computed 推导的,重置后错误信息自然消失,你只需要多管一个 blurTouched。
4.6 错误提示里的用户输入要转义,小心 XSS
这条严格说不是 computed 的问题,但既然聊到“高质量验证逻辑”,就顺带提一句。如果错误信息里拼接了用户输入的内容,比如“用户名 test 已被占用”,那么 test 这部分内容来自外部,直接塞进 innerHTML 是有 XSS 风险的。热词里提到的“存储型 XSS”,本质上就是不可信内容未经过滤就被渲染。
Vue 模板里默认的插值 {{ }} 会自动转义,所以常规写法是安全的。但如果你用了v-html,或者手动往 DOM 里塞 HTML 字符串,就必须先转义再拼接。校验逻辑写得再漂亮,安全这道底线不能丢。
5. 进阶实践:从单表单到可配置规则和表单引擎
5.1 把规则 JSON 化:动态脚本与表单设计器的起点
如果你做过的后台系统比较多,应该会碰上这种需求:表单不是写死的,而是由后端配置、在前端动态渲染的。这个时候,前面写的 rules 对象就可以升级为 JSON 配置项:
const formConfig = [ { field: 'username', label: '用户名', rules: [{ required: true, message: '用户名不能为空' }] }, { field: 'email', label: '邮箱', rules: [{ pattern: emailRegex, message: '邮箱格式不正确' }] } ]然后用 v-for 渲染表单,配合一个“动态生成校验 computed”的方式。Vue 3 里可以用computed(() => { ... })放在循环里创建,也可以用reactive存一组校验结果。注意:在循环里创建 computed 要小心,它仍然遵循“响应式依赖跟踪”的原则,只是每次循环都会新开一个 computed 实例,数量多时要注意性能。
这里能演化成一整套“表单引擎”的核心原因,其实是“校验即配置,配置即数据”。当校验规则从代码里抽出来变成 JSON,放到后端或者表单设计器里去维护,前端就只需要一个“遍历配置、逐个执行规则、汇总结果”的引擎。像热词里提到的“若依表单设计器动态执行脚本”,本质也是把这一段“配置规则 → 校验结果”的管道做得更通用。而 computed 在这里扮演的角色,仍然是最底层的“每个字段一条派生状态”。
5.2 封装成一个可复用的校验钩子
开发久了你就发现,表单校验的模板代码太多,最值得抽出来的是一个useFormValidation函数。它接收表单的初始值和一个规则配置,返回form、errors、canSubmit、reset等一组状态和方法:
function useFormValidation(initialValues, rulesConfig) { const form = reactive({ ...initialValues }) const blurTouched = reactive({}) const errors = {} Object.keys(rulesConfig).forEach((field) => { blurTouched[field] = false errors[field] = computed(() => { // 遍历 rulesConfig[field] 里的规则 for (const rule of rulesConfig[field]) { const message = validate(form[field], [rule]) if (message) return message } return '' }) }) const canSubmit = computed(() => Object.values(errors).every((errorRef) => !errorRef.value) ) function reset() { Object.keys(initialValues).forEach((key) => { form[key] = initialValues[key] blurTouched[key] = false }) } return { form, errors, blurTouched, canSubmit, reset } }这样一来,业务代码里再写一个表单,只需要维护initialValues和rulesConfig两个对象,剩下的交给你封装的钩子就行。可维护性比“每个字段手动写一份 computed”又高了一截。
5.3 什么时候该引入第三方库
这话我得说在前头:computed 写校验适合大多数“自研表单”场景,但如果你的项目里有大量表单、复杂的联动校验(字段 A 的值决定字段 B 的校验规则)、跨表单交叉校验,别硬扛,直接用成熟的表单库,比如 VeeValidate、Element Plus 自带 Form 组件、Ant Design Vue 的 Form 组件。
我见过不少同学,明明项目里已经引入了 Element Plus,还在手写一堆 computed 去模仿人家内置的表单校验能力,最后写出来校验时机、错误样式、表单联动全都要自己调,费时费力还容易出 bug。计算属性做表单校验,核心价值是“让你理解状态怎么派生”,在“轻量、独立、规则简单”的场景下特别好使;真到了重型表单场景,直接站在第三方库的肩膀上,再配合 computed 做少量自定义逻辑,才是性价比最高的方案。
我的建议是:小型表单、设计稿特殊、不想引入额外依赖,用 computed 手写完全没问题;大型系统、团队多人协作、规则频繁变更,选一个成熟的表单方案,比什么都重要。
最后说点个人体会
我用 computed 重写表单校验之后,最大的感受是“代码里几乎没有主动赋值错误信息的语句了”。之前写 methods 的时候,错误信息是“被算完存起来的变量”;现在它变成“随着输入自动推导出来的结果”。这个转变让我少操了很多心:不用再想着在哪个回调里调用校验函数,不用怕漏了某个入口导致错误信息不同步。
如果非要给一个抓手级的建议,我会说:先别管组件库、别管表单引擎,今晚打开你的项目,挑一个简单的搜索表单,把里面的校验逻辑改成 computed 试试。你会发现,原来实时校验可以写得这么顺。等项目里形成了这个习惯,再去看那些封装好的表单库,理解它们的内部原理都会轻松很多。