做后台管理系统的人大概率都遇到过这种场景:页面上一堆输入框,点提交按钮,什么都不发生,控制台没报错,网络请求也没发出去,按钮像是被谁按住了。翻代码,el-form上:model传了,el-form-item上prop也写了,rules也配了,看起来没毛病。我前后带过好几个团队,几乎每个新人都要在el-form的model、prop、rules这三件套上栽一次跟头。原因不复杂,但官方文档把这三者拆开讲,缺少一条把"数据对象、校验路径、规则匹配、错误提示挂载"串起来的主线,所以很多人是靠试错把表单跑通的,换个复杂场景(嵌套对象、动态数组、自定义校验)又懵了。
这篇东西就围绕el-form的model、prop属性和表单校验展开,我会把这根主线完整拆开:model到底在链路里扮演什么角色,prop是怎么变成一个"寻址路径"的,rules匹配规则的底层逻辑,以及validate、validateField、resetFields、clearValidate这几个方法的行为边界。内容偏实战,我会带上真实项目里踩过的坑、排查思路和兜底写法,适合已经用过 Element UI / Element Plus 但总觉得"知其然不知其所以然"的开发者,也适合刚接手表单模块、需要快速建立心智模型的新人。读完之后,再遇到"校验不生效""提示跑到别的输入框下面""重置按钮没反应"这类问题,你应该能直接定位到是哪一环断了。
1. model 在表单里根本不是"数据源",而是校验的引用锚点
很多人对:model的第一反应是"把表单数据传进去",这个理解只对了一半,而且是最不重要的那一半。真正关键的是:el-form内部持有了这个对象的引用,并在需要的时候沿着prop给的路径去这个对象上取值、赋值、触发响应式更新。换句话说,model是"仓库地址",prop是"仓库里的货架编号",校验规则执行的每一步都要靠这两者配合。理解这一点,后面所有的怪现象都能解释。
1.1 为什么必须传一个对象,传别的类型会怎样
el-form的model类型定义是Object。你要是传一个字符串、数字甚至undefined,组件不一定立刻报错,但校验会在某个时机静默失效。我见过最典型的写法是接手别人代码时看到的这种:
<template> <el-form :model="formData" :rules="rules" ref="formRef"> <el-form-item label="用户名" prop="username"> <el-input v-model="formData.username" /> </el-form-item> </el-form> </template> <script setup> import { ref } from 'vue' const formData = ref('') // 错误:初始化成了字符串 </script>当formData是字符串时,el-form-item内部去读取model[prop],也就是''['username'],得到undefined。此时如果rules里配了required: true,校验行为会变得不可预测——有时第一次校验能过,有时一直提示为空。这个 bug 的隐蔽性在于:页面上输入框看起来是正常的,v-model也能改值(因为formData.username在字符串上赋值会在严格模式下报错,非严格模式下静默失败),但校验拿不到值。
正确的初始化应该永远是一个带完整字段的对象:
const formData = ref({ username: '', password: '', age: undefined })提示:字段即使暂时没有默认值,也建议显式声明出来并给个空值。
undefined在某些异步校验场景下和''的行为不一样,后面第 6 节会专门说这个差异。
1.2 reactive 和 ref 的选择,会影响校验结果吗
这个问题我被问过无数次。结论是:ref({...})和reactive({...})都能正常驱动校验,区别在于你在模板里引用时要不要加.value,以及有没有可能"解构丢响应式"。真正会导致校验出问题的是第三种情况——把reactive对象解构了:
const formData = reactive({ username: '', password: '' }) // 下面这行会切断响应式连接 const { username } = formData解构之后username变成一个普通字符串,el-form-item通过prop路径去model上读值时,读到的还是formData上那个响应式的属性,这部分没问题;但你如果在模板里用解构出来的username绑定v-model,输入时改的是那个游离变量,model上的值不变,于是校验一直拿旧值。这个坑在组合式 API 里特别常见,尤其是喜欢在setup里"顺手解构"的人。
我的习惯是:表单数据统一用reactive,需要传递到子组件时用toRefs或者toRef包一层。toRefs返回的每个属性仍然指向原对象,不会断链。
1.3 换个引用,为什么校验结果会"穿越"
还有一个更阴的场景:表单会根据某个下拉框的选择切换字段结构,有人图省事直接给formData整个重新赋值:
function handleTypeChange(type) { formData.value = type === 'A' ? { username: '', email: '' } : { company: '', taxNo: '' } }el-form内部在挂载时对model建立的引用关系,会在你重新赋值后被替换。只要新对象是响应式的,校验本身还是能跑。但如果新对象里缺少了某个el-form-item声明的prop字段,那个表单项的校验就会失效——因为model上根本没有这个"货架"。
更麻烦的是resetFields。这个方法内部依赖el-form-item挂载时记录的初始值,如果中途你换了整个model引用,resetFields可能把表单重置到一个"混合状态":一部分字段回到新对象的值,一部分字段还停留在旧对象记录的初始值上。我处理这类需求时,更倾向于保持model引用不变,只改字段值:
function handleTypeChange(type) { Object.keys(formData).forEach(k => delete formData[k]) Object.assign(formData, type === 'A' ? { username: '', email: '' } : { company: '', taxNo: '' }) }注意:如果确实必须换引用(比如要彻底清掉旧字段),换完之后手动调一次
formRef.value.clearValidate(),把残留的校验状态清干净,比什么都不做要稳。
2. prop 属性:一条把 model、rules、错误提示串起来的"寻址路径"
prop这个属性在文档里只有一句话的说明,但它才是整个表单校验的中枢。它同时承担了四件事:告诉el-form-item去model的哪个位置取值、告诉el-form去rules里找哪条规则、决定错误提示渲染在哪个表单项下方、决定validateField和resetFields操作的目标。任何一环对不上,校验就断。
2.1 prop、rules 的 key、v-model 绑定字段,三者为什么必须一致
先看一个能跑通的完整例子:
<el-form :model="formData" :rules="rules" ref="formRef"> <el-form-item label="手机号" prop="phone"> <el-input v-model="formData.phone" /> </el-form-item> </el-form>const formData = reactive({ phone: '' }) const rules = { phone: [ { required: true, message: '请输入手机号', trigger: 'blur' }, { pattern: /^1[3-9]\d{9}$/, message: '格式不正确', trigger: 'blur' } ] }这里有三处phone:el-form-item的prop="phone"、rules对象的 keyphone、v-model绑定的formData.phone。很多人以为它们"必须完全一样"是框架硬性规定,其实更准确的说法是:prop决定了另外两处的"对接方式"。el-form-item拿到prop之后,会去rules上按这个字符串取规则,同时去model上按这个路径取值。所以prop是唯一的"契约字段",它必须同时是rules的 key 和model上的属性路径。v-model绑的字段只要最终指向model上的同名属性即可,写法上可以绕,但绕来绕去容易出问题,我建议始终保持三者字面一致,可读性最好,排查也快。
2.2 rules 写在 form 上还是 form-item 上
规则有两个挂载位置,行为有细微差别。写在el-form的:rules上时,el-form-item通过prop去这个总规则表里查;写在el-form-item的:rules上时,就是表单项自己独有的一套。两者同时存在时,el-form-item上的会覆盖同名的表单级规则。
那什么时候用哪个?我的经验是按"规则的作用域"来分:
| 场景 | 建议位置 | 理由 |
|---|---|---|
| 大部分字段通用、规则集中管理 | el-form的:rules | 便于统一维护,适合配置化表单 |
| 某字段规则复杂、含自定义 validator | el-form-item的:rules | 就近维护,逻辑内聚 |
| 动态生成的表单项 | el-form-item的:rules | 每个实例独立,避免 key 冲突 |
| 需要根据条件整体切换规则集 | el-form的:rules配合计算属性 | 换一份规则对象即可 |
有个坑要注意:如果你把规则写在el-form-item上,同时又给prop写了一个在model上不存在的路径,规则依然会执行,但取值是undefined,required校验会一直提示。这种"规则生效但取值错位"的情况最难查,因为你会以为规则本身写错了。
2.3 不写 prop 会怎样,错误提示为什么消失了
el-form-item的prop是可选的。你不写,输入框照样渲染,v-model照样工作,但校验整套机制就和你无关了:el-form的validate()不会去校验它,rules配了也不会生效(除非规则挂在el-form-item上且你手动调validate,但错误提示还是无处可挂)。
错误提示的渲染位置也是一个常被忽略的点。el-form-item内部会渲染一个.el-form-item__error元素来显示错误消息,这个元素只有在prop存在、校验失败时才会出现,并且挂在当前el-form-item的下方。如果你发现错误提示跑到了别的输入框下面,八成是两个表单项的prop写重了,或者一个表单项的prop指向了公共父级路径,导致两条规则都往同一个位置渲染消息。
提示:用
el-form包一组纯展示、不需要校验的内容时,可以干脆不写prop,这样这些表单项完全不参与校验,比配一堆空的rules干净。
3. rules 的写法、trigger 的选择与自定义校验的边界
规则这一块,初学者最容易犯的错是把"触发时机"和"规则内容"混为一谈。trigger决定规则在什么时候被调用,规则本身决定校验结果。这两个维度分开看,很多"校验没触发"的问题就清楚了。
3.1 内联规则、校验函数与 async-validator 的关系
el-form的校验底层用的是async-validator这个库。你写的{ required: true, message: '...' }最终会被解析成它的一个描述对象。了解这一点很有必要,因为它决定了你能写哪些字段。除了required、message、trigger(这个是 Element 自己加的),其他字段基本都透传给async-validator,比如:
type:指定校验的数据类型,可选string、number、array、object、date、email、url等min/max:对字符串是长度,对数字是大小,对数组是元素个数pattern:正则len:精确长度enum:值必须在枚举列表里validator:自定义校验函数
这里有个高频误区:type: 'number'时,min和max比较的是数值大小;但如果type没写或写成string,而输入框绑定的是数字,min/max会按字符串长度比较,结果完全不是你想要的。我见过有人用min: 1, max: 100想限制数值范围,结果输入 5 也提示不通过,一查才发现type没写,'5'的长度是 1,卡在了边界。
const rules = { // 错误:想限制 1-100 的数值范围 amount: [{ required: true, message: '请输入金额' }], // 正确:明确类型 amount2: [ { required: true, message: '请输入金额', trigger: 'blur' }, { type: 'number', min: 1, max: 100, message: '金额需在 1-100 之间', trigger: 'blur' } ] }3.2 trigger 选 blur、change 还是空
trigger可以是blur、change,也可以是数组['blur', 'change'],不写则默认只在手动调validate时触发(其实默认值是change,但实际表现和具体组件有关,建议显式写)。
选择逻辑我总结成这样:
- 需要用户输完离开输入框才提示的,用
blur。输入过程中不打扰,体验最好。 - 下拉选择、开关、单选这类即时变化的,用
change。 - 数字输入框、滑块这种连续变化的,慎用
change。因为用户每敲一个字符都会触发,边输边报错很烦,建议用blur。 - 需要两个字段联动校验的(比如"确认密码"要和"密码"一致),两个字段都得配
trigger,且在一个字段变化时手动触发另一个字段的校验。
联动校验是个高频需求,光靠trigger不够,必须手动补一刀:
function validateConfirmPassword() { formRef.value.validateField('confirmPassword') }这段代码要挂在"密码"字段的change事件上,否则用户改完密码,确认密码的原有错误提示不会自动消失或更新。
3.3 自定义 validator 的三个参数
自定义校验函数的签名是(rule, value, callback)。很多人只用value,忽略了rule和callback。rule里带着这条规则的完整信息(比如你自定义塞进去的额外参数),callback是告诉校验框架"结果如何"的唯一通道。
const validateUsername = (rule, value, callback) => { if (!value) { return callback(new Error('用户名不能为空')) } // 模拟异步校验 setTimeout(() => { if (['admin', 'root'].includes(value)) { callback(new Error('该用户名已被占用')) } else { callback() // 必须调用,否则校验永远挂起 } }, 300) }这里有两个致命点:第一,callback一定要被调用,异步分支里漏掉会导校验 Promise 永远不 resolve,提交按钮卡死;第二,callback(new Error(...))和callback('错误信息')都可以,但推荐用Error对象,语义清晰,避免把字符串当成功误判——有些版本里callback('')空字符串会被当成有效错误信息之外的东西,行为不明确。
注意:
validator函数里抛出的异常不会被自动捕获成校验失败。如果你在里面调了会抛错的方法,异常会冒泡出去,校验结果变成 rejected 之外的状态,表现就是"表单卡住"。所以自定义校验里的逻辑尽量用 try/catch 包住。
3.4 动态规则:为什么改了 rules 校验没变
rules是响应式的,改它理论上能生效,但有几个前提。第一,rules必须是响应式数据(reactive或ref),如果你定义成一个普通const rules = {...}然后改它,视图不会更新。第二,如果你改的是rules里某个字段的数组,Vue 能侦测到数组的变更,但如果你整个替换成新对象,要确保引用被追踪。
更常见的问题是:动态增删了表单项,但规则没跟着同步。比如一个表单里"根据角色显示不同字段",你用一个计算属性返回rules:
const rules = computed(() => { const base = { username: [{ required: true, message: '请输入用户名', trigger: 'blur' }] } if (formData.role === 'admin') { base.adminCode = [{ required: true, message: '请输入管理员编码', trigger: 'blur' }] } return base })这种写法在el-form的:rules="rules"里用是没问题的,因为计算属性会跟踪formData.role。但要注意:当你从admin切回普通角色时,adminCode表单项如果还留在 DOM 里、prop还在,校验会去找rules.adminCode,找不到就跳过——这通常没问题。可如果这个字段的输入框被v-if隐藏了但组件实例还在(比如用了v-show),它记录的校验状态会残留,切回来时可能会有旧错误提示闪现。处理方式是在角色切换时手动清一次校验:
watch(() => formData.role, () => { nextTick(() => formRef.value?.clearValidate(['adminCode'])) })4. validate 这一家子方法:别再混用 validateField、clearValidate 和 resetFields
el-form暴露了好几个方法,名字都带校验的意思,但行为差异很大。用错地方就会出现"点了重置按钮,值清空了但错误提示还在"或者"只想清错误提示,结果值也被清了"这种哭笑不得的情况。
4.1 validate 的 Promise 风格和回调风格
validate有两种调用方式,本质是它既接受回调,又返回 Promise。两种别混用,我见过有人两个都写,结果回调里 resolve 了一次,Promise 那边又 await 了一次,逻辑走两遍。
// 回调风格 formRef.value.validate((valid, fields) => { if (valid) { submit() } else { console.log('校验未通过', fields) } }) // Promise 风格 try { await formRef.value.validate() submit() } catch (fields) { console.log('校验未通过', fields) }第二种写法要特别注意:validate()校验失败时会 reject,抛出的就是那个fields对象,不会变成未捕获异常(只要你 catch 了)。但如果你用await却不写try/catch,控制台会红一片,虽然不影响功能,但看着吓人,也容易掩盖真正的问题。
fields这个参数很有用,它包含了所有校验失败字段的信息,结构是{ 字段名: [{ message, field, fieldValue }] }。做统一错误提示(比如在页面顶部弹一个"共 3 项未填写")或者做错误字段的滚动定位时,靠它就拿得到数据。
4.2 validateField、clearValidate、resetFields 的适用场景
这三个方法特别容易混。我用一张表把它们的边界说清楚:
| 方法 | 做了什么 | 会不会改值 | 典型场景 |
|---|---|---|---|
validateField | 只校验指定的字段 | 不会 | 联动校验、局部校验 |
clearValidate | 清除指定字段的校验状态和错误提示 | 不会 | 切换条件后清理残留提示 |
resetFields | 校验状态和字段值一起重置 | 会 | 表单整体的重置按钮 |
validateField接受字段名(字符串或数组)和可选的回调:
// 只校验一个字段 formRef.value.validateField('email') // 校验多个字段 formRef.value.validateField(['email', 'phone'], (errorMessage) => { if (!errorMessage) { console.log('这两个字段都通过了') } })clearValidate和resetFields的差异是最容易踩的。做"重置"按钮时,如果你只想清掉红色错误提示、保留用户已填的内容,应该用clearValidate;如果你要的是把表单恢复到初始状态,用resetFields。
4.3 resetFields 为什么会"重置不干净"
resetFields的机制是:每个el-form-item在挂载时会把自己的初始值记录在组件实例上(来自model上对应prop的值),resetFields就是把model上的值改回那个记录值。
所以它有几种典型的不生效场景:
- 数据是异步拉回来的。组件先挂载,此时
model上的值是空的,组件记录了"空"作为初始值。等接口数据回来你改了model,初始值并不会更新。此时点重置,表单又变回空的。正确做法是数据拿到后再让表单渲染,或者手动记录初始值。 prop路径写错了。组件按prop去model上找初始值,找不到就记录成undefined,重置后变成undefined,视图上看着像"没重置"。- 字段是在挂载之后动态添加的,它的初始值记录时机很微妙,有时记录的是添加那一刻的值,重置行为和你预期不符。
我处理异步数据的标准做法是手动备份一份初始值:
const formData = reactive({ username: '', role: '' }) let initialSnapshot = null async function loadDetail(id) { const res = await api.getDetail(id) Object.assign(formData, res.data) // 数据填充完再备份 initialSnapshot = JSON.parse(JSON.stringify(formData)) } function handleReset() { Object.assign(formData, JSON.parse(JSON.stringify(initialSnapshot))) formRef.value.clearValidate() }这样重置逻辑完全可控,不依赖组件内部对初始值的记录时机。
4.4 scrollToField 和错误定位
表单很长的时候,校验失败用户看不到错误在哪,体验很差。scrollToField能把页面滚动到指定字段:
try { await formRef.value.validate() } catch (fields) { const firstField = Object.keys(fields)[0] formRef.value.scrollToField(firstField) }scrollToField内部会去找对应的el-form-itemDOM 节点并调用scrollIntoView。它默认滚动的是最近的滚动容器,如果你页面用了自定义滚动区域,可能滚不到位置,这时可以传第二个参数false关掉"只滚最近容器"的行为,或者自己拿 DOM 节点处理。
提示:做统一错误提示时,
fields的 key 顺序不保证和页面字段顺序一致,想滚动到"第一个错误字段"最好自己按页面定义顺序排一遍,而不是直接取Object.keys(fields)[0]。
5. 嵌套字段、动态数组表单的 prop 路径写法
前面讲的都是扁平字段,prop直接就是属性名。真实业务里表单往往有嵌套结构,prop得写成"路径"形式,规则匹配也会跟着变。
5.1 对象嵌套:prop 用点号路径
如果model长这样:
const formData = reactive({ user: { name: '', contact: { email: '' } } })那么el-form-item的prop要跟着写路径:
<el-form-item label="邮箱" prop="user.contact.email"> <el-input v-model="formData.user.contact.email" /> </el-form-item>对应的rules也按路径组织,注意是嵌套的对象结构,不是带点号的 key:
const rules = { user: { name: [{ required: true, message: '请输入姓名', trigger: 'blur' }], contact: { email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }] } } }这里有个反直觉的点:prop是"user.contact.email"这种字符串,但rules是嵌套对象。el-form-item内部会先把prop按.拆成数组['user', 'contact', 'email'],然后逐层去rules里找。所以rules千万不能写成{ 'user.contact.email': [...] },那样是找不到规则的。
不过要注意,嵌套校验有时会带来一个副作用:当你校验user.contact.email时,如果user或者contact是undefined,取值过程会报错。所以嵌套对象的每一层都要保证存在,最好是初始化时就建好完整结构。
5.2 动态数组表单:用索引拼路径
数组表单是后台管理里的常客,比如动态添加多个联系人:
const formData = reactive({ contacts: [ { name: '', phone: '' } ] })渲染时要用v-for拿到索引,prop里拼上索引:
<el-form-item v-for="(item, index) in formData.contacts" :key="index" :label="`联系人 ${index + 1}`" :prop="`contacts.${index}.name`" :rules="{ required: true, message: '请输入姓名', trigger: 'blur' }" > <el-input v-model="item.name" /> </el-form-item>两个关键点:第一,:prop是动态绑定的(前面带冒号),值是字符串模板拼接出来的路径;第二,规则最好写在el-form-item上而不是el-form上。因为数组长度是动态的,如果你把规则集中写在el-form的:rules上,就得为每个可能出现的索引准备规则,不现实。写在表单项上,每个实例自带规则,最省心。
注意:
:key不要用index,尤其是带删除功能的时候。用index会导致删除中间一项后,后面项的输入框和校验状态发生错位——Vue 认为它们复用了同一批 DOM,于是错误提示会串到别的行上。给每项加一个唯一id作为key,问题消失。
5.3 动态增删后的校验残留
给数组push一个新项时,新表单项会挂载,规则生效,这没问题。但splice删除时,被删项组件销毁,如果删除后没有手动清理,可能会出现两种情况:
- 删掉中间一项后,后面项的索引变了,旧校验状态被误挂到新索引的组件上,红色提示闪现。
- 删除后调
validate(),报错的字段名指向一个已经不存在的索引。
我处理删除时的标准动作是:删完之后nextTick里清一次校验。
function removeContact(index) { formData.contacts.splice(index, 1) nextTick(() => { formRef.value?.clearValidate() }) }整体清一次比逐个字段清更保险,代价只是已填字段的临时错误提示消失,用户重新提交时会再次校验,体验上可以接受。如果你很在意这个体验,可以只清和这次删除相关的字段,但要处理索引偏移,逻辑会复杂不少。
6. 表单封装和排查时,我踩过的那几个老坑
写到这里,基础链路已经讲完了。最后分享几个在真实项目里反复出现、而且文档里不会写的问题,算是我自己的经验收藏。
6.1 校验通过了,但提交的数据还是旧值
这是我认为最值得警惕的一个问题,跟 Vue 的更新时机有关。用户输入后立刻点提交,v-model的更新和validate的执行在同一个事件循环里,绝大多数情况没问题。但如果你的输入用了@change搭配手动赋值,或者用了v-model.lazy,那么点击提交的瞬间model上可能还是上一次的值。
我遇到过的一个具体场景是:一个经过格式化的金额输入框,用户输入1,000,组件内部去掉了逗号再写回model。如果写回是异步的(比如放在setTimeout或nextTick之后),而用户点提交很快,validate校验的是旧值。表现就是"我明明填了 1000,为什么提示不通过"。
处理方式:提交前先
await nextTick(),让所有响应式更新落地再校验。虽然多一个微任务,但能规避一整类时序问题。
6.2 一个表单里多组按钮,校验串了
有些页面会把"新建"和"编辑"做成同一个表单,或者一个页面里有多个el-form。如果多个el-form没有分别拿到各自的ref,或者复用了一个ref变量,校验就会出错。ref在v-for里会变成数组,在多个el-form里如果名字重复,后面的会覆盖前面的。
我建议一个el-form一个独立的ref,名字带上用途,比如loginFormRef、profileFormRef。别图省事用一个formRef到处用,这种"能用但脆弱"的写法在后期加需求时一定会反噬。
6.3 被 disabled 的字段还参与校验
el-input加了disabled只是禁止交互,字段值和校验状态都还在。如果你的逻辑是"某个条件满足时禁用某字段且不校验它",光加disabled不够,还得把规则也去掉,或者把prop也去掉。
<el-form-item label="部门" :prop="formData.autoMatch ? 'dept' : ''" > <el-input v-model="formData.dept" :disabled="formData.autoMatch" /> </el-form-item>把prop动态置空,这个表单项就退出了校验体系,错误提示也不再挂载。这是我在动态表单里最常用的一招,比动态维护规则对象简单得多。
6.4 校验规则里的正则来源可靠吗
最后提一句规则里的正则。很多人抄正则不看场景,比如用手机号正则时用了某些只匹配特定号段的老表达式,导致合法号码校验不通过。我的原则是:涉及业务强相关的格式校验,正则要跟着业务规则走,写清楚注释,并且维护一份测试用例。表单校验失败是用户最容易产生挫败感的交互之一,规则过严比过松更糟,因为它会直接阻断正常流程。对于可选填写的字段,宁可放宽格式校验,把严格性放在提交前的服务端兜底。
围绕model、prop和校验方法这一圈写下来,我自己的体会是:el-form的表单校验不是"配好规则就完事"的黑盒,它本质上是一条从数据对象到规则表再到错误提示渲染的链式查找。model提供仓库,prop提供路径,rules提供判定,validate系列方法提供触发和清理。任何一次"校验不生效",你都可以按这个顺序逐环检查:model是不是响应式对象、prop路径在model和rules上是否同时存在、规则挂载位置对不对、触发方法有没有被混用。把这套心智模型建立起来,动态表单、嵌套结构、异步数据这些复杂场景,都只是这条链路的变形而已。