同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~
(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)
你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?
你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?
就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。
一天只有24小时,时间永远不够用,常常感到力不从心。
技术行业,本就是逆水行舟,不进则退。
如果你也有同样的困扰,别慌。
从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲。
这一次,我们一起慢慢来,扎扎实实变强。
不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,
咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。
一、v-model 到底是什么?——先把"语法糖"这三个字吃透
很多人写了好几年 Vue,天天用 v-model,但如果问一句:"v-model 的本质是什么?",不少人只能说出"双向绑定"四个字就卡住了。
一句话结论:v-model 是一颗语法糖,它帮你把"传值进去 + 监听变化抛出来"这两步合成了一步。
1.1 在原生元素上:v-model = :value + @input
先看最基础的用法:
<template>
<input v-model="username" />
</template>
<script setup>
import { ref } from 'vue'
const username = ref('')
</script>
这段代码等价于:
<template>
<input :value="username" @input="username = $event.target.value" />
</template>
<script setup>
import { ref } from 'vue'
const username = ref('')
</script>
看到了吗?Vue 帮你做了两件事:
-
把
username 的值通过 :value 绑定到 input 上(数据 → 视图)
-
监听 input 的
input 事件,拿到用户输入的新值,赋回给 username(视图 → 数据)
这就是所谓的"双向绑定"——但本质上并不神秘,就是一个 prop 传入 + 一个事件抛出的简写。
踩坑提醒: 不同的原生表单元素,v-model 背后绑定的属性和事件是不一样的:
| 元素 |
绑定的属性 |
监听的事件 |
<input type="text"> |
value |
input |
<input type="checkbox"> |
checked |
change |
<select> |
value |
change |
<textarea> |
value |
input |
所以当你用原生 checkbox 配合 v-model 时,它走的是 checked + change,别和 text input 搞混。
1.2 一个常见的新手困惑:v-model 和 :value 能一起写吗?
不能。 写了 v-model 就不要再手动写 :value,因为 v-model 已经包含了 :value 的行为。如果你两个都写,Vue 会在控制台警告你冲突。
<!-- ❌ 错误写法 -->
<input v-model="username" :value="username" />
<!-- ✅ 二选一 -->
<input v-model="username" />
<!-- 或者 -->
<input :value="username" @input="username = $event.target.value" />
二、Vue 3 自定义组件的 v-model——规则变了,别用 Vue 2 的老习惯
如果说原生元素上的 v-model 是"开胃菜",那自定义组件上的 v-model 才是日常业务中真正高频使用的。而且 Vue 3 对 v-model 的机制做了重大改动,这里是很多从 Vue 2 迁移过来的同学最容易踩坑的地方。
2.1 Vue 2 vs Vue 3 的对比
| 特性 |
Vue 2 |
Vue 3 |
| 默认 prop 名 |
value |
modelValue |
| 默认事件名 |
input |
update:modelValue |
| 多个 v-model |
❌ 不支持(要用 .sync) |
✅ 原生支持 |
.sync 修饰符 |
有 |
❌ 移除了,用 v-model:xxx 替代 |
2.2 最基础的自定义组件 v-model
场景: 封装一个自定义输入框组件 MyInput。
子组件 MyInput.vue:
<template>
<div class="my-input-wrapper">
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
class="my-input"
/>
</div>
</template>
<script setup>
defineProps({
modelValue: {
type: String,
default: ''
}
})
defineEmits(['update:modelValue'])
</script>
父组件使用:
<template>
<MyInput v-model="username" />
<p>你输入的是:{{ username }}</p>
</template>
<script setup>
import { ref } from 'vue'
import MyInput from './MyInput.vue'
const username = ref('')
</script>
拆解: 父组件写 v-model="username",Vue 3 会自动展开为:
<MyInput :modelValue="username" @update:modelValue="username = $event" />
所以子组件需要做两件事:
- 用
modelValue 这个 prop 接收值
- 变化时通过
$emit('update:modelValue', newValue) 把新值抛出去
踩坑提醒: 事件名必须是 update:modelValue,中间是冒号,不是横杠、不是驼峰拼接。很多人写成 updateModelValue 或者 update-model-value,都是错的。
2.3 多个 v-model——Vue 3 的杀手级特性
Vue 2 时代,一个组件只能有一个 v-model,如果要双向绑定多个值,得用 .sync 修饰符,写起来很割裂。Vue 3 直接支持多个 v-model,优雅多了。
场景: 一个用户信息组件,同时需要双向绑定姓名和年龄。
子组件 UserFields.vue:
<template>
<div class="user-fields">
<label>
姓名:
<input
:value="name"
@input="$emit('update:name', $event.target.value)"
/>
</label>
<label>
年龄:
<input
type="number"
:value="age"
@input="$emit('update:age', Number($event.target.value))"
/>
</label>
</div>
</template>
<script setup>
defineProps({
name: { type: String, default: '' },
age: { type: Number, default: 0 }
})
defineEmits(['update:name', 'update:age'])
</script>
父组件使用:
<template>
<UserFields v-model:name="form.name" v-model:age="form.age" />
<p>姓名:{{ form.name }},年龄:{{ form.age }}</p>
</template>
<script setup>
import { reactive } from 'vue'
import UserFields from './UserFields.vue'
const form = reactive({
name: '',
age: 0
})
</script>
v-model:name="form.name" 展开后就是 :name="form.name" @update:name="form.name = $event"。规则和默认的 v-model 一样,只是把 modelValue 换成了你自己指定的 prop 名。
2.4 defineModel()——Vue 3.4+ 的终极简化
从 Vue 3.4 开始,defineModel() 正式转正(之前是实验性 API)。它让自定义组件的 v-model 写法大幅简化。
改造上面的 MyInput.vue:
<template>
<div class="my-input-wrapper">
<input v-model="model" class="my-input" />
</div>
</template>
<script setup>
const model = defineModel({ type: String, default: '' })
</script>
没有了手动 defineProps + defineEmits,也不用自己写 $emit。defineModel() 返回的是一个 ref,你直接用 v-model 绑定到原生 input 上就行,它会自动帮你处理和父组件之间的双向通信。
多个 v-model 也支持:
<script setup>
const name = defineModel('name', { type: String, default: '' })
const age = defineModel('age', { type: Number, default: 0 })
</script>
选型建议:
- 如果你的项目已经是 Vue 3.4+,强烈推荐用
defineModel(),代码量少、可读性好、不容易出错。
- 如果项目还在 Vue 3.3 及以下,老老实实用
defineProps + defineEmits 的经典写法。
- 别在生产项目里用实验性 API,等转正了再上。
三、表单组件拆分的实战思路——什么时候该拆?怎么拆?
知道了 v-model 的原理,接下来聊聊实战中最常遇到的问题:表单越写越长,该怎么拆?
3.1 不拆的代价
先看一个典型的"不拆"写法——一个订单表单,所有字段怼在一个组件里:
<template>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<!-- 基本信息 -->
<el-form-item label="订单名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="订单类型" prop="type">
<el-select v-model="form.type">
<el-option label="普通" value="normal" />
<el-option label="加急" value="urgent" />
</el-select>
</el-form-item>
<!-- 收货信息 -->
<el-form-item label="收货人" prop="receiver">
<el-input v-model="form.receiver" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" />
</el-form-item>
<el-form-item label="地址" prop="address">
<el-input v-model="form.address" />
</el-form-item>
<!-- 商品信息 -->
<el-form-item label="商品名称" prop="product">
<el-input v-model="form.product" />
</el-form-item>
<el-form-item label="数量" prop="quantity">
<el-input-number v-model="form.quantity" :min="1" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref, reactive } from 'vue'
const formRef = ref()
const form = reactive({
name: '', type: '', receiver: '', phone: '',
address: '', product: '', quantity: 1, remark: ''
})
const rules = {
name: [{ required: true, message: '请输入订单名称', trigger: 'blur' }],
type: [{ required: true, message: '请选择订单类型', trigger: 'change' }],
receiver: [{ required: true, message: '请输入收货人', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
],
address: [{ required: true, message: '请输入地址', trigger: 'blur' }],
product: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
quantity: [{ required: true, message: '请输入数量', trigger: 'change' }]
}
const handleSubmit = async () => {
await formRef.value.validate()
console.log('提交数据:', form)
}
</script>
这还只是 8 个字段。现实业务中,一个表单二三十个字段是常态,加上联动逻辑、动态显隐、异步校验……一个文件轻松上千行。
问题在哪?
- 改一个区块的字段,要在一大坨模板里翻半天
- 校验规则和字段分离,对应关系全靠
prop 字符串"人肉匹配"
- 无法复用——收货信息这一块在别的页面也要用,你只能复制粘贴
3.2 拆分原则:按业务区块拆,不是按字段拆
核心原则:一个子表单组件 = 一个业务含义的区块。
以上面的订单表单为例,天然可以拆成三块:
-
基本信息 ——
OrderBasicInfo.vue
-
收货信息 ——
ReceiverInfo.vue
-
商品信息 ——
ProductInfo.vue
3.3 拆分后的代码实现
子组件 ReceiverInfo.vue(收货信息区块):
<template>
<el-form-item label="收货人" prop="receiver">
<el-input :model-value="modelValue.receiver" @update:model-value="updateField('receiver', $event)" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input :model-value="modelValue.phone" @update:model-value="updateField('phone', $event)" />
</el-form-item>
<el-form-item label="地址" prop="address">
<el-input :model-value="modelValue.address" @update:model-value="updateField('address', $event)" />
</el-form-item>
</template>
<script setup>
const props = defineProps({
modelValue: {
type: Object,
required: true
}
})
const emit = defineEmits(['update:modelValue'])
const updateField = (field, value) => {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
</script>
关键点解读:
- 子组件通过
modelValue 接收整个区块的数据对象(不是单个字段)
- 更新时,用展开运算符创建一个新对象:
{ ...props.modelValue, [field]: value }
- 整体抛出给父组件,让父组件拿到新值去更新
⚠️ 这里有一个非常重要的踩坑点:为什么不能直接修改 props?
你可能想:props.modelValue 是个对象,我直接 props.modelValue.receiver = '张三' 不行吗?
技术上可以,Vue 不会报错(对象是引用传递)。但这是一个非常坏的习惯。 原因:
- 违反了 Vue 的"单向数据流"原则——数据应该从父组件流向子组件,子组件想改数据,应该通过事件通知父组件去改
- 当组件层级变深、多个子组件共享同一份数据时,直接修改 props 会导致"数据在哪被改的"完全无法追踪
- 在使用 Vue DevTools 调试时,直接改 props 不会触发事件记录,等于"偷偷改了但没人知道"
结论:永远通过 emit 通知父组件修改,哪怕多写几行代码。
父组件 OrderForm.vue(组装):
<template>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<h3>基本信息</h3>
<OrderBasicInfo v-model="basicInfo" />
<h3>收货信息</h3>
<ReceiverInfo v-model="receiverInfo" />
<h3>商品信息</h3>
<ProductInfo v-model="productInfo" />
<el-form-item>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import OrderBasicInfo from './OrderBasicInfo.vue'
import ReceiverInfo from './ReceiverInfo.vue'
import ProductInfo from './ProductInfo.vue'
const formRef = ref()
const basicInfo = ref({ name: '', type: '' })
const receiverInfo = ref({ receiver: '', phone: '', address: '' })
const productInfo = ref({ product: '', quantity: 1, remark: '' })
// 组装完整表单数据(用于提交和校验)
const form = computed(() => ({
...basicInfo.value,
...receiverInfo.value,
...productInfo.value
}))
const rules = {
name: [{ required: true, message: '请输入订单名称', trigger: 'blur' }],
receiver: [{ required: true, message: '请输入收货人', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
],
// ...其他规则
}
const handleSubmit = async () => {
await formRef.value.validate()
console.log('提交数据:', form.value)
}
</script>
3.4 用 defineModel() 简化子组件(Vue 3.4+)
如果项目版本允许,子组件可以进一步简化:
<!-- ReceiverInfo.vue(Vue 3.4+ 简化版)-->
<template>
<el-form-item label="收货人" prop="receiver">
<el-input v-model="model.receiver" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="model.phone" />
</el-form-item>
<el-form-item label="地址" prop="address">
<el-input v-model="model.address" />
</el-form-item>
</template>
<script setup>
const model = defineModel({ type: Object, required: true })
</script>
注意: defineModel() 返回的 ref 本质上会对对象的属性修改进行追踪并自动触发 update:modelValue。但如果你需要更细粒度的控制(比如只在某些条件下才允许更新),还是用 defineProps + defineEmits 的显式写法更合适。
四、Element Plus 表单封装实战——来点真正的项目级经验
上面讲了拆分的基本思路,下面来看在 Element Plus 体系下,实际项目中常用的几个封装模式。
4.1 踩坑重灾区:el-form 的 prop 路径与嵌套对象
当表单数据是嵌套结构时,el-form-item 的 prop 需要写成路径形式,否则校验不生效。
<template>
<el-form :model="form" :rules="rules" ref="formRef">
<!-- ❌ 错误:prop 写成 "name",但数据在 form.basic.name -->
<el-form-item label="姓名" prop="name">
<el-input v-model="form.basic.name" />
</el-form-item>
<!-- ✅ 正确:prop 要写完整路径 -->
<el-form-item label="姓名" prop="basic.name">
<el-input v-model="form.basic.name" />
</el-form-item>
</el-form>
</template>
<script setup>
import { reactive } from 'vue'
const form = reactive({
basic: { name: '', age: 0 },
contact: { phone: '', email: '' }
})
const rules = {
// 规则的 key 也要用完整路径
'basic.name': [{ required: true, message: '请输入姓名', trigger: 'blur' }],
'basic.age': [{ required: true, message: '请输入年龄', trigger: 'blur' }]
}
</script>
踩坑总结:
-
prop 的值必须和 form 对象中的路径一一对应
-
rules 对象的 key 也必须用相同的路径
- 如果
prop 和实际数据路径对不上,校验静默失败——不报错、不提示、就是不校验,非常难排查
4.2 封装一个通用的表单弹窗组件
这是项目中使用频率最高的模式之一——点击按钮弹出表单弹窗,填写后提交。
<!-- FormDialog.vue -->
<template>
<el-dialog
:model-value="visible"
@update:model-value="$emit('update:visible', $event)"
:title="title"
width="600px"
:close-on-click-modal="false"
@closed="handleClosed"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
@submit.prevent
>
<slot :form="formData" />
</el-form>
<template #footer>
<el-button @click="$emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleConfirm">
确定
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: '表单' },
rules: { type: Object, default: () => ({}) },
initialData: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['update:visible', 'confirm'])
const formRef = ref()
const formData = ref({})
const loading = ref(false)
// 每次打开弹窗时,用 initialData 初始化表单
watch(() => props.visible, (val) => {
if (val) {
formData.value = JSON.parse(JSON.stringify(props.initialData))
}
})
const handleConfirm = async () => {
try {
await formRef.value.validate()
loading.value = true
emit('confirm', { ...formData.value })
} catch {
// 校验未通过,不做处理
} finally {
loading.value = false
}
}
const handleClosed = () => {
formRef.value?.resetFields()
}
</script>
父组件使用:
<template>
<el-button @click="dialogVisible = true">新增用户</el-button>
<FormDialog
v-model:visible="dialogVisible"
title="新增用户"
:rules="rules"
:initial-data="defaultForm"
@confirm="handleConfirm"
>
<template #default="{ form }">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" />
</el-form-item>
</template>
</FormDialog>
</template>
<script setup>
import { ref } from 'vue'
import FormDialog from './FormDialog.vue'
const dialogVisible = ref(false)
const defaultForm = { username: '', email: '' }
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
]
}
const handleConfirm = async (formData) => {
// 调用接口提交
console.log('提交的数据:', formData)
dialogVisible.value = false
}
</script>
这个封装的核心设计:
| 设计点 |
为什么这么做 |
initialData 深拷贝 |
避免编辑时直接修改原始数据,取消后数据被"污染" |
@closed 时 resetFields
|
关闭动画结束后再清理,避免用户看到表单闪烁 |
:close-on-click-modal="false" |
防止用户填了一半误触遮罩关闭 |
@submit.prevent |
防止表单内按回车触发页面刷新 |
通过 slot 传入表单项 |
表单内容由调用方决定,弹窗组件只管"壳"和"行为" |
踩坑提醒: resetFields() 只会重置到 el-form 初始挂载时的值,不是清空为空字符串。所以如果你在弹窗打开后才给 formData 赋值,resetFields 可能重置到的是空对象而不是你期望的初始值。这就是为什么我们用 watch 在 visible 变为 true 时就立即赋值——确保表单挂载时就有正确的初始数据。
4.3 编辑与新增共用同一个弹窗
实际业务中,新增和编辑往往共用一个表单弹窗,只是初始数据不同。
<template>
<el-button @click="handleAdd">新增</el-button>
<el-button @click="handleEdit(mockData)">编辑</el-button>
<FormDialog
v-model:visible="dialogVisible"
:title="isEdit ? '编辑用户' : '新增用户'"
:rules="rules"
:initial-data="currentForm"
@confirm="handleConfirm"
>
<template #default="{ form }">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" :disabled="isEdit" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" />
</el-form-item>
</template>
</FormDialog>
</template>
<script setup>
import { ref, computed } from 'vue'
import FormDialog from './FormDialog.vue'
const dialogVisible = ref(false)
const editingItem = ref(null)
const isEdit = computed(() => !!editingItem.value)
const defaultForm = { username: '', email: '' }
const currentForm = computed(() =>
isEdit.value ? { ...editingItem.value } : { ...defaultForm }
)
const mockData = { id: 1, username: '张三', email: 'zhangsan@example.com' }
const handleAdd = () => {
editingItem.value = null
dialogVisible.value = true
}
const handleEdit = (item) => {
editingItem.value = item
dialogVisible.value = true
}
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
]
}
const handleConfirm = async (formData) => {
if (isEdit.value) {
console.log('更新数据:', { id: editingItem.value.id, ...formData })
} else {
console.log('新增数据:', formData)
}
dialogVisible.value = false
}
</script>
五、表单校验的那些事——从基础到自定义
5.1 Element Plus 校验的基本运作方式
Element Plus 的表单校验基于 async-validator 这个库。理解它的工作流程:
用户输入 → 触发 trigger 事件(blur/change)→ 根据 prop 查找对应的 rules → 执行校验 → 显示/隐藏错误提示
5.2 自定义校验器(validator)
内置规则(required、min、max、pattern 等)能覆盖大多数场景,但碰到复杂逻辑就得用自定义 validator。
const rules = {
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value.length < 8) {
callback(new Error('密码至少8位'))
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.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'
}
]
}
注意事项:
-
callback 必须调用,不管校验通过还是失败。忘记调用会导致校验"卡死"——按钮一直 loading,表单提交不了也不报错
- 校验通过时调用
callback()(不传参数)
- 校验失败时调用
callback(new Error('错误信息'))
5.3 异步校验(如:检查用户名是否已存在)
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{
validator: async (rule, value, callback) => {
try {
const { data } = await checkUsernameApi(value)
if (data.exists) {
callback(new Error('用户名已被占用'))
} else {
callback()
}
} catch {
callback(new Error('校验失败,请稍后再试'))
}
},
trigger: 'blur'
}
]
}
踩坑提醒: 异步校验如果不做防抖,用户每输入一个字符都会发请求。建议给异步校验加一个 debounce:
import { debounce } from 'lodash-es'
const checkUsername = debounce(async (value, callback) => {
try {
const { data } = await checkUsernameApi(value)
data.exists ? callback(new Error('用户名已被占用')) : callback()
} catch {
callback(new Error('校验失败,请稍后再试'))
}
}, 500)
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ validator: (rule, value, callback) => checkUsername(value, callback), trigger: 'blur' }
]
}
5.4 动态表单项的校验
动态增删的表单项(比如"添加更多联系人"),校验规则需要跟着走。
<template>
<el-form :model="form" ref="formRef" label-width="100px">
<div v-for="(contact, index) in form.contacts" :key="index" class="contact-row">
<el-form-item
:label="'联系人' + (index + 1)"
:prop="'contacts.' + index + '.name'"
:rules="[{ required: true, message: '请输入姓名', trigger: 'blur' }]"
>
<el-input v-model="contact.name" />
</el-form-item>
<el-form-item
label="电话"
:prop="'contacts.' + index + '.phone'"
:rules="[
{ required: true, message: '请输入电话', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '格式不正确', trigger: 'blur' }
]"
>
<el-input v-model="contact.phone" />
</el-form-item>
<el-button @click="removeContact(index)" type="danger" text>删除</el-button>
</div>
<el-button @click="addContact" type="primary" plain>添加联系人</el-button>
<el-form-item>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref, reactive } from 'vue'
const formRef = ref()
const form = reactive({
contacts: [{ name: '', phone: '' }]
})
const addContact = () => {
form.contacts.push({ name: '', phone: '' })
}
const removeContact = (index) => {
form.contacts.splice(index, 1)
}
const handleSubmit = async () => {
try {
await formRef.value.validate()
console.log('提交数据:', form.contacts)
} catch {
console.log('校验未通过')
}
}
</script>
踩坑要点:
-
:prop 必须是动态的,格式为 '数组名.' + index + '.字段名'
- 校验规则可以直接写在
el-form-item 的 :rules 上,不用全放在 el-form 的 :rules 里
-
v-for 一定要绑定 :key,且最好不要用 index 作为 key(删除中间项时会导致校验状态错乱)。推荐给每个 contact 加一个唯一 id
改进后的做法:
import { nanoid } from 'nanoid'
const addContact = () => {
form.contacts.push({ id: nanoid(), name: '', phone: '' })
}
<div v-for="(contact, index) in form.contacts" :key="contact.id">
六、常见踩坑汇总
写了这么多,最后把文中提到的和额外的高频坑点汇总一下,方便速查:
| # |
踩坑点 |
现象 |
正确做法 |
| 1 |
v-model 和 :value 同时写 |
控制台警告,行为异常 |
二选一,不要混用 |
| 2 |
Vue 3 事件名写错 |
子组件修改不生效 |
必须是 update:modelValue
|
| 3 |
子组件直接修改 props 对象 |
短期没问题,长期数据流混乱 |
通过 emit 通知父组件修改 |
| 4 |
prop 路径和数据路径不匹配 |
校验静默失败 |
prop 值必须对应 form 对象的完整路径 |
| 5 |
validator 忘记调用 callback |
表单提交"卡死" |
无论通过还是失败都要调用 callback |
| 6 |
resetFields 重置到空而非初始值 |
编辑弹窗关闭后数据异常 |
确保表单挂载时就有正确初始数据 |
| 7 |
动态表单 v-for 用 index 做 key |
删除项后校验状态错乱 |
用唯一 id 作为 key |
| 8 |
异步校验没做防抖 |
疯狂发请求 |
用 debounce 包装异步校验 |
| 9 |
弹窗表单 close-on-click-modal |
填了一半误触关闭 |
设为 false |
| 10 |
表单内按回车刷新页面 |
原生 form 默认提交行为 |
加 @submit.prevent
|
七、总结
回顾整篇文章的知识脉络:
v-model 本质(语法糖)
├─ 原生元素::value + @input
└─ 自定义组件
├─ Vue 3 经典写法:defineProps + defineEmits
├─ Vue 3.4+ 简化:defineModel()
└─ 多个 v-model:v-model:xxx
│
▼
表单组件拆分
├─ 按业务区块拆分
├─ 子组件通过 v-model 和父组件通信
└─ 永远不要直接修改 props
│
▼
Element Plus 表单封装
├─ 嵌套对象的 prop 路径
├─ 通用表单弹窗组件
└─ 新增/编辑共用弹窗
│
▼
表单校验
├─ 自定义 validator
├─ 异步校验 + 防抖
└─ 动态表单项校验
表单是前端日常工作中占比最大的 UI 模式之一。把 v-model 的本质搞清楚,把组件拆分的边界想明白,把 Element Plus 的校验机制摸透——这三件事做到了,你在日常表单开发中就能做到写得快、改得动、不踩坑。
学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。
后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。
关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。
如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。
我是 Eugene,你的电子学友,我们下一篇干货见~