尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用Vue计算属性优雅实现表单实时校验

用Vue计算属性优雅实现表单实时校验 表单校验这个需求说大不大说小不小但几乎每个前端项目都躲不开。我见过太多项目从一开始用提交时校验到后来被产品要求改成“输入错误就马上标红”结果一堆watch满天飞、校验状态散落在各种变量里改一个字段跟拆地雷似的。用计算属性做实时校验不是唯一方案但在我做了这么多项目之后可以负责任地说它是代码可维护性和用户体验平衡得最好的方案。这篇文章不讲大道理直接拿实际案例一步步拆从单个输入框到整张表单再到异步校验写完这套逻辑你基本可以把它搬进任何一个项目里当模板用。1. 先搞清楚计算属性为什么天然适合做表单实时校验1.1 传统校验方式为什么越写越难受先说说我们以前是怎么做表单校验的很多人应该有印象。第一种是最原始的提交时校验点提交按钮之后统一跑一遍校验函数把错误提示塞进去渲染出来。这种方式的体验问题很明显——用户必须填完一整套表单点击提交的那一瞬间才知道哪里填错了填错太多的表单直接就让人想扔电脑。更麻烦的是如果用户某个字段本来就填错了后来改对了错误提示不会自己消失除非你重新点一次提交。后来有人开始用watch监听单个字段每监听一个字段就手动把一个错误信息赋值给某个响应式变量。字段少的时候还行字段一多问题全来了十几个watch堆在一个组件里每个watch的callback逻辑又长又重复改一个校验规则要同时改watch和提交函数的校验逻辑两边一旦没同步就会出现“提交报错和实时提示结果不一致”这种灵异事件。还有一部分同学直接用HTML5自带的required、pattern、maxlength这些属性配合:invalid伪类来标红。这种方法对付简单表单确实省事但碰到真实业务就乏力了。比如“确认密码要和密码一致”“结束日期不能早于开始日期”“手机号和邮箱至少填一个”这种跨字段关联校验HTML5自身那套基于单个input的规则体系做不了最后还是得回到JavaScript。而且默认的错误气泡UI在部分浏览器里还特别难看要自定义样式得下不少功夫。1.2 computed为什么能做到“输入即校验”计算属性解决的核心问题是它天生就具备“依赖追踪”和“自动重新计算”的能力。你在computed里写了一个函数函数内部用到了username这个ref或者reactive里的字段Vue就会记下这个依赖关系。每次input事件导致username的值发生变化时computed会自动检测到依赖变了然后重新执行那个函数把新的结果交给模板渲染。这个过程用生活里的例子类比特别容易理解就是一个Excel表格。你有A1、A2、A3三格的数据在B1格里写了公式SUM(A1:A3)那么无论修改哪一个单元格的数字B1的总和都会自动更新。你不需要手动告诉Excel“请重新计算一下”它自己就好。计算属性就是这个公式表单里的输入框就是那些数据源单元格。这里有个特别重要的特性——computed是有缓存的。它不像methods里的方法一进渲染周期就被反复执行。computed只有在它依赖的响应式数据发生变化时才会重新计算依赖没变就直接返回上一次的缓存结果同样的校验函数在性能上比methods当时调用要省得多。1.3 computed、watch、methods到底该选哪个很多新手对这三者的选择一直很困惑我这里直接给一个判断标准如果一个数据可以通过另一个数据直接派生出来就用computed如果需要在数据变化时执行一些带副作用的操作比如发请求、写日志、操作DOM才用watch如果校验逻辑只是用户在点击提交的时候跑一次并不需要响应式地实时展示用methods也完全可以。用表格来对比会清晰很多维度computedwatchmethods数据来源由已有响应式数据派生监听已有数据变化后手动处理主动调用时执行是否自动更新依赖变化时自动重新求值依赖变化时自动触发回调不会自动执行缓存机制有依赖不变时直接用缓存结果无每次触发都会执行无调用即执行适合场景实时校验、派生状态异步操作、复杂副作用提交按钮的最终校验、一次性事件处理实际开发中实时校验这种场景就是“一个字段的值派生出一段错误提示文案”计算属性和这个模型的匹配度太高了。watch更适合拿来监听某个数据变化然后做点别的事情比如用户改了城市你判断要不要重置街道选择框。但用它来实时存储“错误文案”就又回到刚才说的那个散落变量的坑里了。2. 从零手写单字段校验没你想的那么复杂2.1 第一个demo用户名实时长度校验老规矩先从一个最简单的用户名校验开始。需求是用户名不能为空长度在3到12个字符之间不符合时在输入框下方显示红色提示。用Vue 3的setup语法代码长这样script setup import { ref, computed } from vue const username ref() const usernameError computed(() { const value username.value.trim() if (!value) { return 用户名不能为空 } if (value.length 3 || value.length 12) { return 用户名长度需要在3-12个字符之间 } return }) /script template div classform-item input v-modelusername typetext placeholder请输入用户名 / p v-ifusernameError classerror-text{{ usernameError }}/p /div /template style scoped .error-text { color: #e74c3c; font-size: 13px; margin-top: 4px; } /style这段代码的逻辑非常直观。username是输入框绑定的响应式数据usernameError是一个计算属性每次username一变它就重新跑一遍trim、判空、长度判断然后把错误文案返回。返回空字符串时模板里v-if的值是false提示不渲染。用户只要输入一个字符校验立刻执行如果长度不够提示马上出现再输几个字符长度够了提示马上消失。这里有一个很多人会忽略的细节我先调用了trim()方法。为什么要trim因为用户在输入法下经常会在首尾多敲出空格如果不做trim“ abc ”这种输入长度虽然够但实际上用户名字符串里混着空格如果不处理会出现一提交就被后端拒绝的情况。实时校验阶段就顺手把前后空格的问题解决掉后面提交的时候再trim一次双保险。2.2 邮箱、手机号的正则校验怎么写才稳用户名长度校验只是开胃菜真实业务里最常见的是格式校验。这里我直接把我项目里沉淀下来的邮箱和手机号正则贴出来都是经过生产环境验证过的适用范围很广script setup import { ref, computed } from vue const email ref() const phone ref() const emailError computed(() { const value email.value.trim() if (!value) { return 邮箱不能为空 } // 基础但够用的邮箱格式校验 const reg /^[^\s][^\s]\.[^\s]$/ if (!reg.test(value)) { return 邮箱格式不正确 } return }) const phoneError computed(() { const value phone.value.trim() if (!value) { return 手机号不能为空 } // 国内手机号只校验11位且1开头 const reg /^1[3-9]\d{9}$/ if (!reg.test(value)) { return 手机号格式不正确 } return }) /script template div classform-item input v-modelemail typeemail placeholder请输入邮箱 / p v-ifemailError classerror-text{{ emailError }}/p /div div classform-item input v-modelphone typetel placeholder请输入手机号 / p v-ifphoneError classerror-text{{ phoneError }}/p /div /template邮箱正则是/^[^\s][^\s]\.[^\s]$/意思是不允许空格和出现在不必要的部分必须有一个后面至少要有一个点点前后不能是空。这个正则我不会写得复杂到要去校验域名后缀是否顶级那种程度一方面太长影响可读性另一方面真实业务邮箱格式校验真的不需要做到那个级别只要能把明显的错别字拦下来就完成任务了剩下的交给邮箱的验证邮件环节去兜底。手机号正则^1[3-9]\d{9}$是当前比较稳妥的国内手机号校验方案。开头必须是1第二位在3到9之间后面再跟9位数字总长度11位。有些老正则还保留着^1[3456789]\d{9}$这种写法其实[3-9]已经覆盖了5、6、7、8这些所有现有号段写起来也更简洁。2.3 交互细节一直报红还是失焦再提示代码写完接下来要面对一个用户交互问题这也是做实时校验绕不开的决策用户刚点进输入框什么都还没输入提示就弹出来“不能为空”了这体验其实很糟糕会让人有一种被冒犯的感觉。更好的做法是把“是否已经动过这个字段”的状态记下来只有用户触碰过blur了或者输入过才把实时校验的错误展示出来。实现加一个touched标记就行不复杂。这里用blur事件作为触发点用户在输入框失去焦点时标记为已触碰之后实时校验才开始显示错误script setup import { ref, computed } from vue const username ref() const usernameTouched ref(false) const usernameError computed(() { const value username.value.trim() if (!value) return 用户名不能为空 if (value.length 3 || value.length 12) return 用户名长度需要在3-12个字符之间 return }) const showUsernameError computed(() { return usernameTouched.value usernameError.value }) /script template div classform-item input v-modelusername typetext placeholder请输入用户名 blurusernameTouched true / p v-ifshowUsernameError classerror-text{{ usernameError }}/p /div /template这样处理之后一个完整的用户流程是用户点进输入框开始输入此时即使还没输完也不会报错等到用户把焦点移出去的瞬间touched变为true如果这时候内容还是空的或者长度不对错误提示才出现。在用户回到输入框继续修改的过程中因为touched已经为true了所以每次按键都会重新计算错误提示改对了立刻消失改错了继续提示。这个小细节看着简单但对体验提升非常明显。如果不想每个字段都手动维护一个touched变量也可以把这个逻辑封装成一个小组件Composable函数或者表单组件内部自动处理后面的章节我会提到怎么抽成一个可复用的校验引擎。3. 整表单校验从单个字段到提交状态联动3.1 设计一个统一返回结构化结果的校验对象单个字段校验会了接下来把视角拉到整张表单。比如一个注册表单包含用户名、邮箱、手机号、密码、确认密码五个字段如果按照上面的方式一个字段写一个computed代码会显得臃肿。更合理的做法是用一个computed返回一个包含所有校验结果的对象。script setup import { reactive, computed } from vue const form reactive({ username: , email: , password: , confirmPassword: , }) const errors computed(() { const result {} // 用户名 const username form.username.trim() if (!username) { result.username 用户名不能为空 } else if (username.length 3 || username.length 12) { result.username 用户名长度需要在3-12个字符之间 } // 邮箱 const email form.email.trim() if (!email) { result.email 邮箱不能为空 } else if (!/^[^\s][^\s]\.[^\s]$/.test(email)) { result.email 邮箱格式不正确 } // 密码 if (!form.password) { result.password 密码不能为空 } else if (form.password.length 6) { result.password 密码至少需要6位 } // 确认密码 if (form.confirmPassword ! form.password) { result.confirmPassword 两次输入的密码不一致 } return result }) /scripterrors这个计算属性每次form里的任何一个字段变化时都会把所有校验规则重新执行一遍最终得到一个对象。对象里有字段就表示该字段有错误没有字段就表示这个字段是合法的。模板里要展示某个字段的错误直接读errors.username、errors.email为空就不展示。这种组织方式最大的好处是校验规则和模板展示完全解耦。模板不用关心复杂的校验规则只需要判断errors对象里有没有对应的key。以后如果一个字段的校验规则变了只需要改computed里那一段js逻辑模板代码一行都不用动。3.2 用isValid自动控制提交按钮表单整体有效性判断也顺理成章地出来了。既然errors是一个对象那只要判断这个对象有没有key就能知道表单整体有没有错script setup // ...上面的form和errors继续用 const isValid computed(() { return Object.keys(errors.value).length 0 }) const submit () { // 提交逻辑 } /script template form submit.preventsubmit !-- 各输入项 -- button typesubmit :disabled!isValid提交/button /form /templateisValid这个计算属性的语义特别清晰errors对象里只要有一条错误记录返回false提交按钮就处于disabled状态用户想点都点不了。所有字段都合法了按钮自动变成可点击状态。这里就充分体现了计算属性的“派生状态”思想按钮的可点击状态不是我们手动去维护的一个变量比如let clickable true而是从form表单数据中自动推导出来的一个结果。数据对了按钮自然亮数据有错按钮自然灰不存在两边不同步的情况。不过有一点要提醒把disabled绑到isValid上的方案必须结合前面说的 touched 设计来思考。因为如果用户完全没填任何内容就去点击按钮这时按钮如果已经被disabled了用户反而不知道为什么不能点。所以实际项目里我更常用的交互方式是按钮不禁用但在提交时做一次强制校验如果通过不了就把所有字段的错误整体展示出来并且滚动聚焦到第一个错误字段。这种方式用户能明确看到“到底哪里不对”体验比按钮灰在那里要好很多。3.3 提交时的兜底校验为什么不能只靠computed实时校验虽然在输入过程中给了用户即时的反馈但如果完全依赖computed去禁用提交按钮会遇到一个交互尴尬用户什么都没输入提交按钮直接是灰色用户不知道是因为没填还是填错了就算用户把所有字段填完但由于touched还没有置为true错误提示不会展示用户看到按钮灰着也可能一脸懵。在大多数业务场景里更稳妥的组合拳是实时校验用于输入时提示提交时再调用一次完整的校验函数做最终把关。如果校验不通过就把每一个出错字段的touched设置为true强制展示所有错误同时把页面滚动到第一个出错字段帮用户直接定位问题。这样既保留了实时反馈的顺滑也保证了提交环节不会放过任何一处遗漏。每次提交时校验函数和computed里的规则是同一套那么我建议把校验规则抽成一个独立的纯函数computed和提交函数都调用它。这样可以彻底避免两处逻辑不一致的情况。script setup import { reactive, computed } from vue const form reactive({ username: , email: , password: , confirmPassword: , }) // 抽出独立的校验函数返回错误对象 function validateForm(target) { const result {} const username target.username.trim() if (!username) { result.username 用户名不能为空 } else if (username.length 3 || username.length 12) { result.username 用户名长度需要在3-12个字符之间 } const email target.email.trim() if (!email) { result.email 邮箱不能为空 } else if (!/^[^\s][^\s]\.[^\s]$/.test(email)) { result.email 邮箱格式不正确 } if (!target.password) { result.password 密码不能为空 } else if (target.password.length 6) { result.password 密码至少需要6位 } if (target.confirmPassword ! target.password) { result.confirmPassword 两次输入的密码不一致 } return result } const errors computed(() validateForm(form)) const isValid computed(() Object.keys(errors.value).length 0) const findFirstErrorKey () { return Object.keys(errors.value)[0] || } const focusFirstError () { const firstKey findFirstErrorKey() if (!firstKey) return // 通过key找到对应的输入框并聚焦 const el document.querySelector([data-field${firstKey}]) el?.scrollIntoView({ behavior: smooth, block: center }) el?.focus() } /script几个输出对象的细节validateForm这个纯函数没有依赖任何响应式数据它只接收一个普通对象然后返回校验结果。computed里调用它传form提交时的兜底校验也是直接调用它传form保证两边永远拿的是同一个结果。路径上我额外写了focusFirstError来定位第一个错误字段。这个方法在表单很长、用户在页面下方时特别有用用户点提交页面自动滚到第一个错误框不用自己去满屏找。4. 进阶场景异步校验、中文输入法与表单引擎化4.1 异步唯一性校验computed搞不定的活该交给谁实时校验做到这个程度大部分常见的表单都能覆盖了。但有个场景需要特殊对待异步校验。最典型的就是注册时校验用户名是否已被占用。这种校验没法直接在computed里写因为computed要求函数同步返回结果而检查用户名唯一性必须发HTTP请求结果是异步的。处理这个场景我一般分两条路。如果对实时性要求不高可以放在提交时校验提交按钮点击后再发起请求loading转一圈服务端返回“用户名已存在”再把错误信息挂到username下面。这种方式逻辑最简单前端只需要增加一个处理异步错误的状态。如果产品经理要求输入用户名后失去焦点就要立刻提示是否被占用那就只能用watch单独响应式变量来配合script setup import { ref, watch } from vue const username ref() const usernameChecking ref(false) const usernameExists ref(false) watch(username, async (newVal) { if (!newVal) { usernameExists.value false return } usernameChecking.value true try { const res await fetch(/api/user/check?username${encodeURIComponent(newVal)}) const data await res.json() usernameExists.value data.exists } finally { usernameChecking.value false } }) /script这里的关键点在于异步校验的结果我们单独存到一个ref里不让它进入computed的同步链路。模板展示的时候把同步格式校验和异步占用检查的结果汇总到一起判断。还要注意给请求加防抖否则用户每敲一个字符就发一次请求不光后端压力大前端也会被频繁的loading闪烁干扰。常见的做法是在watch回调里用一个setTimeout延迟300到500毫秒再发请求用户停止输入一段时间后才发起真实请求。4.2 中文输入法组合输入期的坑做实时校验还有一个隐藏比较深的坑中文输入法。用户用拼音输入“张三”的时候输入框里会经历一个“z”、“zh”、“zha”、“zhan”...的组合过程。如果用v-model直接绑定input的valueVue在默认情况下会在compositionstart到compositionend这个区间内持续更新绑定的值。这也意味着你输入拼音过程中computed会不停地重新校验而校验拿到的数据其实是用户还没确认上屏的拼音半成品。解决方式很标准监听compositionstart和compositionend事件在组合输入期间挂起校验等用户真正选中了汉字上屏后再触发。script setup import { ref, computed } from vue const username ref() const isComposing ref(false) const usernameError computed(() { // 输入法组合期不校验等上屏后再校验 if (isComposing.value) return const value username.value.trim() if (!value) return 用户名不能为空 if (value.length 3 || value.length 12) return 用户名长度需要在3-12个字符之间 return }) const onCompositionStart () { isComposing.value true } const onCompositionEnd () { isComposing.value false } /script template input v-modelusername typetext compositionstartonCompositionStart compositionendonCompositionEnd / /template加了composition判断之后用户在拼音输入过程中错误提示会短暂暂停等汉字上屏后computed重新计算拿到的就是完整的词而不是拼音碎片。这个细节在做中文本地化表单时非常重要不做的话经常会出现“输入法还在组词错误提示已经闪了十几回”的问题。4.3 动态表单和表单引擎把校验规则抽取成配置聊到这里再把视角拉远一点。实际项目里表单页面经常会碰到结构重复的情况比如用户填多个联系人每个联系人的手机号、邮箱都要校验或者运营后台用动态表单引擎渲染一份由后端下发的JSON Schema不同的schema对应不同的输入框和校验规则。这个时候再一个字段写一个computed就完全不可行了需要把校验规则数据化、组件化。我的做法是维护一张规则表每个字段的校验由规则数组组成规则对象里包含typerequired、maxLength、minLength、pattern、custom、错误文案、自定义校验函数。一个通用的校验函数遍历规则表执行每一条规则并收集错误信息。一个可复用的FormField组件接收字段的value和rules内部用computed跑校验逻辑渲染出输入框和错误提示。拿表单引擎举例后端返回的schema里既有字段类型又有validators配置前端渲染时用同一套computed校验函数去消费。这样无论表单怎么动态生成只要规则配置正确实时校验就会自动生效。实际工作中我用这种方式做过一个简单版的配置化表单页面十几行的规则配置就能替代原来五十多行的模板代码而且新增字段不用动组件内部逻辑只改配置就行。5. 踩坑速查计算属性做校验的常见问题清单5.1 computed没更新或错误提示不消失这是新手最容易踩的坑症状是这个字段明明已经输入正确了错误提示还挂在页面上。排查思路第一看是不是依赖漏了。computed里如果判断逻辑使用了某个响应式数据Vue会自动追踪依赖但如果你把原始数据存到了非响应式的地方比如const value form.username在computed外部缓存了一份再手动去比较就可能出现依赖追踪不到的问题。解决办法是computed里使用的数据必须直接从响应式数据源上读取。第二看是否使用了非响应式的方法修改数据。比如你用数组的arr[0] xxx这种方式去改变reactive里的数组元素Vue 3里reactive对深层数组的索引修改是可以被追踪的但如果用的是ref数组就必须用arr.value[0] xxxx或splice方法。这个坑排查起来耗时间建议先给computed加一个console.log看看它在数据变化时到底有没有重新执行如果没执行就先查依赖关系。5.2 类型转换陷阱数字输入框拿到的是字符串另一个高频问题出在输入框类型上。typenumber的inputv-model绑定的值在Vue中依然是字符串不是数字。如果你在校验时直接用value 18 || value 60这种数值比较字符串和数字比较时JavaScript会做隐式转换大多数情况下结果是对的但一旦输入框为空空字符串比较false判断逻辑很容易漏掉空值校验。我自己的习惯是任何数字校验的第一步先显式转换类型再比较用Number(value)然后单独判断是不是NaN因为Number()的结果也是0如果不小心就会出现“空输入也能通过最小值校验”的bug。转换成数字以后再统一做范围校验逻辑才可靠。5.3 清空表单时校验状态残留的处理“清空表单内容”这个操作在表单功能中很常见但很多人清空完就发现校验状态还在点一下重置按钮所有输入框内容都空了但错误提示还挂在界面上。原因很简单清空了form数据computed里的校验会立刻重新计算结果每一项都会变成“不能为空”如果你把isValid绑定在提交按钮上按钮也会继续处于禁用状态。这里要分清业务语义重置表单之后到底要不要显示错误大多数情况下不该显示。所以我的处理方式是在清空操作里同时把touched相关状态也重置掉。如果是用前面说的touched机制按钮在重置后不应该直接禁用而应该恢复到初始状态让用户点击提交时再触发统一校验。所以resetForm函数里不能只清空form字段要连touched都一起清掉。5.4 服务端校验永远不能省最后说一个经常被忽略但涉及安全红线的问题前端校验不管做得多么严密服务端校验一步都不能省。因为前端的校验逻辑运行在用户自己的浏览器里用户完全可以通过浏览器开发者工具改掉所有判断直接提交请求。存储型XSS、数据注入、异常值攻击等问题本质都是服务端没有做二次校验导致的。计算属性、实时校验、提交时校验这些都属于“用户体验优化”的范畴真正的数据安全底线一定是在后端接口处把参数再完整验证一遍。结尾计算属性做表单实时校验这件事每次我向团队里新人推荐的时候都会强调一句话校验逻辑不要散落在各种watch和事件处理函数里它应该是一个“由表单数据直接派生出来的结果”。记住这个原则你的校验代码就会一直保持逻辑清晰、状态可控。这篇文章从单字段写到了整表单联动从同步校验写到了异步处理再到输入法、表单引擎和一堆实际踩过的坑基本覆盖了我这些年做表单功能时积累的核心经验。使用过程中如果你遇到了文章里没写到的问题不妨先在依赖追踪和类型转换这两个方向排查这两个方向的坑覆盖面最广也最容易隐藏。
返回列表