Code前端首页关于Code前端联系我们

Vue3的v-model到底怎么用?和Vue2有啥本质区别?

terry 7天前 阅读数 966 #Vue

很多从Vue2转过来的开发者,或者刚接触Vue3的前端新人,打开官方文档看到v-model的介绍时,都会有点懵:一会儿是v-model可以直接绑多个了,一会儿是modelValue和update:modelValue的命名,一会儿又冒出来defineModel这个新语法糖——这些到底是怎么回事?今天咱们就从最基础的用法开始,一点一点聊透Vue3的v-model,帮你彻底搞懂它的逻辑,以及它和Vue2相比的变化到底在哪里。

先理清楚:v-model的本质从来没变过

不管是Vue2还是Vue3,v-model都不是什么“魔法指令”,它永远只是一个语法糖组合,这句话一定要记牢,很多困惑解开的前提就是理解这个底层逻辑。

那它组合的是什么呢?在Vue3里,默认情况下是:

  • 给子组件(或者原生表单控件,但我们今天主要聊组件,因为原生控件的变化小很多)传入一个叫 modelValue 的 prop
  • 给子组件绑定一个叫 update:modelValue 的自定义事件
  • 当子组件内部触发 update:modelValue 事件时,会自动把事件的第一个参数赋给父组件绑定的变量

举个最简单的例子你就懂了,假设我们有一个父组件,要绑定一个搜索关键词给子组件的输入框:

不用v-model的原生写法(帮你还原语法糖)

<!-- 父组件 -->
<template>
  <div class="search-box">
    <!-- 这行是原生写法,没有用v-model -->
    <SearchInput 
      :modelValue="searchKeyword" 
      @update:modelValue="searchKeyword = $event" 
    />
    <p>当前搜索词:{{ searchKeyword }}</p>
  </div>
</template>
<script setup>
import { ref } from 'vue'
import SearchInput from './SearchInput.vue'
const searchKeyword = ref('')
</script>
<!-- 子组件 SearchInput.vue -->
<template>
  <div class="input-wrapper">
    <label>搜索:</label>
    <!-- 子组件拿到modelValue,然后当输入改变时,触发update:modelValue事件 -->
    <input 
      type="text" 
      :value="modelValue" 
      @input="handleInputChange" 
    />
  </div>
</template>
<script setup>
// 显式声明接收的prop
const props = defineProps({
  modelValue: String
})
// 显式声明要触发的事件
const emit = defineEmits(['update:modelValue'])
const handleInputChange = (e) => {
  // 事件的第一个参数就是父组件要接收的新值
  emit('update:modelValue', e.target.value)
}
</script>

用v-model的简化写法

上面那段父组件的代码,把两个绑定合并成一个v-model就行,效果完全一样:

<!-- 父组件简化后 -->
<template>
  <div class="search-box">
    <SearchInput v-model="searchKeyword" />
    <p>当前搜索词:{{ searchKeyword }}</p>
  </div>
</template>
<!-- 子组件不用改 -->

是不是瞬间清爽了?这就是v-model最核心的作用——帮你省掉写prop和emit绑定的两行代码,原生表单控件的逻辑也差不多,只是默认的prop和事件不一样,比如原生text输入框是:value和@input,checkbox是:checked和@change这些,Vue内部帮你做了对应,但核心语法糖逻辑是通的。

对比Vue2:到底有哪些变了?

知道了本质没变,接下来聊大家最关心的——变化部分,其实Vue2的v-model也有语法糖,但当时有三个明显的限制,Vue3都给解决了,而且还优化了命名方式,让逻辑更清晰。

变化1:组件可以绑定多个v-model了!

这应该是Vue3 v-model最大的功能升级,没有之一。

在Vue2里,一个组件只能有一个v-model,因为当时默认的prop名是value,默认的事件名是input,而且没有办法给不同的v-model起不同的“别名”,如果想让一个组件同时双向绑定两个值(比如一个弹窗组件,既要双向绑定“是否打开”,又要双向绑定“弹窗标题”),Vue2的做法只能是:主功能用v-model,副功能自己显式写prop+emit,或者用.sync修饰符。

但.sync修饰符在Vue3里被彻底移除了(别慌,它的功能被完全整合进了v-model里),现在Vue3支持给v-model加“参数”,每个参数对应一个不同的双向绑定,自然就能绑多个了。

还是用弹窗组件举例子,这个场景太常见了:

<!-- 父组件 绑定两个v-model -->
<template>
  <div class="container">
    <button @click="isModalOpen = true">打开弹窗</button>
    <!-- 第一个v-model是默认的,没有参数,对应modelValue/update:modelValue -->
    <!-- 第二个v-model加了:title参数,对应title/update:title -->
    <CustomModal 
      v-model="isModalOpen" 
      v-model:title="modalTitle" 
    >
      <p>这是弹窗的内容,标题会和下面的输入框联动哦!</p>
    </CustomModal>
    <br>
    <input type="text" v-model="modalTitle" placeholder="修改弹窗标题试试">
  </div>
</template>
<script setup>
import { ref } from 'vue'
import CustomModal from './CustomModal.vue'
const isModalOpen = ref(false)
const modalTitle = ref('默认弹窗标题')
</script>
<!-- 子组件 CustomModal.vue -->
<template>
  <!-- 只有isModalOpen为true时才显示弹窗 -->
  <div v-if="modelValue" class="modal-mask" @click.self="handleClose">
    <div class="modal-content">
      <div class="modal-header">
        <!-- 这里用了带参数的title prop -->
        <h3>{{ title }}</h3>
        <button @click="handleClose">×</button>
      </div>
      <div class="modal-body">
        <!-- 插槽放父组件传的内容 -->
        <slot></slot>
      </div>
    </div>
  </div>
</template>
<script setup>
// 显式声明两个prop:一个默认的modelValue,一个带参数的title
const props = defineProps({
  modelValue: Boolean, String
})
// 显式声明两个事件:一个默认的update:modelValue,一个带参数的update:title
const emit = defineEmits(['update:modelValue', 'update:title'])
const handleClose = () => {
  // 触发关闭事件,把modelValue设为false
  emit('update:modelValue', false)
}
</script>
<style scoped>
/* 简单写个弹窗样式,方便测试 */
.modal-mask {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}
.modal-content {
  background-color: white;
  padding: 20px;
  border-radius: 8px;
  min-width: 300px;
}
.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  border-bottom: 1px solid #eee;
  margin-bottom: 15px;
  padding-bottom: 10px;
}
.modal-header button {
  border: none;
  background: none;
  font-size: 24px;
  cursor: pointer;
}
</style>

你可以把这段代码复制到Vue3项目里跑一下,点击“打开弹窗”按钮能显示,点击×或遮罩层能关闭;在父组件下面的输入框修改文字,弹窗标题会同步变——两个v-model都完美工作,完全不需要以前的.sync,也不用自己记哪个是主哪个是副,逻辑非常直观。

变化2:默认的prop和事件名变了,但更合理了

刚才的例子里你也看到了,Vue3默认的prop是modelValue,事件是update:modelValue,而Vue2默认的是valueinput

为什么要改呢?很多人一开始觉得这是个“没必要的小改动”,但其实背后有个很好的理由:在Vue2里,如果你的组件主功能不是输入类的(比如刚才的弹窗组件,主功能是“打开/关闭”),用value作为prop名就会很奇怪——“关闭弹窗的value设为false?”,语义完全不通。

改成modelValue之后,不管组件是什么类型,这个prop的语义都是“v-model绑定的那个值”,不会和业务逻辑里的“value”混淆了;事件名改成update:propName的格式,不管是默认的还是带参数的,都一眼能看出来是“更新哪个prop的事件”,代码的可读性大大提高。

如果你习惯了Vue2的命名,或者项目里要兼容旧的子组件代码,Vue3也允许你修改默认的prop和事件名吗?不对,等一下——Vue3的组件选项API里(就是不用<script setup>的写法),确实还有model配置项,功能和Vue2的一样,可以改默认的prop和事件,但在<script setup>里,官方推荐直接用带参数的v-model来代替修改默认配置,因为这样更灵活,也不需要额外的配置项。

变化3:新增了defineModel语法糖,写子组件更爽了

刚才的子组件代码,我们都是先写defineProps接收值,再写defineEmits声明事件,然后写个处理函数(或者直接在模板里写@input="emit('update:modelValue', $event.target.value)")——虽然不算麻烦,但如果是一个简单的双向绑定组件,还是有点冗余。

于是Vue3.4版本(注意是3.4及以上,老版本用不了)新增了一个叫defineModel的宏(Macro),它可以把definePropsdefineEmits和“双向绑定的变量”整合在一起,写起来就像在子组件里直接操作父组件的变量一样,特别丝滑。

还是用最开始的SearchInput组件举例子,用defineModel重写一下子组件:

<!-- 子组件 SearchInput.vue 用defineModel重写 -->
<template>
  <div class="input-wrapper">
    <label>搜索:</label>
    <!-- 这里可以直接用v-model绑定model,不需要写:value和@input了! -->
    <input type="text" v-model="model" />
  </div>
</template>
<script setup>
// 直接用defineModel宏,不需要defineProps和defineEmits
// 默认情况下,它会自动创建modelValue prop和update:modelValue事件
const model = defineModel()
</script>

哇,是不是太简洁了?而且父组件的代码完全不用改,还是用v-model="searchKeyword"就行。

那如果是带参数的v-model呢?比如刚才的CustomModal组件,我们可以给defineModel传一个参数,对应v-model的参数:

<!-- 子组件 CustomModal.vue 用defineModel重写 -->
<template>
  <div v-if="isOpen" class="modal-mask" @click.self="handleClose">
    <div class="modal-content">
      <div class="modal-header">
        <h3>{{ modalTitle }}</h3>
        <button @click="handleClose">×</button>
      </div>
      <div class="modal-body">
        <slot></slot>
      </div>
    </div>
  </div>
</template>
<script setup>
// 带参数的defineModel:第一个参数是v-model的参数('open'对应v-model:open)
// 第二个参数是prop的配置项,和defineProps的配置一样
const isOpen = defineModel('open', { type: Boolean, default: false })
const modalTitle = defineModel('title', { type: String, default: '默认标题' })
const handleClose = () => {
  // 直接给isOpen赋值就行!不需要emit了!
  isOpen.value = false
}
</script>

对应的父组件代码,要把默认的v-model="isModalOpen"改成v-model:open="isModalOpen",因为我们传了'open'参数:

<!-- 父组件调整一下,对应defineModel的参数 -->
<template>
  <div class="container">
    <button @click="isModalOpen = true">打开弹窗</button>
    <CustomModal 
      v-model:open="isModalOpen" 
      v-model:title="modalTitle" 
    >
      <p>这是用defineModel重写的弹窗!</p>
    </CustomModal>
    <br>
    <input type="text" v-model="modalTitle" placeholder="修改标题">
  </div>
</template>

这里要注意一个细节:defineModel返回的是一个ref,所以在<script setup>的逻辑代码里(比如handleClose函数),要加.value才能访问或修改;但在模板里,和普通的ref一样,不用加.value

还有,如果你想给defineModel加验证规则,或者设置default值,直接在第二个参数里写就行,和defineProps的配置项完全一样,非常方便。

原生表单控件的v-model有变化吗?

刚才聊的都是组件的v-model,可能有新手会问:那原生的input、checkbox这些,v-model的用法变了吗?

答案是:几乎没有变化,和Vue2的用法完全一样,你之前怎么用的,现在还怎么用。

  • text、textarea用v-model="xxx",绑定的是输入的字符串
  • checkbox单个用v-model="xxx",绑定的是布尔值;多个用v-model="xxxArray",绑定的是选中值的数组
  • radio用v-model="xxx",绑定的是选中的value
  • select单选绑定的是选中的option的value或内容,多选绑定的是数组

唯一的小变化是:Vue3移除了.sync修饰符,但原生表单控件本来就不用.sync,所以完全不受影响。

实际开发中,Vue3 v-model有哪些使用技巧?

聊完了基础用法和变化,再给大家分享几个实际开发中常用的小技巧,能帮你少走弯路。

技巧1:用defineModel的get和set做值的转换

父组件传过来的值和子组件需要的格式不一样,比如父组件存的是时间戳,子组件的输入框需要显示“YYYY-MM-DD”格式的日期,这时候,我们可以给defineModel传第三个参数,里面写get和set函数,做自动转换。

举个日期选择组件的例子:

<!-- 子组件 DatePicker.vue -->
<template>
  <div class="date-picker">
    <label>选择日期:</label>
    <input type="date" v-model="dateStr" />
  </div>
</template>
<script setup>
// defineModel的第三个参数是transform,里面有get和set
const dateStr = defineModel({
  type: Number, // 父组件传的是时间戳(Number类型)
  get: (timestamp) => {
    // get函数:把父组件的时间戳转成YYYY-MM-DD格式,给子组件的input用
    if (!timestamp) return ''
    const date = new Date(timestamp)
    const year = date.getFullYear()
    const month = String(date.getMonth() + 1).padStart(2, '0')
    const day = String(date.getDate()).padStart(2, '0')
    return `${year}-${month}-${day}`
  },
  set: (str) => {
    // set函数:把子组件input的YYYY-MM-DD格式转成时间戳,传给父组件
    if (!str) return 0
    return new Date(str).getTime()
  }
})
</script>
<!-- 父组件 -->
<template>
  <div class="container">
    <DatePicker v-model="selectedTimestamp" />
    <p>选中的时间戳:{{ selectedTimestamp }}</p>
    <p>转成日期后:{{ new Date(selectedTimestamp).toLocaleString() }}</p>
  </div>
</template>
<script setup>
import { ref } from 'vue'
import DatePicker from './DatePicker.vue'
// 父组件存的是时间戳
const selectedTimestamp = ref(new Date('2024-05-01').getTime())
</script>

这个技巧特别实用,再也不用在父组件或者子组件里单独写转换函数了,defineModel自动帮你搞定。

技巧2:绑定多个v-model时,注意参数的命名规范

带参数的v-model参数名,官方没有强制要求,但建议用小驼峰(camelCase)或者短横线(kebab-case),和prop的命名规范一致。

  • 推荐:v-model:userName 或者 v-model:user-name
  • 不推荐:v-model:UserName 或者 v-model:user_name

因为Vue的模板是不区分大小写的(DOM的attribute都是小写的),如果用大驼峰(PascalCase)或者下划线,虽然代码能跑,但可能会有一些隐性的问题,而且不符合Vue的代码风格。

子组件的defineModel参数名,要和父组件v-model的参数名完全一致(短横线的话,defineModel里要转成小驼峰吗?不对,试一下:比如父组件用v-model:user-name,子组件defineModel传'userName'或者'user-name'都可以,因为Vue内部会自动处理短横线和小驼峰的转换,但为了代码风格统一,建议父组件用短横线,子组件defineModel传小驼峰,或者都用小驼峰)。

技巧3:在选项API里也可以用类似defineModel的逻辑吗?

虽然<script setup>是Vue3官方推荐的写法,但有些老项目可能还在使用选项API(就是有data、methods、computed这些选项的写法),那选项API里怎么实现类似defineModel的简洁逻辑呢?

其实很简单,用computed属性的get和set就行:

<!-- 子组件 SearchInput.vue 选项API写法 -->
<template>
  <div class="input-wrapper">
    <label>搜索:</label>
    <input type="text" v-model="localModel" />
  </div>
</template>
<script>
export default {
  props: {
    modelValue: String
  },
  emits: ['update:modelValue'],
  computed: {
    localModel: {
      get() {
        return this.modelValue
      },
      set(val) {
        this.$emit('update:modelValue', val)
      }
    }
  }
}
</script>

这个逻辑和defineModel的底层逻辑是一样的,defineModel本质上就是帮你自动生成了这样一个computed属性,所以如果是选项API的项目,用computed的get和set就对了。

Vue3 v-model的核心要点

咱们把今天聊的内容总结成几个核心要点,方便你记忆:

  1. 本质永远是语法糖:不管怎么变,v-model都是“prop + 自定义事件”的组合,默认是modelValue和update:modelValue。
  2. 支持多v-model绑定:用带参数的v-model,比如v-model:title,每个参数对应一个prop和事件。
  3. Vue3.4+用defineModel更爽:整合了defineProps、defineEmits和computed,写子组件特别简洁,还支持get和set做值转换。
  4. 原生表单控件用法不变:和Vue2一样,不用改任何代码。
  5. 选项API用computed的get和set:老项目也能实现简洁的双向绑定。

现在再回头看Vue3的官方文档,是不是觉得清晰多了?其实Vue3的v-model并没有变得更复杂,反而把Vue2里零散的功能(比如单v-model、.sync)整合在了一起,逻辑更统一,用法更灵活,只要你记住了“语法糖”这个本质,不管以后Vue怎么更新,v-model的核心逻辑都不会变。

如果你还有其他关于Vue3 v-model的问题,或者想了解更多Vue3的新特性,欢迎在评论区留言,咱们一起讨论!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门