【译】Vuejs: 使用带有对象的 v-model 来创建自定义组件

翻译原文: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。这样一来,当 valuenull 时,也能优雅地使用默认值。

嵌套对象的情况

假设我们的数据模型更复杂,有 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,就能让一切更稳健、更可预测。

相关推荐
计算机学姐2 小时前
基于Python的高校后勤报修系统【2026最新】
开发语言·vue.js·后端·python·mysql·django·flask
我来变强了2 小时前
无法正确访问 Vue 实例的属性
前端·javascript·vue.js
qixingchao2 小时前
VUE Pinia 官方首推的数据状态管理库
前端·javascript·vue.js
凌波粒2 小时前
CSS基础详解(2)--Grid网格布局详解
前端·css·css3·html5
飛6792 小时前
Flutter 状态管理深度实战:从零封装轻量级响应式状态管理器,告别 Provider/Bloc 的臃肿与复杂
前端·javascript·flutter
汝生淮南吾在北2 小时前
SpringBoot3+Vue3新闻动态网站
前端·javascript·vue.js·spring boot·毕业设计·毕设
LYFlied2 小时前
Vue Router 监听地址变化的核心逻辑示意
前端·javascript·vue.js·vue router·前端路由·源码理解
web守墓人2 小时前
【前端】rspack和rsbuild的关系
前端