Skip to content

组件 v-model

v-model 可以在组件上使用以实现双向绑定。

首先让我们回忆一下 v-model 在原生元素上的用法:

template
<input v-model="searchText" />

在代码背后,模板编译器会对 v-model 进行更冗长的等价展开。因此上面的代码其实等价于下面这段:

template
<input
  :value="searchText"
  @input="searchText = $event.target.value"
/>

而当使用在一个组件上时,v-model 会被展开为如下的形式:

template
<CustomInput
  :model-value="searchText"
  @update:model-value="newValue => searchText = newValue"
/>

要让这个例子实际工作起来,<CustomInput> 组件内部需要做两件事:

  1. 将内部原生 <input> 元素的 value attribute 绑定到 modelValue prop
  2. 当原生的 input 事件触发时,触发一个携带了新值的 update:modelValue 自定义事件

这里是相应的代码:

vue
<!-- CustomInput.vue -->
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>
vue
<!-- CustomInput.vue -->
<script>
export default {
  props: ['modelValue'],
  emits: ['update:modelValue'],
  template: `
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
    />
  `
}
</script>

现在 v-model 就可以在这个组件上正常工作了:

template
<CustomInput v-model="searchText" />

另一种在组件内实现 v-model 的方式是使用一个可写的,同时具有 getter 和 setter 的 computed 属性。get 方法需返回 modelValue prop,而 set 方法需触发相应的事件:

vue
<!-- CustomInput.vue -->
<script setup>
import { computed } from 'vue'

const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

const value = computed({
  get() {
    return props.modelValue
  },
  set(value) {
    emit('update:modelValue', value)
  }
})
</script>

<template>
  <input v-model="value" />
</template>

v-model 的参数

默认情况下,v-model 在组件上都是使用 modelValue 作为 prop,并以 update:modelValue 作为对应的事件。我们可以通过给 v-model 指定一个参数来更改这些名字:

template
<MyComponent v-model:title="bookTitle" />

在这个例子中,子组件应该有一个 title prop,并通过触发 update:title 事件更新父组件值:

vue
<!-- MyComponent.vue -->
<script setup>
defineProps(['title'])
defineEmits(['update:title'])
</script>

<template>
  <input
    type="text"
    :value="title"
    @input="$emit('update:title', $event.target.value)"
  />
</template>
vue
<!-- MyComponent.vue -->
<script>
export default {
  props: ['title'],
  emits: ['update:title'],
  template: `
    <input
      type="text"
      :value="title"
      @input="$emit('update:title', $event.target.value)"
    />
  `
}
</script>

多个 v-model 绑定

利用刚才在 v-model 参数小节中学到的指定参数与事件名的技巧,我们可以在单个组件实例上创建多个 v-model 双向绑定。

组件上的每一个 v-model 都会同步不同的 prop,而无需额外的选项:

template
<UserName
  v-model:first-name="first"
  v-model:last-name="last"
/>
vue
<!-- UserName.vue -->
<script setup>
defineProps({
  firstName: String,
  lastName: String
})

defineEmits(['update:firstName', 'update:lastName'])
</script>

<template>
  <input
    type="text"
    :value="firstName"
    @input="$emit('update:firstName', $event.target.value)"
  />
  <input
    type="text"
    :value="lastName"
    @input="$emit('update:lastName', $event.target.value)"
  />
</template>
vue
<!-- UserName.vue -->
<script>
export default {
  props: {
    firstName: String,
    lastName: String
  },
  emits: ['update:firstName', 'update:lastName'],
  template: `
    <input
      type="text"
      :value="firstName"
      @input="$emit('update:firstName', $event.target.value)"
    />
    <input
      type="text"
      :value="lastName"
      @input="$emit('update:lastName', $event.target.value)"
    />
  `
}
</script>

处理 v-model 修饰符

在学习输入绑定时,我们知道了 v-model 有一些内置的修饰符,例如 .trim.number.lazy。在某些场景下,你可能想要一个自定义组件的 v-model 支持自定义的修饰符。

我们来创建一个自定义的修饰符 capitalize,它会自动将 v-model 绑定输入的字符串值第一个字母转为大写:

template
<MyComponent v-model.capitalize="myText" />

组件的 v-model 上所添加的修饰符,可以通过 modelModifiers prop 在组件内访问到。在下面的组件中,我们声明了 modelModifiers 这个 prop,它的默认值是一个空对象:

vue
<!-- MyComponent.vue -->
<script setup>
const props = defineProps({
  modelValue: String,
  modelModifiers: { default: () => ({}) }
})

defineEmits(['update:modelValue'])

console.log(props.modelModifiers) // { capitalize: true }
</script>

<template>
  <input
    type="text"
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>
vue
<!-- MyComponent.vue -->
<script>
export default {
  props: {
    modelValue: String,
    modelModifiers: {
      default: () => ({})
    }
  },
  emits: ['update:modelValue'],
  created() {
    console.log(this.modelModifiers) // { capitalize: true }
  },
  template: `
    <input
      type="text"
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
    />
  `
}
</script>

注意这里组件的 modelModifiers prop 包含了 capitalize 且其值为 true,因为它在模板中的 v-model 绑定 v-model.capitalize="myText" 上被使用了。

有了这个 prop,我们就可以检查 modelModifiers 对象的键,并编写一个处理函数来改变抛出的值。在下面的代码里,我们就是在每次 <input /> 元素触发 input 事件时将值的首字母大写:

vue
<script setup>
const props = defineProps({
  modelValue: String,
  modelModifiers: { default: () => ({}) }
})

const emit = defineEmits(['update:modelValue'])

function emitValue(e) {
  let value = e.target.value
  if (props.modelModifiers.capitalize) {
    value = value.charAt(0).toUpperCase() + value.slice(1)
  }
  emit('update:modelValue', value)
}
</script>

<template>
  <input type="text" :value="modelValue" @input="emitValue" />
</template>
vue
<script>
export default {
  props: {
    modelValue: String,
    modelModifiers: {
      default: () => ({})
    }
  },
  emits: ['update:modelValue'],
  methods: {
    emitValue(e) {
      let value = e.target.value
      if (this.modelModifiers.capitalize) {
        value = value.charAt(0).toUpperCase() + value.slice(1)
      }
      this.$emit('update:modelValue', value)
    }
  },
  template: `
    <input
      type="text"
      :value="modelValue"
      @input="emitValue"
    />
  `
}
</script>

对于又有参数又有修饰符的 v-model 绑定,生成的 prop 名将是 arg + "Modifiers"。举例来说:

template
<MyComponent v-model:title.capitalize="myText">

相应的声明应该是:

js
const props = defineProps(['title', 'titleModifiers'])
defineEmits(['update:title'])

console.log(props.titleModifiers) // { capitalize: true }
js
export default {
  props: ['title', 'titleModifiers'],
  emits: ['update:title'],
  created() {
    console.log(this.titleModifiers) // { capitalize: true }
  }
}

实际应用示例

基础自定义输入组件

vue
<!-- BaseInput.vue -->
<script setup>
import { computed } from 'vue'

const props = defineProps({
  modelValue: String,
  placeholder: String,
  type: {
    type: String,
    default: 'text'
  },
  disabled: Boolean,
  readonly: Boolean,
  error: String
})

const emit = defineEmits(['update:modelValue', 'focus', 'blur'])

const inputValue = computed({
  get() {
    return props.modelValue
  },
  set(value) {
    emit('update:modelValue', value)
  }
})

const handleFocus = (event) => {
  emit('focus', event)
}

const handleBlur = (event) => {
  emit('blur', event)
}
</script>

<template>
  <div class="base-input">
    <input
      v-model="inputValue"
      :type="type"
      :placeholder="placeholder"
      :disabled="disabled"
      :readonly="readonly"
      :class="{ 'has-error': error }"
      @focus="handleFocus"
      @blur="handleBlur"
    />
    <div v-if="error" class="error-message">{{ error }}</div>
  </div>
</template>

<style scoped>
.base-input {
  margin-bottom: 16px;
}

input {
  width: 100%;
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
  transition: border-color 0.2s;
}

input:focus {
  outline: none;
  border-color: #007bff;
}

input.has-error {
  border-color: #dc3545;
}

input:disabled {
  background-color: #f5f5f5;
  cursor: not-allowed;
}

.error-message {
  color: #dc3545;
  font-size: 12px;
  margin-top: 4px;
}
</style>

复杂表单组件

vue
<!-- UserForm.vue -->
<script setup>
import { ref, computed } from 'vue'
import BaseInput from './BaseInput.vue'

const props = defineProps({
  user: {
    type: Object,
    default: () => ({
      firstName: '',
      lastName: '',
      email: '',
      phone: ''
    })
  }
})

const emit = defineEmits(['update:user'])

// 使用计算属性实现双向绑定
const firstName = computed({
  get() {
    return props.user.firstName
  },
  set(value) {
    emit('update:user', { ...props.user, firstName: value })
  }
})

const lastName = computed({
  get() {
    return props.user.lastName
  },
  set(value) {
    emit('update:user', { ...props.user, lastName: value })
  }
})

const email = computed({
  get() {
    return props.user.email
  },
  set(value) {
    emit('update:user', { ...props.user, email: value })
  }
})

const phone = computed({
  get() {
    return props.user.phone
  },
  set(value) {
    emit('update:user', { ...props.user, phone: value })
  }
})

// 表单验证
const errors = ref({})

const validateEmail = (email) => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  return emailRegex.test(email)
}

const validatePhone = (phone) => {
  const phoneRegex = /^1[3-9]\d{9}$/
  return phoneRegex.test(phone)
}

const validate = () => {
  errors.value = {}
  
  if (!firstName.value.trim()) {
    errors.value.firstName = '请输入姓'
  }
  
  if (!lastName.value.trim()) {
    errors.value.lastName = '请输入名'
  }
  
  if (!email.value.trim()) {
    errors.value.email = '请输入邮箱'
  } else if (!validateEmail(email.value)) {
    errors.value.email = '请输入有效的邮箱地址'
  }
  
  if (!phone.value.trim()) {
    errors.value.phone = '请输入手机号'
  } else if (!validatePhone(phone.value)) {
    errors.value.phone = '请输入有效的手机号'
  }
  
  return Object.keys(errors.value).length === 0
}

const handleSubmit = () => {
  if (validate()) {
    emit('submit', props.user)
  }
}

defineExpose({
  validate,
  errors
})
</script>

<template>
  <form @submit.prevent="handleSubmit" class="user-form">
    <div class="form-row">
      <div class="form-col">
        <label>姓</label>
        <BaseInput
          v-model="firstName"
          placeholder="请输入姓"
          :error="errors.firstName"
        />
      </div>
      <div class="form-col">
        <label>名</label>
        <BaseInput
          v-model="lastName"
          placeholder="请输入名"
          :error="errors.lastName"
        />
      </div>
    </div>
    
    <label>邮箱</label>
    <BaseInput
      v-model="email"
      type="email"
      placeholder="请输入邮箱"
      :error="errors.email"
    />
    
    <label>手机号</label>
    <BaseInput
      v-model="phone"
      type="tel"
      placeholder="请输入手机号"
      :error="errors.phone"
    />
    
    <button type="submit" class="submit-btn">提交</button>
  </form>
</template>

<style scoped>
.user-form {
  max-width: 400px;
  margin: 0 auto;
  padding: 20px;
}

.form-row {
  display: flex;
  gap: 16px;
}

.form-col {
  flex: 1;
}

label {
  display: block;
  margin-bottom: 4px;
  font-weight: 500;
  color: #333;
}

.submit-btn {
  width: 100%;
  padding: 12px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
  transition: background-color 0.2s;
}

.submit-btn:hover {
  background-color: #0056b3;
}
</style>

使用示例

vue
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'
import BaseInput from './BaseInput.vue'

// 基础输入组件示例
const searchText = ref('')
const message = ref('')

// 复杂表单示例
const user = ref({
  firstName: '',
  lastName: '',
  email: '',
  phone: ''
})

const handleUserSubmit = (userData) => {
  console.log('用户数据:', userData)
  alert('表单提交成功!')
}

// 多个 v-model 示例
const firstName = ref('')
const lastName = ref('')

// 自定义修饰符示例
const capitalizedText = ref('')
</script>

<template>
  <div class="app">
    <h1>组件 v-model 示例</h1>
    
    <!-- 基础输入组件 -->
    <section>
      <h2>基础输入组件</h2>
      <BaseInput
        v-model="searchText"
        placeholder="搜索..."
        @focus="console.log('输入框获得焦点')"
        @blur="console.log('输入框失去焦点')"
      />
      <p>搜索内容: {{ searchText }}</p>
      
      <BaseInput
        v-model="message"
        placeholder="输入消息"
        :error="!message ? '消息不能为空' : ''"
      />
      <p>消息: {{ message }}</p>
    </section>
    
    <!-- 复杂表单组件 -->
    <section>
      <h2>复杂表单组件</h2>
      <UserForm
        v-model:user="user"
        @submit="handleUserSubmit"
      />
      <pre>{{ JSON.stringify(user, null, 2) }}</pre>
    </section>
    
    <!-- 多个 v-model -->
    <section>
      <h2>多个 v-model</h2>
      <UserName
        v-model:first-name="firstName"
        v-model:last-name="lastName"
      />
      <p>姓名: {{ firstName }} {{ lastName }}</p>
    </section>
  </div>
</template>

<style scoped>
.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

section {
  margin-bottom: 40px;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
}

h1, h2 {
  color: #333;
}

pre {
  background-color: #f5f5f5;
  padding: 12px;
  border-radius: 4px;
  overflow-x: auto;
}
</style>

最佳实践

1. 命名约定

  • 使用 modelValue 作为默认的 prop 名
  • 使用 update:modelValue 作为默认的事件名
  • 对于具名的 v-model,使用描述性的参数名
vue
<!-- 好的做法 -->
<CustomInput v-model="value" />
<UserForm v-model:user="userData" />
<DatePicker v-model:start-date="startDate" v-model:end-date="endDate" />

2. 类型安全

  • 为 props 和 emits 提供类型声明
  • 使用 TypeScript 获得更好的类型检查
vue
<script setup lang="ts">
interface User {
  name: string
  email: string
}

const props = defineProps<{
  modelValue: User
}>()

const emit = defineEmits<{
  'update:modelValue': [value: User]
}>()
</script>

3. 验证和错误处理

  • 在组件内部进行数据验证
  • 提供清晰的错误信息
  • 使用 computed 属性处理复杂的数据转换
vue
<script setup>
const props = defineProps({
  modelValue: String,
  required: Boolean,
  minLength: Number
})

const emit = defineEmits(['update:modelValue'])

const error = computed(() => {
  if (props.required && !props.modelValue) {
    return '此字段为必填项'
  }
  if (props.minLength && props.modelValue.length < props.minLength) {
    return `最少需要 ${props.minLength} 个字符`
  }
  return null
})
</script>

4. 性能优化

  • 避免在每次输入时都触发复杂的计算
  • 使用防抖处理频繁的输入事件
  • 合理使用 lazy 修饰符
vue
<script setup>
import { debounce } from 'lodash-es'

const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

// 防抖处理
const debouncedEmit = debounce((value) => {
  emit('update:modelValue', value)
}, 300)

const handleInput = (event) => {
  debouncedEmit(event.target.value)
}
</script>

下一步

  • 组件事件
  • 组件 Props
  • 透传 Attributes
  • 插槽

vue study guide - 专业的 Vue.js 学习平台