Vue中的v-model自定义组件:从原理到实践,实现一个支持v-model的复杂组件。
再谈 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" />
<!-…
转载请注明出处,版权归原作者所有。
管理员
黑卡会员