翻译原文:Vue.js: Using v-model with objects for custom components - Simon Kollross
组件是 Vue.js 最强大的特性,它允许你把应用拆成小的、可复用的单元,再组合成新的功能。
在构建 SimpleSell 的过程中,我需要一些自定义组件,它们内部可能包括多个 input 字段。我们希望它们像"可复用的输入控件"一样使用,同时它们的状态由一个对象提供。
v-model 的基础
你当然知道 v-model ------ 它是让你的大多数输入元素能够工作的重要属性。但你知道吗,你也可以把 v-model 作为你自己组件的接口。
例如,对普通 HTML <input>:
html
<input type="text" v-model="value">
其实 v-model 不过是下列写法的语法糖:
html
<input type="text" :value="value" @input="e => value = e.target.value">
也就是说:首先有 :value 绑定,它提供 input 的值;其次,当 input 触发 input 事件时,我们更新数据属性 value。你也可以简写为 @input="value = $event.target.value"。
在自定义组件中包裹一个 input
接下来,我们创建一个叫 CreateCustomer.vue 的小组件,它封装了一个 input 元素:
vue
<template>
<div>
<label>Name</label>
<input type="text" :value="value" @input="$emit('input', $event.target.value)">
</div>
</template>
<script>
export default {
props: ['value'],
}
</script>
也就是:我们把父组件传来的 value 绑定给内部的 text 输入框,当 input 值改变时,触发 input 事件并把当前值发回父组件。使用方式如下:
vue
<template>
<CreateCustomer v-model="name"></CreateCustomer>
</template>
<script>
import CreateCustomer from './CreateCustomer'
export default {
components: { CreateCustomer },
data() {
return {
name: 'John Doe',
}
},
}
</script>
这样就实现了自定义组件上的双向绑定。
对象 + v-model
但如果你加入多个输入字段,并希望把整个对象作为 v-model 的值 ------ 情况就复杂一些。我在一个 GitHub issue 中看到一个例子,说如果你对对象使用 v-model,每次数据更新都必须 $emit 一个"全新的对象实例"(new object),然后替换掉父组件中存储的对象。否则,由于 JavaScript 对象是以引用方式传递,会出现奇怪且难以 debug 的行为(例如 watcher 无法正确触发)。。
乍一看好像很复杂,但其实并不。你只需要对对象做一次(深/浅)克隆,对克隆后的对象修改值,然后 $emit 那个新对象即可。
举例,我们扩展 CreateCustomer 组件,加多一个 select 用于 contact type:
vue
<template>
<div>
<div>
<label>Name</label>
<input type="text" :value="value.name" @input="update('name', $event.target.value)">
</div>
<div>
<label>Type</label>
<select :value="value.type" @input="update('type', $event.target.value)">
<option value="Person">Person</option>
<option value="Company">Company</option>
</select>
</div>
</div>
</template>
<script>
export default {
props: ['value'],
methods: {
update(key, value) {
this.$emit('input', { ...this.value, [key]: value })
}
}
}
</script>
这里我们将 v-model 的值设为一个对象(例如 { name: 'John Doe', type: 'Person' })。在 template 里,对象的属性可以直接用于 :value 绑定。当某个字段变更时,我们用 object spread({ ...this.value, [key]: value })创建一个新的对象,把修改后的值设置给相应 key,然后通过 input 事件发回父组件。
使用 null 作为 v-model 值
如果父组件把 v-model 的初始值设为 null ------ 因为暂时不知道会不会使用这个表单 ------ 那么直接访问对象属性会导致错误(因为 null 没有属性)。同时,如果没有为 select 提供默认值,select 会显示一个无效的空选项。这个情况曾让我头疼。幸运的是,有人给了解决建议:使用 computed 属性,设定一个 local 变量 ------ 如果 value 存在就用它,否则返回一个带默认字段的对象。这样可以防止空指针错误。
示例简化如下:
js
computed: {
local() {
return this.value ? this.value : { type: 'Person' }
}
},
methods: {
update(key, value) {
this.$emit('input', { ...this.local, [key]: value })
}
}
然后在 template 中,都用 local 而不是 value。这样一来,当 value 为 null 时,也能优雅地使用默认值。
嵌套对象的情况
假设我们的数据模型更复杂,有 nested 对象,例如:
js
{
name: 'John Doe',
type: 'Person',
address: {
street: 'Example Street 42',
zip: '12345',
city: 'Example City'
}
}
我们可以继续用同样的方法 ------ 要么为每层嵌套创建一个子组件(比较推荐的方式),要么在一个组件里处理所有层级(较复杂但也可行)。
为了支持嵌套字段(例如 address.street),需要:
- 在 computed 的
local中,给出一个含默认值的对象结构,比如{ type: 'Person', address: {} },这样就算value是 null,也不会导致错误。 - 在更新的时候,使用深拷贝(deep clone) + 按路径设置(path setting),然后
$emit这个新对象。因为 JavaScript 本身没有深拷贝 + 路径设置,我们可以借助像 Lodash 那样的库来实现 ------ 用它的cloneDeep()和set()方法。
例如,在更新 address.street 时:
js
update(key, value) {
this.$emit('input',
tap(cloneDeep(this.local), v => set(v, key, value))
)
}
其中 tap(a, fn) 接受一个值 a 和一个函数 fn,对 a 执行 fn(修改 a),然后返回 a。这样你只需一行就能创建深拷贝、修改它、并发出新的对象。
处理包含数组的情况(如联系人列表 contacts)
如果你的数据模型中还包含数组,比如联系人列表 contacts: [ { type: 'Email', value: '...' }, ... ],你也可以用类似方式扩展组件。
- 在 computed
local属性中,为contacts字段提供一个默认值([]); - 当用户"添加"或"删除"联系人时,用深拷贝 + 修改数组(push / splice) +
$emit。这样父组件收到的是一个新的对象,其中包含更新后的数组。
需要注意的是,对于仅仅是组件内部状态(例如"当前正在输入的新联系人 type/value"),你可以继续使用本地 data + v-model,而不需要每次输入就发出 input 事件。只有当用户点击 "Add" / "Remove" 时才发出。
在 created 钩子里发出初始状态
有时候你可能希望在组件创建时就将默认状态(例如带默认字段、默认数组、默认空联系人)发回给父组件。你可以在 created() 钩子里调用一次 update 方法(或直接 $emit)。但要注意,只发出一次,并在同一次调用中设置所有默认值,以免触发多次 watcher,导致不必要的副作用或时序问题。
总结
恭喜你读到这里!这篇文章比我预期的要长得多 😄
当然,并不是你构建的每个组件都要用上这些模式。有时把复杂组件分解成更小、更好理解、更易维护的子组件反而更好。
但如果你确实需要"可复用的、带简单 v-model 接口的复杂输入控件"(例如表单子组件、多字段 + 嵌套对象 + 数组......这种场景),考虑这些技巧是很有帮助的 ------ 深拷贝 + 每次 emit 新对象 + default 值 / fallback + 避免 null,就能让一切更稳健、更可预测。