⭐ 推荐:社区规则条款 V1.0

Vue中的v-model自定义组件:从原理到实践,实现一个支持v-model的复杂组件。

dp32323
dp32323 正式会员正式会员
发布于 2026-08-03 20:23 ·95 浏览 ·0 回复
内容摘要

梳理 Vue 自定义组件中 v-model 的底层机制,即 value prop 与 input 事件(或 model 选项,Vue 3 为 modelValue/update:modelValue)的配合,并以支持远程搜索、多选、可清空的 Vue 2 下拉选择器为例,说明如何设计 prop 和事件、处理交互,避免 blur 先于 click 关闭下拉等坑,从而在复杂组件中正确实现双向绑定。

再谈 v-model:它不只是语法糖

用过 Vue 的同学都知道,v-model 在表单元素上就是 value + input 事件的语法糖。可一旦到了自定义组件里,很多人就开始懵了:为什么我写了 props: ['value'],也 $emit('input') 了,双向绑定还是不好使?更别提遇到 checkbox、radio、select 或者需要多个值的场景了。

其实,v-model 的原理并不复杂,但真正把它用到复杂组件里,你会发现有不少“坑”。这篇文章从底层原理出发,结合一个实际的复杂组件案例,帮你彻底吃透它。

从源码角度看 v-model 的本质

我们先看一个最简单的例子:

<input v-model="message" />

编译器会把这段模板编译成:

h('input', {
  value: message,
  onInput: ($event) => {
    message = $event.target.value
  }
})

所以 v-model 本质上就是:父组件传一个 value prop,子组件抛一个 input 事件,父组件监听事件并更新自己的数据。看起来很简单,对吧?

但如果你在自定义组件上使用 v-model:

<my-input v-model="message" />

那么 MyInput 内部必须:

  1. 接收名为 value 的 prop
  2. 在需要更新时,$emit('input', newValue)

注意,这里 $emit 的第二个参数会作为事件回调的第一个参数,也就是 $event。和原生 input 不同,自定义组件的 $event 可以是任意值,不必是 event.target.value。

从 value/input 到 model 选项

如果组件里的 prop 和事件不想叫 value 和 input,或者一个组件需要支持多个双向绑定值,Vue 2.2+ 提供了 model 选项:

Vue.component('my-checkbox', {
  model: {
    prop: 'checked',
    event: 'change'
  },
  props: {
    checked: Boolean
  },
  methods: {
    handleChange($event) {
      this.$emit('change', $event.target.checked)
    }
  },
  template: `
    <input
      type="checkbox"
      :checked="checked"
      @change="handleChange"
    />
  `
})

这给了我们灵活性,但同时也意味着编写复杂组件时,需要明确约定哪些 prop 是可变的、哪些事件对应哪个 prop。

实战:一个支持 v-model 的复杂下拉选择器

理论说够了,直接上代码。我们要实现一个“远程搜索 + 多选 + 可清空”的下拉选择器,并且让它支持 v-model。这里用 Vue 2 语法(Vue 3 中 v-model 改为 modelValue / update:modelValue,但思路一致)。

先看组件定义:

<template>
  <div class="complex-select">
    <div class="tags" v-if="selectedItems.length">
      <span v-for="item in selectedItems" :key="item.value" class="tag">
        {{ item.label }}
        <button type="button" @click="removeItem(item)">×</button>
      </span>
    </div>
    <input
      type="text"
      :value="searchText"
      @input="handleSearch"
      @focus="open"
      @blur="delayClose"
      placeholder="输入关键词搜索"
    />
    <ul v-show="isOpen" class="dropdown">
      <li
        v-for="opt in filteredOptions"
        :key="opt.value"
        :class="{ disabled: isSelected(opt) }"
        @mousedown.prevent="toggleSelect(opt)"
      >
        {{ opt.label }}
      </li>
      <li v-if="!filteredOptions.length" class="empty">暂无数据</li>
    </ul>
  </div>
</template>

这里我故意使用了 mousedown.prevent 而不是 click,因为 blur 事件会先于 click 触发,下拉列表在点击前就关掉了。加上 .prevent 后,mousedown 不会让 input 失去焦点,再用 mousedown 去处理选择,就能避免这个经典坑。

接下来是 script 部分:

<script>
export default {
  name: 'ComplexSelect',
  // 默认 model 指向 value/input,这里不显式定义也可以
  // 但为了清晰,可以这样写:
  model: {
    prop: 'value',
    event: 'change'
  },
  props: {
    // v-model 绑定的值,这里要求是一个数组,即选中的 item 列表
    value: {
      type: Array,
      default: () => []
    },
    options: {
      type: Array,
      default: () => []
    }
  },
  data() {
    return {
      searchText: '',
      isOpen: false,
      // 内部维护的选中项,初始化为 value 的拷贝
      selectedItems: [...this.value]
    }
  },
  computed: {
    filteredOptions() {
      const keyword = this.searchText.trim().toLowerCase()
      if (!keyword) return this.options
      return this.options.filter(opt => opt.label.toLowerCase().includes(keyword))
    }
  },
  watch: {
    // 父组件若重置 value,内部也要同步
    value(newVal) {
      this.selectedItems = [...newVal]
    }
  },
  methods: {
    handleSearch(e) {
      this.searchText = e.target.value
      this.isOpen = true
    },
    open() {
      this.isOpen = true
      this.searchText = ''
    },
    delayClose() {
      setTimeout(() => {
        this.isOpen = false
      }, 100)
    },
    isSelected(opt) {
      return this.selectedItems.some(item => item.value === opt.value)
    },
    toggleSelect(opt) {
      if (this.isSelected(opt)) {
        this.removeItem(opt)
      } else {
        const newItems = [...this.selectedItems, opt]
        this.updateValue(newItems)
      }
    },
    removeItem(item) {
      const newItems = this.selectedItems.filter(i => i.value !== item.value)
      this.updateValue(newItems)
    },
    updateValue(newItems) {
      this.selectedItems = newItems
      // 向外提交 change 事件,而不是 input
      this.$emit('change', newItems)
    }
  }
}
</script>

核心就在于 updateValue 方法:它同时更新内部数据并向外 $emit('change'),这样父组件用 v-model 绑定一个 selected 数组时,就能实现双向同步。

父组件用法:

<template>
  <complex-select
    v-model="selectedList"
    :options="allOptions"
  />
</template>

<script>
export default {
  data() {
    return {
      selectedList: [],
      allOptions: [
        { label: 'JavaScript', value: 1 },
        { label: 'Python', value: 2 },
        { label: 'Vue', value: 3 },
        { label: 'React', value: 4 }
      ]
    }
  }
}
</script>

是不是很清爽?组件的内部细节都被封装起来了,父组件只管通过 v-model 读写数据。

Vue 3 中的变化

Vue 3 把 v-model 的默认 prop 换成了 modelValue,默认事件换成了 update:modelValue,并且支持在同一个组件上用多个 v-model:

```vue
<!-- 子组件 -->
<el-input :modelValue="modelValue" @update:modelValue="updateValue" />

<!-…

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-193.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~