为什么v-model绑定的对象属性不是总是响应式?

v-model 仅仅只是 v-bind:value="value"@input="value = $event.target.value" 的语法糖吗?

一直以来我都喜欢在Vue中声明对象变量的时候给变量赋值一个空对象,而不是把所有的属性值一起声明好。
比如说:

// vue2.x form demo
export default {
  data() {
    return {
      // 我喜欢这样声明
      formData: {},
      // 而不是这样在声明时把所有的属性一起书写好 
      formData: {
        username: '',
        password: ''
      }
    }
  }
}

也可以从我之前关于 props 属性的笔记中察觉到 👉 Vue中接收没有声明的Prop属性[null/undefined]的接收问题

因为我在写业务的时候如果 props 的属性值不存在,传入的会是一个 undefined 而不是 null 或者 '' 这样的预设。因为我期望使用组件 props 中声明的默认值,而不是在外部预设的空值
所以一直以来我都在和小伙伴交流的时候也是推荐直接给变量一个空对象即可,除了一些数组属性需要单独声明。

但前段时间一位小伙伴向我提问:他在组件生命周期函数中给表单属性赋值了一个预设值,后续再使用 v-model 对表单属性修改时发现丢失了响应……

<!-- 丢失响应的最小复现 -->
<template>
  <form>
    <input v-model="formData.username" />
    <span>{{ formData.username }}</span>
  </form>
</template>
<script>
export default {
  data() {
    return {
      formData: {},
    }
  },
  created() {
    this.formData.username = 'admin'
  }
}
</script>

按照思维惯性,我就理所当然的说,如果是变量是属性数组的话,你需要通过 $set 去修改内部的值,一般遇到失去响应都是错误操作数组导致的。但是他说原因不是这个,他自己写了一个最小实现,赋值和修改的都是字符串也会丢失响应。就和我贴在上面的示例一样,就是简单直接的赋值操作。

那就很奇怪了,在我的理解里应该是不会丢失响应式的,所以我就去看了一下我业务中和他类似的操作:

export default {
  data() {
    return {
      formData: {},
    }
  },
  created() {
    this.formData = {
      username: 'admin'
    }
  }
}

可以看到区别在于我是给 formData 整个重新赋值了,而小伙伴是单独给 formData.username 进行赋值操作,所以问题就是出现在这里。按照Vue手册中的说法是:

Vue 无法检测 property 的添加或移除。

那么如何添加对象属性呢?

对于已经创建的实例,Vue 不允许动态添加根级别的响应式 property。但是,可以使用 Vue.set(object, propertyName, value) 方法向嵌套对象添加响应式 property。例如,对于:

Vue.set(vm.someObject, 'b', 2)

您还可以使用 vm.$set 实例方法,这也是全局 Vue.set 方法的别名:

this.$set(this.someObject,'b',2)

有时你可能需要为已有对象赋值多个新 property,比如使用 Object.assign()_.extend()。但是,这样添加到对象上的新 property 不会触发更新。在这种情况下,你应该用原对象与要混合进去的对象的 property 一起创建一个新的对象。

// 代替 `Object.assign(this.someObject, { a: 1, b: 2 })`
this.someObject = Object.assign({}, this.someObject, { a: 1, b: 2 })

所以我给 formData 整体赋值方式可以,但是小伙伴单独给 formData 添加 username 不可以。小伙伴调整为 $set() 来赋值方式解决了问题。

但是小伙伴随后就问了第二个问题:为什么我们在 data 中声明 formData 为空对象 {},但是在模板中直接使用 v-model 进行双向绑定时不会丢失响应式呢?
比如说:

<template>
  <form>
    <input v-model="formData.username" />
    <span>{{ formData.username }}</span>
  </form>
</template>
<script>
export default {
  data() {
    return {
      formData: {},
    }
  }
}
</script>

这就问到我的知识盲区了,所以去翻了一下 Vue 仓库中关于 v-model 部分的源码 👉 vue/src/compiler/directives/model.ts at master · vuejs/vue

export function genAssignmentCode(value: string, assignment: string): string {
  const res = parseModel(value)
  if (res.key === null) {
    return `${value}=${assignment}`
  } else {
    return `$set(${res.exp}, ${res.key}, ${assignment})`
  }
}

可以看到其实内部是使用了 $set 去赋值了,而不是简单的使用赋值运算符(=)去赋值。至此,全部的疑惑都解开了。
也纠正了我的一个错误理解,之前一直以为我们声明的变量被 Vue 在监听后,变量属性数量改变时也会触发更新的。但其实并不是,单纯只是因为自己的操作习惯正好命中了正确的添加属性操作。所以并不能简单的通过检查变量是否被挂载 ob 来判断是否具有响应式。


相关链接

深入响应式原理 — Vue.js
表单输入绑定 | Vue.js
vue/src/compiler/directives/model.ts at master · vuejs/vue