表单最佳实践:从 v-model 到自定义表单组件(含校验)

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零 ,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱"面向搜索引擎写代码"的尴尬。

一、v-model 到底是什么?------先把"语法糖"这三个字吃透

很多人写了好几年 Vue,天天用 v-model,但如果问一句:"v-model 的本质是什么?",不少人只能说出"双向绑定"四个字就卡住了。

一句话结论:v-model 是一颗语法糖,它帮你把"传值进去 + 监听变化抛出来"这两步合成了一步。

1.1 在原生元素上:v-model = :value + @input

先看最基础的用法:

vue 复制代码
<template>
  <input v-model="username" />
</template>

<script setup>
import { ref } from 'vue'
const username = ref('')
</script>

这段代码等价于:

vue 复制代码
<template>
  <input :value="username" @input="username = $event.target.value" />
</template>

<script setup>
import { ref } from 'vue'
const username = ref('')
</script>

看到了吗?Vue 帮你做了两件事:

  1. username 的值通过 :value 绑定到 input 上(数据 → 视图)
  2. 监听 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 会在控制台警告你冲突。

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

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>

父组件使用:

vue 复制代码
<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 会自动展开为:

vue 复制代码
<MyInput :modelValue="username" @update:modelValue="username = $event" />

所以子组件需要做两件事:

  1. modelValue 这个 prop 接收值
  2. 变化时通过 $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

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>

父组件使用:

vue 复制代码
<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

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,也不用自己写 $emitdefineModel() 返回的是一个 ref,你直接用 v-model 绑定到原生 input 上就行,它会自动帮你处理和父组件之间的双向通信。

多个 v-model 也支持:

vue 复制代码
<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 不拆的代价

先看一个典型的"不拆"写法------一个订单表单,所有字段怼在一个组件里:

vue 复制代码
<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 拆分原则:按业务区块拆,不是按字段拆

核心原则:一个子表单组件 = 一个业务含义的区块。

以上面的订单表单为例,天然可以拆成三块:

  1. 基本信息 ------ OrderBasicInfo.vue
  2. 收货信息 ------ ReceiverInfo.vue
  3. 商品信息 ------ ProductInfo.vue

3.3 拆分后的代码实现

子组件 ReceiverInfo.vue(收货信息区块):

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>

关键点解读:

  1. 子组件通过 modelValue 接收整个区块的数据对象(不是单个字段)
  2. 更新时,用展开运算符创建一个新对象{ ...props.modelValue, [field]: value }
  3. 整体抛出给父组件,让父组件拿到新值去更新

⚠️ 这里有一个非常重要的踩坑点:为什么不能直接修改 props?

你可能想:props.modelValue 是个对象,我直接 props.modelValue.receiver = '张三' 不行吗?

技术上可以,Vue 不会报错(对象是引用传递)。但这是一个非常坏的习惯。 原因:

  • 违反了 Vue 的"单向数据流"原则------数据应该从父组件流向子组件,子组件想改数据,应该通过事件通知父组件去改
  • 当组件层级变深、多个子组件共享同一份数据时,直接修改 props 会导致"数据在哪被改的"完全无法追踪
  • 在使用 Vue DevTools 调试时,直接改 props 不会触发事件记录,等于"偷偷改了但没人知道"

结论:永远通过 emit 通知父组件修改,哪怕多写几行代码。

父组件 OrderForm.vue(组装):

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+)

如果项目版本允许,子组件可以进一步简化:

vue 复制代码
<!-- 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-itemprop 需要写成路径形式,否则校验不生效。

vue 复制代码
<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 封装一个通用的表单弹窗组件

这是项目中使用频率最高的模式之一------点击按钮弹出表单弹窗,填写后提交。

vue 复制代码
<!-- 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>

父组件使用:

vue 复制代码
<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 深拷贝 避免编辑时直接修改原始数据,取消后数据被"污染"
@closedresetFields 关闭动画结束后再清理,避免用户看到表单闪烁
:close-on-click-modal="false" 防止用户填了一半误触遮罩关闭
@submit.prevent 防止表单内按回车触发页面刷新
通过 slot 传入表单项 表单内容由调用方决定,弹窗组件只管"壳"和"行为"

踩坑提醒: resetFields() 只会重置到 el-form 初始挂载时的值,不是清空为空字符串。所以如果你在弹窗打开后才给 formData 赋值,resetFields 可能重置到的是空对象而不是你期望的初始值。这就是为什么我们用 watchvisible 变为 true 时就立即赋值------确保表单挂载时就有正确的初始数据。

4.3 编辑与新增共用同一个弹窗

实际业务中,新增和编辑往往共用一个表单弹窗,只是初始数据不同。

vue 复制代码
<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 这个库。理解它的工作流程:

bash 复制代码
用户输入 → 触发 trigger 事件(blur/change)→ 根据 prop 查找对应的 rules → 执行校验 → 显示/隐藏错误提示

5.2 自定义校验器(validator)

内置规则(required、min、max、pattern 等)能覆盖大多数场景,但碰到复杂逻辑就得用自定义 validator。

js 复制代码
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 异步校验(如:检查用户名是否已存在)

js 复制代码
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

js 复制代码
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 动态表单项的校验

动态增删的表单项(比如"添加更多联系人"),校验规则需要跟着走。

vue 复制代码
<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

改进后的做法:

js 复制代码
import { nanoid } from 'nanoid'

const addContact = () => {
  form.contacts.push({ id: nanoid(), name: '', phone: '' })
}
html 复制代码
<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

七、总结

回顾整篇文章的知识脉络:

ruby 复制代码
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,你的电子学友,我们下一篇干货见~

相关推荐
昨晚我输给了一辆AE861 小时前
为什么现在不推荐使用 React.FC 了?
前端·react.js·typescript
不会敲代码11 小时前
深入浅出 React 闭包陷阱:从现象到原理
前端·react.js
不会敲代码11 小时前
React性能优化:深入理解useMemo和useCallback
前端·javascript·react.js
Dilettante2581 小时前
我的 Monorepo 实践经验:从基础概念到最佳实践
前端·前端工程化
只会cv的前端攻城狮2 小时前
Elpis-Core — 融合 Koa 洋葱圈模型实现服务端引擎
前端·后端
Java小卷2 小时前
流程设计器为啥选择diagram-js
前端·低代码·工作流引擎
HelloReader3 小时前
Isolation Pattern(隔离模式)在前端与 Core 之间加一道“加密网关”,拦截与校验所有 IPC
前端
兆子龙3 小时前
从 float 到 Flex/Grid:CSS 左右布局简史与「刁钻」布局怎么搞
前端·架构
YukiMori233 小时前
一个有趣的原型继承实验:为什么“男人也会生孩子”?从对象赋值到构造函数继承的完整推演
前端·javascript