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

阿乐
阿乐 管理员 黑卡会员
发布于 2026-09-10 04:40 ·12 浏览 ·0 回复

再谈 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" />

<!-…

本文转载自 阿乐技术社区,原文地址:https://www.leleweb.cn/thread-193.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~