vuejs中怎么给vm实例动态添加响应式属性?

vue.js官网中相关章节是这么解释的:
链接为:https://cn.vuejs.org/v2/guide...

有时你可能需要为已有对象赋予多个新属性,比如使用 Object.assign() 或
_.extend()。在这种情况下,你应该用两个对象的属性创建一个新的对象。所以,如果你想添加新的响应式属性,不要像这样:

Object.assign(vm.userProfile, {
  age: 27,
  favoriteColor: 'Vue Green'
})

你应该这样做:

vm.userProfile = Object.assign({}, vm.userProfile, {
  age: 27,
  favoriteColor: 'Vue Green'
})

我用代码测试了一下,确实如此,但不明白为什么会这样,很奇怪,来个大神给解释下吧。

阅读 5.7k
5 个回答

两种写法的userProfile得到的结果虽然是一样的。
还是官网那个页面,前面有提到--- 对于已经创建的实例,Vue 不能动态添加根级别的响应式属性

var vm = new Vue({
  data: {
    a: 1
  }
})
// `vm.a` 现在是响应式的

vm.b = 2
// `vm.b` 不是响应式的

问题中的第一种写法相当于vm.b = 2,对于已经创建的实例userProfile,对于在userProfile上添加属性,Vue不能动态的检测到。
第二种写法相当于vm.a = XX,先把两个对象的属性赋给一个空的对象,然后再把这个对象赋给userprofile, 这是直接为根级别的对象重新赋值,这与对对象属性的添加与删除本质是不一样的。这是我的想法。

赞同上一楼,其实已经解释得差不多了,我再补充一些Object.assign的相关知识吧

if (desc !== undefined && desc.enumerable) {
            to[nextKey] = nextSource[nextKey];
          }

从Object的polyfill实现可以看出,属性的复制通过赋值实现(这样子做的缺点在于只有根属性是深拷贝,其他都是浅拷贝)
这样子的写法其实和vm.b = 2没有什么差别,都属于在已经创建的实例上面对其添加属性,而Vue并没有对这些属性执getter/setter 转化过程,所以也无法做到的这些属性的数据双向绑定,而为什么对vm.userProfile赋值可以触发这个机制,没有看过源码暂不得知。
附上官方的说明:

Vue 不允许在已经创建的实例上动态添加新的根级响应式属性 (root-level reactive property)。然而它可以使用 Vue.set(object, key, value) 方法将响应属性添加到嵌套的对象上。
有时你想向已有对象上添加一些属性,例如使用 Object.assign() 或 _.extend() 方法来添加属性。但是,添加到对象上的新属性不会触发更新。在这种情况下可以创建一个新的对象,让它包含原对象的属性和新的属性。

你这个问题问得非常好,我之前都没注意到这个细节。。为了解答你的问题,我从Vue的原理开始解释可能会更好地发现这个原因:
PS:可以关注我的文章Vue原理剖析,自己写个vue更加容易理解

我们都知道Vue的响应式原理用到了Object.defineProperty这个属性,我们可以模仿Vue写一个定义响应式的方法:

function defineReactive (obj, key, value){
  Object.defineProperty(obj,key,{
    get:function(){
      console.log("get了值"+JSON.stringify(value));
      return value;//获取到了值
    },
    set:function(newValue){
      if(newValue === value){
        return;//如果值没变化,不用触发新值改变
      }
      value = newValue;//改变了值
      console.log("set了最新值"+JSON.stringify(value));
    }
  })
}

上面的方法,我们可以看到它的原理是通过Object.defineProperty 声明Vue的data实例来声明响应式,我们可以在其中拦截到这个getset的方法。
那么,这个时候我们声明一个对象obj:

let obj = {
    userProfile: {
        name: '我是姓名'
    }
}

你可以把这个obj理解成vue里面的data属性。
然后,我们声明响应式:

Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key]);
})

如果采用第一个方法:

Object.assign(obj.userProfile, {
  age: '新添加了属性age'
})
obj.userProfile.age = '改了年龄这个属性';

我们可以发现输出的结果是这样的:
图片

我们可以发现,只有拦截到它的get方法,并没有触发set方法!也就是没有触发响应式
但是!当我们用第二种方法的时候:

obj.userProfile = Object.assign({}, obj.userProfile, {
  age: '新添加了属性age'
});
obj.userProfile.age = '改了年龄这个属性';

你会发现输出是这样的:
图片描述

这时候惊喜的发现,触发了set方法,达到了响应式的目的
所以根据我的结论:由于Object.defineProperty这个方法的特殊性,所以导致响应式数据的内部深度属性监听实现有点麻烦,所以用上述其他几位回答的原理,可以达到新对象直接赋值的目的,从而间接实现了响应式

测试响应式,F12控制台输出查看


编辑了,原来举的例子是错误的,官方声明如下:
图片描述

根本原因是vue使用Object.defineProperty()重新s定义vue的实例对象vue源码,它跟普通的对象就有了区别(这里使用了发布订阅者模式,而不同推向没有这个),你像操作普通对象那样操作vue实例对象,肯定不合适了。而vue给了我们api Vue.$set,Vue.$delete来操作属性,它们继承了Vue.prototype。而你说的 Object.assign({}, vm.userProfile,{...}) 就是让一个普通对象拥有了vue实例对象的响应式特点,关键是你没明白 Object.assign() 的特点, MDN.aspx)。

看你还没有采纳上述诸多答案,似乎题主没有看到自己想要的答案,那我也来凑凑数。

从题主提出的问题来看,题主也是个好奇宝宝,而我也觉得有点意思,顺道也看了上述大佬的一些回答,不过我不解释原理,请跟我来。

写在前面

本次作答调试所用的项目fork自 @安静的木马 分享的项目
https://codepen.io/rushui/ful...
https://codepen.io/rushui/ful...
只是做了少许改动,为了调试,加了debugger且修改了引入的vue.js。

开始调试第一个链接

当你打开上述俩个链接的控制台并刷新页面时,你便进入了我设置的断点处。
我们先进行第一个 https://codepen.io/rushui/ful... 的调试

clipboard.png

先看这种情况进入断点会被vue怎么处理,

进入vm.userProfile的代理getter

clipboard.png

这里,sourceKey是_data, key是userProfile, sharedPropertyDefinition可以理解为vue处理数据的一个代理对象。
最终代理getter取了vm["_data"]["userProfile"]的对象值,值得注意的是,这个对象值里面是只有在初始化实例的时候定义的name,以及vue赋予的可响应式getter和setter。

进入vm.userProfile的getter

接下来就会进入userProfile的可响应式getter中,如图

clipboard.png

最终返回了这个对象,这是Object.assign中参数vm.userProfile的读取就完成了

进入vm.userProfile的setter

下面该是Object.assign执行之后,进入vm.userProfile的setter了
关键的地方在这里,请看下图

clipboard.png

大家看到了吗,新的值对象newVal和旧的值对象val是不一样的,并且vue在这里做了检测,如果这俩个值一样(并处理了NaN这个诡异的东东了,作者还是非常谨慎的,??),那就直接返回,因为你赋值没改啥就不处理这些属性了(一会我们会发现题主说的另一种方式这俩个值是一样的),当前情况现在发现俩值不一样,那么就会进行后续逻辑,我们往下继续。
新的对象没有自定义的setter,源对象也没有setter描述符定义,走到1017行,直接把没有响应式的新对象赋给val了,丢弃了旧的vue处理过响应式的对象。

对新对象进行响应式观察

接下来,1019行,observe(newVal)

observe方法做了一系列判断,就为了创建一个observer instance.

clipboard.png

最终创建了这个新对象的observer实例,

clipboard.png

遍历新对象的所有属性并迭代观察

创建完之后,this.walk这个方法又将其属性值深度遍历了一遍进行了响应式观察。

clipboard.png

至此,本次调试结束。

调试1结论

从上面看出,如果是一个新对象,那么vue会重新处理这个值对象的属性的响应式。

第二个调试

接下来我们看看第二个与第一个的不同点

https://codepen.io/rushui/ful...

打开调试

离奇的没有进入setter

发现,并没有进入vue在1004行给userProfile定义的响应式setter中, 如下图(复制的上面调试的图,只想显示一下这个setter, 显示的值不是本次调试的内容,请忽略)

clipboard.png

这有点奇怪,而MDN上这样描述Object.assign,按描述应该调用才对,一脸懵逼?,求大神解答。
Object.assign(target, ...sources)

Object.assign 方法只会拷贝源对象自身的并且可枚举的属性到目标对象。该方法使用源对象的[[Get]]和目标对象的[[Set]],所以它会调用相关 getter 和 setter。

如果没有调用的话,那么我们看下图,
clipboard.png

到28行的时候,userProfile对象只有name属性有响应式getter和setter而age是没有的,因为vue没有机会给age添加响应式getter和setter, 因为age属性在加到userProfile对象上的时候没有调用userProfile的setter。。。

强制进入setter

于是我增加了28行下面的代码,强制让他走一下userProfile的setter,可不可以呢。
为了让前面的属性定义不影响后面的调试,我们再来一个项目(有点多哦),

https://codepen.io/rushui/ful...

我们继续这个项目的调试,这次走到setter了,如下图

clipboard.png

但是没逃过作者定义的判断,那就是这俩个值如果相等就直接返回。 可是这俩个值为什么相等呢?

为什么这次调试 val和newVal是相等的

val和newVal为啥是一模一样的,原来Object.assign的方式吧属性都合并到userProfile这个对象上了,而userProfile实际上是一个指针,指向了内存中的一个对象,而这个对象只有一个,所以当你合并完成,这个对象的引用并没有发生变化,内存中的对象增加了属性age,而userProfile存储的值本身就是地址,这个地址没有变化,所以前后取得都是同一个对象。而这个对象在当前情况下已经被Object.assign给更新为最新的了。
引申出了一个问题,vue作者为啥不拿val的快照做比较,而是拿引用做比较?一脸懵逼?。

总结一下

所以至此我的调试完毕,后面俩中方式都不可行,都会被终止,而没有进入到vue处理属性的setter和getter中。

推荐问题
宣传栏