Vue.js: 表单与 v-model

最后更新:2026-08-26

表单是 Web 应用的核心交互——登录、注册、搜索、设置都离不开表单处理。Vue 的 v-model 是处理表单的"语法糖",让双向绑定代码极简(1 行 vs 5 行)。

Vue 3.4+ 的 defineModel 进一步简化 v-model 实现。复杂表单还需要校验、序列化、文件上传等高级功能。本课覆盖 5 大表单场景。

1. 你将学到


2. 一个登录表单的"5 处 boilerplate"噩梦

(1) 痛点:5 个表单字段,10 行 v-model 代码

Alice 的登录表单有 5 个字段,每个都有 v-model 绑定:

VUE
<!-- ❌ 翻车版:5 个字段 + 5 个 ref + 5 个 v-model -->
<template>
  <form @submit.prevent="handleLogin">
    <input v-model="username">
    <input v-model="password" type="password">
    <input v-model="email" type="email">
    <input v-model="phone">
    <input v-model="captcha">
    <button>Login</button>
  </form>
</template>

<script setup>
const { ref } = Vue
const username = ref('')
const password = ref('')
const email = ref('')
const phone = ref('')
const captcha = ref('')
</script>

5 字段 = 10 行样板代码。10 字段就是 20 行。表单越复杂越糟。

(2) Vue v-model 进阶解法:reactive 对象 + defineModel

VUE
<!-- ✅ 正确版:1 个 reactive 对象 -->
<template>
  <form @submit.prevent="handleLogin">
    <input v-model="form.username">
    <input v-model="form.password" type="password">
    <input v-model="form.email" type="email">
    <input v-model="form.phone">
    <input v-model="form.captcha">
    <button>Login</button>
  </form>
</template>

<script setup>
const { reactive } = Vue
const form = reactive({
  username: '',
  password: '',
  email: '',
  phone: '',
  captcha: ''
})
</script>

5 字段 = 1 个对象。reactive 让代码量减少 50%。

(3) 收益

v-model 优化后:


3. v-model 原理

(1) v-model 本质

VUE
<!-- v-model 是 props + emit 的语法糖 -->
<input v-model="searchQuery">

<!-- 等同于 -->
<input 
  :value="searchQuery" 
  @input="searchQuery = $event.target.value"
>

(2) 组件中的 v-model

VUE
<!-- 子组件:CustomInput.vue -->
<template>
  <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)">
</template>

<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
VUE
<!-- 父组件使用 v-model -->
<CustomInput v-model="searchQuery" />

<!-- 等同于 -->
<CustomInput 
  :modelValue="searchQuery" 
  @update:modelValue="searchQuery = $event"
/>

(3) defineModel(Vue 3.4+ 简化)

VUE
<!-- Vue 3.4+:1 行替代上面 10 行 -->
<template>
  <input v-model="value">
</template>

<script setup>
const value = defineModel('modelValue')
// 现在 value 是 ref,可以:
// - 读:value.value
// - 写:value.value = 'new'(自动 emit)
// - watch:watch(value, ...)
// - 父组件 v-model 双向绑定照常
</script>

4. 5 种自定义 v-model

(1) 基本 input

VUE
<template>
  <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)">
</template>

<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

(2) textarea

VUE
<template>
  <textarea :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</template>

(3) select

VUE
<template>
  <select 
    :value="modelValue" 
    @change="$emit('update:modelValue', $event.target.value)"
  >
    <option value="vue">Vue</option>
    <option value="react">React</option>
  </select>
</template>

(4) checkbox(多选)

VUE
<template>
  <div>
    <label v-for="option in options" :key="option">
      <input 
        type="checkbox" 
        :value="option" 
        :checked="modelValue.includes(option)"
        @change="onChange($event, option)"
      >
      {{ option }}
    </label>
  </div>
</template>

<script setup>
const props = defineProps({
  modelValue: Array,
  options: Array
})
const emit = defineEmits(['update:modelValue'])

function onChange(e, option) {
  const newValue = e.target.checked
    ? [...props.modelValue, option]
    : props.modelValue.filter(v => v !== option)
  emit('update:modelValue', newValue)
}
</script>

(5) 自定义修饰符(trim)

VUE
<!-- 父组件:v-model.trim -->
<CustomInput v-model.trim="username" />

<!-- 子组件:处理 trim 修饰符 -->
<script setup>
const props = defineProps({
  modelValue: String,
  modelModifiers: { default: () => ({}) }
})
const emit = defineEmits(['update:modelValue'])

function onInput(e) {
  let value = e.target.value
  if (props.modelModifiers.trim) {
    value = value.trim()
  }
  emit('update:modelValue', value)
}
</script>

5. 表单校验

(1) 自定义校验

JS
// utils/validators.js
export function isEmail(value) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}

export function isPhone(value) {
  return /^1[3-9]\d{9}$/.test(value)
}

export function minLength(value, min) {
  return value.length >= min
}
VUE
<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="form.email" @blur="validateField('email')">
    <p v-if="errors.email" class="error">{{ errors.email }}</p>
    
    <input v-model="form.password" type="password">
    <p v-if="errors.password">{{ errors.password }}</p>
    
    <button :disabled="!isValid">Submit</button>
  </form>
</template>

<script setup>
const { reactive, ref } = Vue
import { isEmail, minLength } from '@/utils/validators'

const form = reactive({
  email: '',
  password: ''
})

const errors = reactive({})
const isValid = ref(false)

function validateField(field) {
  if (field === 'email') {
    if (!form.email) {
      errors.email = 'Email is required'
    } else if (!isEmail(form.email)) {
      errors.email = 'Invalid email format'
    } else {
      delete errors.email
    }
  }
  
  if (field === 'password') {
    if (!form.password) {
      errors.password = 'Password is required'
    } else if (!minLength(form.password, 8)) {
      errors.password = 'Password must be 8+ characters'
    } else {
      delete errors.password
    }
  }
  
  isValid.value = Object.keys(errors).length === 0
}

function handleSubmit() {
  Object.keys(form).forEach(validateField)
  if (isValid.value) {
    console.log('Submit:', form)
  }
}
</script>

(2) VeeValidate 库(推荐)

BASH
npm install vee-validate @vee-validate/rules
VUE
<template>
  <Form @submit="handleSubmit" :validation-schema="schema">
    <Field name="email" type="email" v-model="form.email" />
    <ErrorMessage name="email" />
    
    <Field name="password" type="password" v-model="form.password" />
    <ErrorMessage name="password" />
    
    <button>Submit</button>
  </Form>
</template>

<script setup>
import { Form, Field, ErrorMessage } from 'vee-validate'
import * as yup from 'yup'

const schema = yup.object({
  email: yup.string().email().required(),
  password: yup.string().min(8).required()
})

const form = reactive({ email: '', password: '' })

function handleSubmit(values) {
  console.log('Valid:', values)
}
</script>

6. 复杂表单设计

(1) 嵌套表单

JS
const form = reactive({
  user: {
    name: '',
    email: '',
    age: 0
  },
  address: {
    city: '',
    country: ''
  }
})
VUE
<template>
  <input v-model="form.user.name" placeholder="Name">
  <input v-model="form.user.email" placeholder="Email">
  <input v-model="form.address.city" placeholder="City">
</template>

(2) 动态字段(v-for)

JS
const form = reactive({
  items: [
    { name: '', quantity: 1, price: 0 }
  ]
})

function addItem() {
  form.items.push({ name: '', quantity: 1, price: 0 })
}

function removeItem(index) {
  form.items.splice(index, 1)
}
VUE
<template>
  <div v-for="(item, index) in form.items" :key="index">
    <input v-model="item.name" placeholder="Item name">
    <input v-model.number="item.quantity" type="number">
    <input v-model.number="item.price" type="number">
    <button @click="removeItem(index)">Remove</button>
  </div>
  <button @click="addItem">Add Item</button>
</template>

(3) 5 大表单修饰符

VUE
<!-- 1. .lazy:change 事件触发(不是 input) -->
<input v-model.lazy="email">

<!-- 2. .number:自动转数字 -->
<input v-model.number="age" type="number">

<!-- 3. .trim:自动去空格 -->
<input v-model.trim="username">

<!-- 4. 自定义修饰符(CustomInput) -->
<CustomInput v-model.trim="text" />

<!-- 5. .debounce(自定义修饰符):DebouncedInput 组件内部通过 modelModifiers 实现 -->
<DebouncedInput v-model.debounce="searchText" />

7. 文件上传

(1) 单文件上传

VUE
<template>
  <input type="file" @change="handleFile" accept="image/*">
  <img v-if="preview" :src="preview" alt="Preview">
</template>

<script setup>
const { ref } = Vue

const preview = ref(null)

function handleFile(event) {
  const file = event.target.files[0]
  if (file) {
    // 本地预览
    const reader = new FileReader()
    reader.onload = (e) => {
      preview.value = e.target.result
    }
    reader.readAsDataURL(file)
    
    // 上传到服务器
    const formData = new FormData()
    formData.append('file', file)
    fetch('/api/upload', { method: 'POST', body: formData })
  }
}
</script>

(2) 多文件 + 拖拽

VUE
<template>
  <div 
    @dragover.prevent
    @drop.prevent="handleDrop"
    :class="{ dragging: isDragging }"
  >
    <input type="file" multiple @change="handleFiles" accept="image/*">
    <p>Drag files here or click to upload</p>
    
    <div v-for="file in files" :key="file.name">
      <img :src="file.preview" :alt="file.name">
      <p>{{ file.name }} ({{ file.size }} bytes)</p>
    </div>
  </div>
</template>

<script setup>
const { ref, reactive } = Vue

const files = reactive([])
const isDragging = ref(false)

function addFile(file) {
  if (!file.type.startsWith('image/')) return
  
  const reader = new FileReader()
  reader.onload = (e) => {
    files.push({
      name: file.name,
      size: file.size,
      preview: e.target.result
    })
  }
  reader.readAsDataURL(file)
}

function handleFiles(e) {
  Array.from(e.target.files).forEach(addFile)
}

function handleDrop(e) {
  isDragging.value = false
  Array.from(e.dataTransfer.files).forEach(addFile)
}
</script>

8. 完整示例:5 大表单场景

▶ 示例:5 种 v-model 基础 + 校验

HTML 📖 仅展示
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>

<style>
input { padding: 6px; border: 1px solid #ddd; border-radius: 4px; margin: 4px; display: block; }
.error { color: #ef4444; font-size: 0.85rem; margin: 2px 0; }
.btn { padding: 6px 12px; background: #42b883; color: white; border: none; border-radius: 4px; cursor: pointer; }
.btn:disabled { background: #ccc; cursor: not-allowed; }
.ok { color: #10b981; padding: 4px; }
</style>

<div id="app">
  <h4>注册表单(5 字段 + 校验)</h4>
  <input v-model.trim="form.username" placeholder="Username" @blur="validate('username')">
  <p class="error">{{ errors.username || '' }}</p>

  <input v-model.trim="form.email" placeholder="Email" @blur="validate('email')">
  <p class="error">{{ errors.email || '' }}</p>

  <input v-model.number="form.age" type="number" placeholder="Age" @blur="validate('age')">
  <p class="error">{{ errors.age || '' }}</p>

  <input v-model="form.password" type="password" placeholder="Password (≥8)" @blur="validate('password')">
  <p class="error">{{ errors.password || '' }}</p>

  <input v-model="form.confirm" type="password" placeholder="Confirm Password" @blur="validate('confirm')">
  <p class="error">{{ errors.confirm || '' }}</p>

  <p v-if="success" class="ok">✅ 表单通过校验!可提交</p>

  <button class="btn" :disabled="!isValid" @click="submit">注册</button>
</div>

<script>
const { createApp, reactive, ref, computed } = Vue

const App = {
  setup() {
    const form = reactive({
      username: '', email: '', age: 0, password: '', confirm: ''
    })
    const errors = reactive({})
    const success = ref(false)

    const isValid = computed(() => {
      return form.username && form.email && form.age >= 18
        && form.password.length >= 8 && form.password === form.confirm
        && Object.keys(errors).length === 0
    })

    function validate(field) {
      // 1. 必填
      if (field === 'username' && !form.username) {
        errors.username = 'Required'
      } else if (field === 'email' && (!form.email || !form.email.includes('@'))) {
        errors.email = 'Invalid email'
      } else if (field === 'age' && (form.age < 18 || form.age > 120)) {
        errors.age = 'Age must be 18-120'
      } else if (field === 'password' && form.password.length < 8) {
        errors.password = 'Password must be ≥8 characters'
      } else if (field === 'confirm' && form.password !== form.confirm) {
        errors.confirm = 'Passwords do not match'
      } else {
        delete errors[field]
      }
      success.value = isValid.value && Object.keys(errors).length === 0
    }

    function submit() {
      if (isValid.value) {
        alert('Submitted: ' + JSON.stringify(form))
      }
    }

    return { form, errors, success, isValid, validate, submit }
  }
}

createApp(App).mount('#app')
</script>
逻辑代码 63 行(超过 40 行限制,仅展示)

▶ 示例:5 种自定义 v-model + 多 v-model 速查

VUE
<!-- 子组件:CustomInput.vue -->
<template>
  <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)">
</template>

<!-- 父组件 -->
<CustomInput v-model="search" />
<!-- 等同于 -->
<CustomInput :modelValue="search" @update:modelValue="search = $event" />

<!-- 多个 v-model(Vue 3.4+)-->
<CustomInput v-model:username="user" v-model:password="pass" />
▶ 试一试

▶ 示例:5 大表单修饰符

VUE
<!-- 1. .lazy:change 事件(失焦后)-->
<input v-model.lazy="email">

<!-- 2. .number:自动转数字 -->
<input v-model.number="age" type="number">

<!-- 3. .trim:自动 trim -->
<input v-model.trim="username">

<!-- 4. 多修饰符组合 -->
<input v-model.lazy.trim="email">

<!-- 5. 自定义修饰符(子组件) -->
<CustomInput v-model.trim="text" />
▶ 试一试

▶ 示例:5 个常见错误速查

错误 现象 解决
v-model.number 仍为字符串 加法运算异常 type="number" + .number
v-model 不更新 ref 改为 reactive 后失效 改 .value 或 toRefs
文件上传 formData 失败 跨域或 method 错 fetch + FormData
校验时机不对 失去焦点才显示 用 @blur
大量字段 boilerplate 代码冗长 用 reactive 对象

▶ 示例:5 大性能对比

模式 简洁度 性能 适用
多个 ref ❌ 重复 ⭐⭐⭐⭐ 1-2 字段
reactive 对象 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 5+ 字段
FormData 手动 ⭐⭐⭐ 文件上传
VeeValidate ⭐⭐⭐⭐ ⭐⭐⭐ 复杂校验
defineModel ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ Vue 3.4+ 组件

❓ 常见问题

Q v-model 在自定义组件中怎么用?
A 组件内 defineProps(['modelValue']) + defineEmits(['update:modelValue'])。父组件用 v-model。Vue 3.4+ 用 defineModel 1 行替代 10 行。
Q v-model.number 不生效?
A 必须配合 type="number"。或者手动 parseFloat()
Q 表单校验用自定义还是 VeeValidate?
A 简单表单(< 5 字段)自定义;复杂表单(> 5 字段 + 嵌套 + 异步校验)用 VeeValidate。
Q 怎么上传文件并显示预览?
A 用 FileReader.readAsDataURL() 读为 base64 字符串,赋值给 <img src="...">。上传时用 FormData。
Q v-model 修饰符 .lazy 和 .debounce 区别?
A .lazy 在 change 事件触发(失焦后),节省实时校验开销。.debounce 是自定义修饰符,需要在子组件实现,300ms 内最后一次触发。
Q 表单数据怎么序列化为 JSON 提交?
A 直接用 JSON.stringify(form)(无需 FormData,除非有文件)。后端接收 JSON 即可。
Q defineModel 替代 v-model 完整写法吗?
A 是的。defineModel('modelValue') 等同于 props.modelValue + emit('update:modelValue') 的 10 行代码。完全向后兼容。

📖 小节


📝 作业

  1. 基础题(难度⭐) 实现一个简单的登录表单:

    • 5 个字段(username / password / email / phone / captcha)
    • v-model + reactive
    • 提交时打印整个 form 对象
  2. 进阶题(难度⭐⭐) 实现表单校验:

    • 5 个字段 + 自定义校验(email 格式、phone 格式、password 长度)
    • @blur 触发校验
    • 错误信息显示
    • 提交按钮根据 isValid 禁用/启用
  3. 挑战题(难度⭐⭐⭐) 实现完整的"用户注册"表单系统:

    1. 10 个字段(含嵌套 address 对象 + 动态 tags 数组)
    2. VeeValidate + Yup schema
    3. 文件上传(头像)
    4. 5 种修饰符全覆盖
    5. defineModel 用于子组件
    6. 5 种错误状态
    7. TypeScript 强类型
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏