ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Vue3 v-model从原理到实践:自定义组件、表单校验与动态表单

Vue3 v-model从原理到实践:自定义组件、表单校验与动态表单 1. 先搞懂 v-model 在 Vue3 里到底是怎么工作的先说个我自己的观察。很多同学从 Vue2 切到 Vue3觉得 v-model 没变化就是把.sync删掉了而已。真去写业务才在自定义组件、表单校验、动态表单这些场景里翻车。Vue3 的 v-model 和 Vue2 的 v-model底层逻辑完全不一样如果你还在按老思路写后面一定踩坑。1.1 v-model 不是双向绑定是语法糖不管是 Vue2 还是 Vue3官方文档里都明确写了v-model 只是语法糖。它并不是什么黑魔法本质上就是帮你把两个操作合并成一个。在 Vue3 中一个普通的输入框这么写input v-modelsearchText /等价于拆开写input :modelValuesearchText update:modelValuesearchText $event /注意这里和 Vue2 最大的区别来了Vue2 里默认绑定的属性是value事件是inputVue3 里默认绑定的属性变成了modelValue事件变成了update:modelValue。为什么改成这样其实是为了语义更统一也让自定义组件实现 v-model 时更规范。Vue2 里遇到select、checkbox、radioinput事件和value属性很容易混Vue3 用一套统一的modelValue规范和update:modelValue事件不管原生元素是什么类型组件内部处理逻辑都一致。你需要理解的一句话是v-model 没有真的帮你做双向绑定它只是帮你省了写属性和监听事件的工作。数据仍然是从父组件往下传子组件通过事件往上通知。一旦你理解了这句话自定义组件的 v-model 就一点不难。1.2 原生表单控件上的 v-model 到底做了什么原生input上使用 v-model 时Vue 会根据元素类型自动选择正确的属性和事件来绑定input[typetext]、textarea绑定value监听input事件input[typecheckbox]、input[typeradio]绑定checked监听change事件select绑定value监听change事件这个自动适配是 Vue 内置的到 Vue3 依然保留。但有一点你要注意Vue3 中如果 v-model 用在input[typecheckbox]上当没有指定value属性时它绑定的是布尔值true/false。如果多个复选框共用同一个数组绑定的则是数组里的值。这个无值多选的写法在业务里非常常用比如权限多选input typecheckbox valueread v-modelpermissions / input typecheckbox valuewrite v-modelpermissions / input typecheckbox valueexecute v-modelpermissions /这里的permissions是一个数组选中时会自动把对应valuepush 进数组取消时自动剔除。这个行为 Vue 已经内置处理好了不需要你手动写逻辑。如果你在自定义组件里做了类似多选的功能这个数组操作逻辑就得自己实现因为原生 v-model 的自动只在原生元素上生效。1.3 多个 v-model 与 v-model 参数化Vue3 支持一个组件上使用多个 v-model这是非常实用的能力。举个例子一个弹窗组件既需要控制显示/隐藏又需要同步标题文本CustomDialog v-model:visibledialogVisible v-model:titledialogTitle /对应的子组件写法script setup const props defineProps({ visible: Boolean, title: String }) const emit defineEmits([update:visible, update:title]) function close() { emit(update:visible, false) } function changeTitle(text) { emit(update:title, text) } /script注意看v-model:visible对应的 props 名是visible事件名是update:visiblev-model:title对应的就是title和update:title。这就是 v-model 的参数化写法。我建议所有组件里的 v-model 都显式带上参数名哪怕只有一个 v-model 也可以写成v-model:modelValue。这样别人看代码的时候一眼就能知道这个组件绑定的到底是什么状态而不是要翻开组件内部才能猜。1.4 Vue2 的 .sync 和 Vue3 的 v-model 有什么关系很多从 Vue2 过来的人会对.sync有感情因为一个组件上可以写多个.sync而 Vue2 的 v-model 只能有一个。Vue3 把.sync的能力合并到 v-model 里了所以你现在不需要再记两套东西统一用 v-model 加上参数就行。对照关系大概是这样的Vue2 写法Vue3 写法说明v-modelvaluev-modelvalue或v-model:modelValuevalue默认绑定的状态.syncvaluev-model:valuevalue自定义绑定的状态名组件内部$emit(update:value, newVal)组件内部emit(update:value, newVal)触发更新的方式不变props 用valueprops 用modelValue默认状态名发生了变化这里有个容易忽略的坑Vue3 里即便不写v-model:visible而是直接写v-modeldialogVisible组件内部接收的 props 名是modelValue不是visible。很多从 Vue2 迁移过来的项目组件 props 里写的是value结果发现怎么都拿不到值就是这个原因。2. 让自定义组件支持 v-model核心就两件事2.1 自定义组件 v-model 的最小实现我做过一个表单控件项目里面所有输入类组件都要求支持 v-model。实现起来其实很固定defineProps定义接收modelValuedefineEmits定义update:modelValue事件然后在合适的时机把数据传回父组件。下面是一个简单的单行输入组件!-- BaseInput.vue -- script setup defineProps({ modelValue: { type: String, default: } }) const emit defineEmits([update:modelValue]) function handleInput(event) { // 这里可以做格式化、过滤、限制输入等操作 emit(update:modelValue, event.target.value) } /script template input :valuemodelValue inputhandleInput / /template父组件里这样用BaseInput v-modelusername /这个例子虽然简单但已经把核心点都覆盖了子组件不能直接修改props必须通过emit通知父组件更新。为什么不能直接在子组件里改props因为props是单向数据流直接改会导致数据源不唯一后面排查问题会非常痛苦。比如你有个表单父组件和子组件各自维护一份值改了一边另一边不知道最后提交的数据就是错的。用emit的好处是不管多少层嵌套最终更新入口都在父组件手里数据流清晰。2.2 自定义组件绑定原生事件要注意什么这里有个我实际踩过的坑。刚写 Vue3 自定义组件时我直接这样写BaseInput v-modelusername inputhandleInput /然后我发现这个input监听的是子组件根元素上的原生input事件而不是我在子组件内部通过emit(update:modelValue)触发的事件。如果子组件根元素恰好是input那这个监听其实是监听到了原生事件但如果你换了个根元素比如外面包了一层divinput可能就完全失效了或者监听的是意外的事件。为什么会这样因为 Vue3 中没有在组件emits选项或defineEmits里声明的事件会被当作透传给组件根元素的原生事件监听器。这其实是 Vue 的一个设计方便你给组件根元素绑定原生事件。但是放在 v-model 的场景里就很危险。比如我在子组件内部已经用defineEmits([update:modelValue])声明了更新事件那么在父组件里写update:modelValue来监听是可以的这是组件自定义事件。但如果写input它会直接落到组件的根 DOM 元素上这个行为容易让人困惑。建议的做法是在自定义组件上不要裸写原生的input、change这类监听器来配合 v-model因为你没法确定它最终落在哪个 DOM 元素上。需要明确监听某个语义时就在子组件内部处理然后通过自定义事件名称暴露出去。比如一个数字输入组件可以对外暴露change但内部其实是自己监听原生change后再 emit。!-- NumberInput.vue -- script setup defineProps({ modelValue: Number }) const emit defineEmits([update:modelValue, change]) function handleChange(e) { const num Number(e.target.value) if (!isNaN(num)) { emit(update:modelValue, num) } emit(change, num) } /script这样父组件里写change时监听的是组件自定义的change语义不会和原生 DOM 事件混淆。2.3 多 v-model 在组件里的实际应用多 v-model 最典型的场景就是封装一些半受控组件。我做过一个筛选面板组件它需要同时维护展开状态、已选条件文本、以及内部表单的值。如果只用一个 v-model整个组件会非常臃肿。示意如下FilterPanel v-model:collapsedcollapsed v-model:selectedTextselectedText :schemaschema /子组件内部script setup const props defineProps({ collapsed: Boolean, selectedText: String, schema: { type: Array, default: () [] } }) const emit defineEmits([update:collapsed, update:selectedText]) function toggle() { emit(update:collapsed, !props.collapsed) } watch(() JSON.stringify(props.schema), () { emit(update:selectedText, formatSelectedText()) }) /script需要注意props 里的collapsed和selectedText都是从父组件传下来的子组件内部判断或处理时不能直接改这些 props只能通过 emit 发新值上去。如果你确实需要临时修改本地值建议用computed做一层读写代理const collapsed computed({ get: () props.collapsed, set: val emit(update:collapsed, val) })这个computed读写代理是我最推荐的模式因为模板里可以直接用v-modelcollapsed不再需要手动写:collapsed和update:collapsed代码干净很多。2.4 Vue3.4 的 defineModel 宏要不要用Vue 3.4 之后出了defineModel宏专门简化自定义组件上的 v-model。标准写法变成script setup const model defineModel({ type: String, default: }) function handleInput(e) { model.value e.target.value } /script template input v-modelmodel / /templatedefineModel会自动帮你处理props和emit这个宏在团队内部是新项目首选。但有一点要提醒如果你的项目还在用 Vue 3.4 之前的版本或者你维护的是一个给外部使用的组件库得确认目标环境支持。另外defineModel目前对多 v-model 的写法是defineModel(title)和 props 的话还是有点区别不要搞混。我自己在实际项目里的策略是内部业务组件直接用defineModel简化代码对外发布的 npm 包组件继续用props emit的经典写法保证兼容性。如果你刚接触 Vue3建议先把 classic 写法吃透再去看宏的实现否则出了问题你可能连底层在做什么都不知道。3. 表单校验实战从登录注册到动态规则表单校验是 v-model 落地时最绕不开的一环。很多项目用组件库如 Element Plus、Naive UI表单校验直接交给了库自带组件比如el-form和el-form-item。但如果你做的是自定义组件或者项目不想引入大组件库校验这层逻辑就得自己设计。我分成几层来讲。3.1 校验方案怎么选我先对比一下常见的表单校验方案好让大家心里有数方案学习成本灵活性体积适用场景手写 if/else 校验低高最小简单表单1~2 个字段不想引依赖组件库内置校验el-form rules中中随组件库引入使用了 element-plus 等库的项目async-validator 独立库中高约 30KB需要在自定义组件里做灵活校验vee-validate / vuelidate较高高中表单逻辑复杂、需要表单状态管理我在实际中比较推荐如果项目已经用了组件库优先用组件库内置的表单校验如果是在开发自定义组件或者表单逻辑特别复杂直接引入async-validator体积不大规则表达能力强而且它本来就是很多组件库表单校验的底层依赖你单独用不会有任何排异反应。3.2 登录注册场景的完整校验示例我用一个比较典型的注册表单举例包含用户名、邮箱、密码、确认密码四个字段。这个例子覆盖了必填校验、格式校验、长度校验、自定义校验密码一致性这是表单校验最常见也最核心的几类需求。script setup import { reactive, ref } from vue import Schema from async-validator const form reactive({ username: , email: , password: , confirmPassword: }) const errors reactive({}) const submitting ref(false) const rules { username: [ { required: true, message: 请输入用户名, trigger: blur }, { min: 3, max: 12, message: 用户名长度需在 3 到 12 个字符之间, trigger: blur } ], email: [ { required: true, type: email, message: 请输入正确的邮箱地址, trigger: blur } ], password: [ { required: true, message: 请输入密码, trigger: blur }, { min: 6, message: 密码长度不能少于 6 位, trigger: blur }, { validator: (rule, value, callback) { if (!/[A-Z]/.test(value)) { callback(new Error(密码必须包含一个大写字母)) } else { callback() } }, trigger: blur } ], confirmPassword: [ { required: true, message: 请再次输入密码, trigger: blur }, { validator: (rule, value, callback) { if (value ! form.password) { callback(new Error(两次输入的密码不一致)) } else { callback() } }, trigger: blur } ] } const validator new Schema(rules) // 校验单个字段用于 blur 时提示 function validateField(field) { validator.validate({ [field]: form[field] }, { firstFields: true }) .then(() { delete errors[field] }) .catch(({ errors: errs }) { errors[field] errs[0]?.message || 校验失败 }) } // 校验整个表单用于提交时校验 async function handleSubmit() { submitting.value true try { await validator.validate(form, { firstFields: true }) // 这里写接口提交逻辑 console.log(表单数据, form) } catch ({ errors: errs }) { // 把错误映射到对应字段 Object.keys(form).forEach(key { delete errors[key] }) errs.forEach(err { if (err.field) { errors[err.field] err.message } }) } finally { submitting.value false } } /script template form submit.preventhandleSubmit div classform-item label用户名/label input v-model.trimform.username blurvalidateField(username) / span v-iferrors.username classerror{{ errors.username }}/span /div !-- 其他字段类似 ... -- button typesubmit :disabledsubmitting注册/button /form /template这个示例有几个关键点第一v-model.trim修饰符会用掉。用户名、手机号这些纯输入字段用.trim去掉首尾空格能省掉不少莫名的校验问题。Vue3 的 v-model 支持.trim、.number和.lazy修饰符这个很多人知道但实际写的时候容易忘。第二异步校验的时机。blur触发单字段校验submit触发全表单校验这是最常见的组合。如果每个字段都在input过程中实时校验用户一进表单看到满屏红色错误提示体验很差。我建议默认 blur 校验提交时全量校验等用户有了一次交互后再往实时校验过渡。第三关于登录失败表单提交校验失败请刷新后重试。这个提示实际项目里经常遇到。排查思路一般是先检查提交时是否触发了handleSubmit里的校验如果校验抛了异常但代码没有正确的 catch 处理前端就会走入错误分支还有一种常见原因是密码框设置了autocomplete导致浏览器自动填充了某个字段但表单数据里并没有正确更新提交时校验就卡住了。排查时先在提交函数里打日志看void validator.validate()有没有 reject再看 reject 的字段和 message 是什么基本能定位。3.3 async-validator 的进阶用法async-validator单独拎出来讲是因为很多组件库底层都靠它包括 Element Plus。如果你能熟练用它不管组件库怎么换校验能力都能保持一致。常用的规则类型包括type: string | number | email | url | date | array | object等required: true必填pattern: /正则/正则匹配min、max对于字符串是长度对于数字是数值大小对于数组是元素个数len固定长度enum: []必须在枚举值中whitespace: true不允许纯空白字符串注意这个要配合空格过滤用transform在校验前对值做转换deep: true对对象类型做深层校验validator自定义校验函数asyncValidator异步自定义校验比如请求后端校验用户名是否重复企业级表单最常用的其实是asyncValidator。举个例子注册时校验用户名是否已存在{ field: username, asyncValidator: (rule, value) { return new Promise((resolve, reject) { checkUsernameExists(value).then(exists { if (exists) { reject(new Error(用户名已被注册)) } else { resolve() } }) }) }, trigger: blur }这里要特别注意异步校验是有竞态问题的。用户在输入框里快速输入时前面几次请求的响应可能晚于后面一次导致校验结果错乱。解决办法是在提交校验前记录一个递增的请求序号或者使用AbortController取消前一次请求或者至少在校验结果返回时判断当前输入值是否还是发起时的值。我自己踩过的一个真实坑某个表单输入手机号时每输入一个数字就发一次后端校验请求最后用户把手机号输完前面的校验结果陆续返回把最后正确的校验结果给覆盖了导致一直提示手机号格式错误。这就是典型的竞态问题后来我加了一个requestFlag才解决let requestFlag 0 function checkMobile(value) { const current requestFlag return fetch(/api/check-mobile, { method: POST, body: JSON.stringify({ mobile: value }) }) .then(res res.json()) .then(data { if (current ! requestFlag) return // 处理返回结果 }) }如果你用vee-validate这类库它内部已经帮你在一定程度上处理了竞态但自己写逻辑时一定要留意。3.4 自定义组件如何接入表单校验体系自定义组件接入校验关键是要把用户操作转成校验时机。blur和change是最常见的校验触发时机。假设我封装了一个下拉选择组件希望选择后立即校验并在外部展示错误信息。我的做法是在组件内部维护自己的原生blur事件然后在事件处理中向外 emit 一个封装过的blur事件同时把当前值一起传出去这样外部表单校验就能感知到用户离开了这个字段就能执行对应规则。!-- BaseSelect.vue -- script setup const props defineProps({ modelValue: [String, Number, Array], // 其他选项配置... }) const emit defineEmits([update:modelValue, blur]) function handleBlur() { emit(blur, props.modelValue) } /script template select :valuemodelValue changee emit(update:modelValue, e.target.value) blurhandleBlur option v-foropt in options :keyopt.value :valueopt.value {{ opt.label }} /option /select /template父组件里BaseSelect v-modelform.city :optionscityOptions blurvalidateField(city) /这样做的好处是校验逻辑完全收敛在父组件里子组件只负责通知我失焦了和我的值变化了符合单向数据流。不要尝试在子组件内部做校验尤其不要自己渲染错误信息因为校验规则和展示形态往往是业务相关的放在父组件或一个专门的表单容器里更容易维护。3.5 校验状态和错误信息的管理方式表单字段多起来之后错误信息的管理就很重要。我见过有人给每个字段写一个ref比如usernameError、emailError、passwordError字段一多代码巨长。我建议用一个响应式对象统一管理const errors reactive({ username: , email: , password: , confirmPassword: })再用一个computed判断整个表单是否有效const formValid computed(() { return Object.values(errors).every(item !item) })这里有个小细节校验通过后要记得删除对应字段在errors里的错误信息只置空也可以但删除更干净避免一些v-iferrors.username判断出错。用delete errors.username和errors.username 的区别在于delete会移除属性遍历时不会出现undefined字段。实践中两种都用我习惯用delete因为模板里v-if判断更干净。4. 动态表单与表单引擎的常见玩法动态表单这个词几乎每个后台项目都会碰到。无非是两种需求一种是后端返回表单配置前端根据配置渲染另一种是前端配置好 schema用户自行拖拽或勾选来生成表单。前者最典型我重点讲这个。4.1 配置驱动的表单渲染最简单的 schema 可以这样定义const formSchema [ { field: name, label: 姓名, type: input, placeholder: 请输入姓名, rules: [{ required: true, message: 姓名为必填项 }] }, { field: gender, label: 性别, type: radio, options: [{ label: 男, value: male }, { label: 女, value: female }] }, { field: skills, label: 技能, type: checkbox, options: [{ label: Vue, value: vue }, { label: React, value: react }] }, { field: city, label: 城市, type: select, options: [{ label: 北京, value: beijing }, { label: 上海, value: shanghai }] } ]然后写一个渲染组件script setup import { reactive } from vue const props defineProps({ schema: { type: Array, required: true }, modelValue: { type: Object, default: () ({}) } }) const emit defineEmits([update:modelValue]) const formData reactive(props.modelValue) function handleUpdate(field, value) { formData[field] value emit(update:modelValue, { ...formData }) } /script template div v-foritem in schema :keyitem.field classform-item label{{ item.label }}/label input v-ifitem.type input v-modelformData[item.field] :placeholderitem.placeholder inputhandleUpdate(item.field, $event.target.value) / select v-else-ifitem.type select v-modelformData[item.field] changehandleUpdate(item.field, $event.target.value) option v-foropt in item.options :keyopt.value :valueopt.value{{ opt.label }}/option /select !-- 其他 type 同理 -- /div /template这种配置驱动的方式核心优点是新增字段不用改模板只要加配置即可。但真正的表单引擎远不止这里简单还涉及联动比如选择某个城市才显示某个区县、动态校验规则、表单项的显隐控制、依赖字段的自动重置以及布局描述。业内说的表单引擎其实就是把这些问题统一抽象成一套可配置方案比如把每个字段配置扩展为{ field: province, type: select, visible: true, dependOn: country, // 依赖字段 resetOnDependChange: true, // 依赖变化时重置本字段 rules: [...] }再强调一点schema 驱动表单在低代码平台里很常见很多开源平台能通过拖拉拽方式创建表单最后导出的本质也是一个 JSON schema。渲染引擎只需要解析 schema 并渲染对应控件校验和布局交给各自组件处理。所以如果你想往这个方向深入核心能力就是 schema 的设计而不是 UI 本身。4.2 表单数据的回填、清空与重置表单的回填和清空是实际项目里绕不过去的需求但很多人处理得很随意导致各种 bug。我来分享几种我常用的方式。回填场景最容易犯的错误是直接用Object.assign(formData, res.data)。如果res.data里有字段 A 但当前表单里没有这个字段会残留在 formData 里如果表单里有的字段res.data没有那就不会被更新。而且Object.assign是浅拷贝遇到嵌套对象时很容易改动到原始引用。我建议这样处理function resetForm() { const initialData { username: , email: , city: , skills: [] } Object.keys(formData).forEach(key { formData[key] initialData[key] }) }因为formData是用reactive定义的直接整体替换对象会破坏响应式所以必须逐个 key 赋值。你可以先定义一个初始值对象然后遍历当前表单的 key 来赋值这样即使 schema 变化也不容易多出字段。清空表单时另一个常见的坑是清空了数据但校验错误提示还留在界面上。这其实是两个状态的同步问题。我的做法是清空数据的同时重置错误对象function resetFormAndValidate() { // 清空数据 Object.keys(formData).forEach(key { formData[key] }) // 清空错误提示 Object.keys(errors).forEach(key { delete errors[key] }) // 如果需要手动触发一次全量校验让界面规整 }另外编辑页回填时还要注意时间选择器、级联选择器这类组件它们的值往往不是普通字符串而是数组或者 Date 对象必须在回填时做一次格式化。否则会出现渲染时看起来没值但一提交数据又有了这种诡异问题。排查时先打印formData看看回填后的值到底长什么样。4.3 动态表单的几个典型坑动态表单用多了总会遇到这几类问题第一个是渲染缓存问题。当 schema 动态变化时v-for里如果不加key或者 key 设置不当组件会被复用导致显示的数据还是旧的。比如你切换了一个下拉选项下面的表单项整体变了但某些 input 里的值残留在那里。解决办法是给每一行表单控件设置稳定且唯一的key最常见的是item.field如果字段可能重复就拼接一个索引:keyitem.field index。第二个是校验规则更新不生效。schema 中某个字段的校验规则变化了但async-validator实例还是旧规则。原因是Schema实例在初始化时就接收了 rules后续改 rules 不会自动生效。解决办法有两个一是把规则对象用 computed 维护每次渲染时通过watch重新创建 validator二是更简单些在提交时才动态构建 rules保证用的都是最新配置。第三个是排序拖拽不生效的问题。后台项目做表单设计器时经常会用到sortablejs来让表单项拖拽排序。很多人反馈vue3 sortable未生效十有八九是因为在 Vue3 的v-for列表上使用 sortable 时拖拽结束后 DOM 顺序变了但v-for对应的数据数组没有同步更新或者反过来。sortable 官方是直接操作 DOM 的和 Vue 的虚拟 DOM 并不天然配合所以要么手动在onEnd事件里更新数组要么用专门为 Vue3 封装的组件。我自己的习惯是直接在onEnd里重新计算数组顺序然后用splice方法原地更新这样 Vue 的响应式能感知到变化。第三个问题很隐蔽但这个真的坑过我好几次。拖拽排序后数组元素的位置变了但有些表单控件的值还挂在原来的位置。因为 schema 驱动渲染时每个字段的值存在formData[item.field]里排序只影响字段顺序不影响字段名和值的映射理论上没问题。但如果配置里有两个相同 field 的字段或者你把值存在了数组下标里那一排序全乱了。所以动态表单设计时value 一定用 field 名做 key不要用数组下标。4.4 从动态表单到完整表单引擎的思路如果你能把动态表单做好再往前一步就是表单引擎。表单引擎的核心能力通常包括组件注册表、schema 描述、联动规则、布局系统、校验系统、数据提交管线。组件注册表是这样实现的const componentMap { input: defineAsyncComponent(() import(./components/BaseInput.vue)), select: defineAsyncComponent(() import(./components/BaseSelect.vue)), radioGroup: defineAsyncComponent(() import(./components/BaseRadioGroup.vue)), checkboxGroup: defineAsyncComponent(() import(./components/BaseCheckboxGroup.vue)) }渲染时直接根据item.type去查表component :iscomponentMap[item.type] :modelValueformData[item.field] update:modelValuehandleUpdate(item.field, $event) /这样每增加一种新控件只需要写好组件并注册进表里schema 中新增一个 type 就能用。这个思路非常实用被很多低代码平台采用。如果你在用开源的智能表单平台本质也是在看它 schema 定义得够不够灵活。5. 常见问题排查与经验速查5.1 高频问题速查表我把团队开发中经常遇到的 v-model 和表单校验问题整理成了一张表方便大家直接对照排查问题现象可能原因排查/解决办法v-model 绑定的值没更新props 名写错还是 value检查 props 是否用 modelValue组件值变了父组件没反应忘记 emit 或事件名写错检查 emit 名是否为 update:modelValue输入框内容能改但页面重渲染后值还原数据流被破坏子组件直接改了 props统一改为 emit 方式更新表单提交时校验失败但没有具体错误校验规则中存在异步校验未 await确认 validator.validate() 是否被 await弹窗组件打开时表单校验报错弹窗还没渲染完就触发了校验在弹窗完全打开后再执行校验可以用 nextTick清空表单后校验错误还在只重置了数据没重置错误对象同时清理 errors 对象动态增加表单项后旧数据残留v-for 的 key 不稳定key 使用唯一字段名拼接索引下拉选项变化后表单项显示旧值schema 变化导致组件复用给组件加 key 或强制重建两个 v-model 同时使用时有冲突props 命名冲突或事件重复声明检查 props 和 defineEmits 命名空间浏览器自动填充密码后表单值没同步autocomplete 填充不触发 input 事件监听 change 或 load 后手动取值输入框输入中文拼音时v-model 更新过频composition 事件未处理需要监听 compositionstart/end 做防抖处理其中补一条很多人不知道的Vue3 的 v-model 在输入框上对中文输入法IME是有特殊处理的。具体来说Vue 内部监听了compositionstart和compositionend事件在拼音组合过程中不会触发input的 v-model 更新等选中候选词上屏后才会触发更新。这本来是好事但如果你对这个输入框额外监听了input事件那原生input事件在拼音组合中还是会触发导致你拿到的值和 v-model 里的值不一致。遇到这种问题时判断一下你的监听是通过 v-model 还是原生 input处理方式不一样。5.2 登录失效与表单提交校验失败的实战排查“登录失败表单提交校验失败请刷新后重试”这种提示多半不是前端表单校验本身写错了而是登录接口返回了异常前端错误处理逻辑把后端错误当成了校验失败来展示。我在一个项目里排查过这类问题。最终定位是拦截器里的逻辑问题后端返回 401 时拦截器没有进入专门的处理分支而是走了一个全局错误回调把message设置成了表单提交校验失败。所以遇到这类提示第一步不是去检查表单 rules而是打开 Network 看接口返回状态码和响应体然后顺着前端请求封装里catch分支走一遍看看这个提示文案到底是从哪出来的。另一个常见情况是接口已经成功返回但前端因为某个字段格式不对在调用链后面报了校验异常却没有 catch最后用户看到的是请刷新后重试。这个时候要在提交函数附近打日志看formData里有没有意外字段、字段值类型是否和预期一致。5.3 我在实际开发中的几个小技巧最后分享几个我一直在用的小技巧每个都是踩过坑换来的。第一个给输入组件统一封装一个useFormField的组合式函数。这个函数负责把modelValue、emit(update:modelValue)、disabled、readonly等公共逻辑收拢起来每个控件只需要关心自己的 UI 差异。代码复用性会提升很多。// useFormField.js export function useFormField(props, emit) { const value computed({ get: () props.modelValue, set: val emit(update:modelValue, val) }) const isDisabled computed(() props.disabled) const isReadonly computed(() props.readonly) return { value, isDisabled, isReadonly } }第二个初始化表单数据对象时尽量把后端可能返回的字段全部定义出来并给上合理的初始值。比如status: 0、tags: []、config: {}。这个习惯能避免很多空值判断问题也有利于复用同一份初始值做重置。第三个关于表单提交按钮的 loading 状态不要只用一个布尔值。我习惯用一个submitState对象区分idle、submitting、success、error四种状态。这样按钮的文案、样式、禁用逻辑都能根据状态变化而不是仅靠一个submitting布尔值去衍生。第四个如果表单校验规则特别多建议把 rules 定义单独抽成一个文件用函数返回而不是写死在组件里。比如createUserRules(formRef)这样当规则需要复用、或者要针对不同模式生成不同规则时会很灵活。6. 最后聊点我自己的选择和体会表单这块我做了好几个完整项目之后最大的感受是表单代码写得好不好不在于你用了多高级的库而在于你对自己封装的那一层东西是否足够理解。v-model 的语法糖也好async-validator的规则也好都只是工具真正的难点是数据流的设计。如果哪天你要从零搭建一个后台系统的表单模块我的建议是第一步先想清楚表单数据结构该怎么定义第二步想清楚哪些组件需要支持 v-model它们的事件体系如何统一第三步再考虑校验怎么做。把这三步理顺了后面加字段、加校验、做动态表单都是水到渠成的事。最后再分享一个个人偏好不要迷信表单引擎这个词也不要把一切问题都往低代码平台上靠。大多数企业后台的表单用配置驱动的 schema 已经能解决 80% 需求剩下 20% 用 slots 或自定义组件兜底就行。真正复杂的逻辑抽象得越多调试成本反而越高。先把 v-model、自定义组件、校验这套基本功打扎实比什么都强。
返回列表