ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Vue计算属性实现表单实时校验:从手动到声明式的模式重构

Vue计算属性实现表单实时校验:从手动到声明式的模式重构 表单校验这活儿前端十个人里至少有八个人写过。早些年我做后台管理系统一个录入页恨不得塞二十个字段产品提的需求永远是“用户输完立刻给提示别等提交了才跳红”。一开始我也用 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-modelemail typeemail placeholder请输入邮箱 / p v-ifemailError classerror{{ 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 整体提交状态用一个总计算属性汇总结果单字段的校验结果都有了接下来最自然的需求是所有字段都通过时提交按钮才能点击。这个“整体状态”也是一个派生结果非常适合用 computedconst 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-ifusernameError改成v-ifusernameError blurTouched.usernameinput v-modelusername blurhandleBlur(username) / p v-ifusernameError blurTouched.username classerror {{ usernameError }} /p这样用户没碰过的字段不会出现红字一旦他离开某个字段失焦这个字段的错误就开始实时显示。他回来改了错误信息也会随着输入自动更新这是“失焦后实时校验”和“失焦时校验一次”最大的区别。提示这里有一个细节容易被忽略。blurTouched的状态和“用户是否修改过”“校验是否通过”是两码事。blurTouched 只负责“是否展示”校验通过不通过交给 computed 管。这样职责分离之后改展示逻辑不会影响校验逻辑改校验规则也不会弄坏展示时机。3.3 汇总提交状态顺便把密码强度也做成 computed四个字段的错误都有了整体状态就一行const canSubmit computed(() { return !usernameError.value !emailError.value !passwordError.value !confirmPasswordError.value })模板里button typesubmit :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.preventhandleSubmit div classform-item label用户名/label input v-modelusername blurhandleBlur(username) / p v-ifusernameError blurTouched.username classerror {{ usernameError }} /p /div div classform-item label邮箱/label input v-modelemail typeemail blurhandleBlur(email) / p v-ifemailError blurTouched.email classerror {{ emailError }} /p /div div classform-item label密码/label input v-modelpassword typepassword blurhandleBlur(password) / p v-ifpasswordError blurTouched.password classerror {{ passwordError }} /p p密码强度{{ strengthLabel }}/p /div div classform-item label确认密码/label input v-modelconfirmPassword typepassword blurhandleBlur(confirmPassword) / p v-ifconfirmPasswordError blurTouched.confirmPassword classerror {{ confirmPasswordError }} /p /div button typesubmit :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 仍然是 truev-ifusernameError 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 试试。你会发现原来实时校验可以写得这么顺。等项目里形成了这个习惯再去看那些封装好的表单库理解它们的内部原理都会轻松很多。
返回列表