前端不得不知道的,Vue.set 的副作用

开发 前端
我个人觉得Vue.set这种添加和修改不一致的表现是vue的一个缺陷。还没看Vue 3.0代码,看过的朋友可以告诉我下,是不是也有这样的问题?

Vue虽然用挺久了,还是会踩到坑,来看下面这段很简单的🌰:点击a和b按钮,下面代码会提示什么?

<html> 
<head> 
    <meta charset="utf-8"> 
    <script src="https://cdn.staticfile.org/vue/2.5.17/vue.min.js"></script> 
</head>  
<body> 
<div id="app"> 
    <p>{{ JSON.stringify(this.testObj) }}</p> 
    <button @click="set('a')">设置testObj属性a</button> 
    <button @click="set('b')">设置testObj属性b</button> 
</div> 
 
<script> 
  new Vue({ 
    el: '#app', 
    data: { 
      testObj: {}, 
    }, 
    watch: { 
      'testObj.a'() { 
        alert('a') 
      }, 
      'testObj.b'() { 
        alert('b') 
      }, 
    }, 
    methods: { 
      set(val) { 
        Vue.set(this.testObj, val, {}); 
      } 
    }, 
  }) 
</script> 
</body> 
</html> 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
 

答案是:

点a的时候alert a,点b的时候alert a,接着alert b。

如果再接着点a,点b,提示什么?

答案是:

点a的时候alert a,点b的时候alert b。

我们把代码做一个很小的改动:把Vue.set的值由对象改为true。这时候点击a和b按钮,下面代码会提示什么?

<html> 
<head> 
    <meta charset="utf-8"> 
    <script src="https://cdn.staticfile.org/vue/2.5.17/vue.min.js"></script> 
</head>  
<body> 
<div id="app"> 
    <p>{{ JSON.stringify(this.testObj) }}</p> 
    <button @click="set('a')">设置testObj属性a</button> 
    <button @click="set('b')">设置testObj属性b</button> 
</div> 
 
<script> 
  new Vue({ 
    el: '#app', 
    data: { 
      testObj: {}, 
    }, 
    watch: { 
      'testObj.a'() { 
        alert('a') 
      }, 
      'testObj.b'() { 
        alert('b') 
      }, 
    }, 
    methods: { 
      set(val) { 
        Vue.set(this.testObj, val, true); 
      } 
    }, 
  }) 
</script> 
</body> 
</html> 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.

答案是:

点a的时候alert a,点b的时候alert b。

如果再接着点a,点b,提示什么?

答案是:

没有提示。

先总结一下发现的现象:用Vue.set为对象o添加属性,如果添加的属性是一个对象,那么o的所有属性会被触发响应。

是不是不明白?且请听我讲解一下。

要回答上面这些问题,我们首先需要理解一下Vue的响应式原理。

从Vue官网这幅图上我们可以看出:当我们访问data里某个数据属性p时,会通过getter将这个属性对应的Watcher加入该属性的依赖列表;当我们修改属性p的值时,通过setter通知p依赖的Watcher触发相应的回调函数,从而让虚拟节点重新渲染。

所以响不响应关键是看依赖列表有没有这个属性的watcher。

为了把依赖列表和实际的数据结构联系起来,我画出了vue响应式的主要数据结构,箭头表示它们之间的包含关系:

Vue里的依赖就是一个Dep对象,它内部有一个subs数组,这个数组里每个元素都是一个Watcher,分别对应对象的每个属性。Dep对象里的这个subs数组就是依赖列表。

从图中我们可以看到这个Dep对象来自于__ob__对象的dep属性,这个__ob__对象又是怎么来的呢?这就是我们new Vue对象时候Vue初始化做的工作了。Vue初始化最重要的工作就是让对象的每个属性成为响应式,具体则是通过observe函数对每个属性调用下面的defineReactive来完成的:

/** 
 * Define a reactive property on an Object. 
 */ 
function defineReactive ( 
  obj, 
  key, 
  val, 
  customSetter, 
  shallow 
) { 
  var dep = new Dep(); 
 
  var property = Object.getOwnPropertyDescriptor(obj, key); 
  if (property && property.configurable === false) { 
    return 
  } 
 
  // cater for pre-defined getter/setters 
  var getter = property && property.get; 
  if (!getter && arguments.length === 2) { 
    val = obj[key]; 
  } 
  var setter = property && property.set; 
 
  var childOb = !shallow && observe(val); 
  Object.defineProperty(obj, key, { 
    enumerable: true, 
    configurable: true, 
    get: function reactiveGetter () { 
      var value = getter ? getter.call(obj) : val; 
      if (Dep.target) { 
        dep.depend(); 
        if (childOb) { 
          childOb.dep.depend(); 
          if (Array.isArray(value)) { 
            dependArray(value); 
          } 
        } 
      } 
      return value 
    }, 
    set: function reactiveSetter (newVal) { 
      var value = getter ? getter.call(obj) : val; 
      /* eslint-disable no-self-compare */ 
      if (newVal === value || (newVal !== newVal && value !== value)) { 
        return 
      } 
      /* eslint-enable no-self-compare */ 
      if (process.env.NODE_ENV !== 'production' && customSetter) { 
        customSetter(); 
      } 
      if (setter) { 
        setter.call(obj, newVal); 
      } else { 
        val = newVal
      } 
      childOb = !shallow && observe(newVal); 
      dep.notify(); 
    } 
  }); 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.

让一个对象成为响应式其实就是给对象的所有属性加上getter和setter(defineReactive做的工作),然后在对象里加__ob__属性(observe做的工作),因为__ob__里包含了对象的依赖列表,所以这个对象就可以响应数据变化。

可以看到defineReactive里也调用了observe,所以让一个对象成为响应式这个动作是递归的。即如果这个对象的属性又是一个对象,那么属性对象也会成为响应式。就是说这个属性对象也会加__ob__然后所有属性加上getter和setter。

刚才说有没有响应看“依赖列表有没有这个属性的watcher”,但是实际上,ob 只存在属性所在的对象上,所以依赖列表是在对象上的依赖列表,通过依赖列表里Watcher的expression关联到对应属性(见图2)。说以准确的说:有没有响应应该是看“对象的依赖列表里有没有属性的watcher”。

注意我们在data里只定义了testObj空对象,testObj并没有任何属性,所以testObj的依赖列表一开始是空的。

但是因为代码有定义Vue对象的watch,初始化代码会对每个watch属性新建watcher,并添加到testObj的依赖队列__ob__.dep.subs里。这里的添加方法非常巧妙:新建watcher时候会一层层访问watch的属性。比如watch 'testObj.a',vue会先访问testObj,再访问testObj.a。因为testObj已经初始化成响应式的,访问testObj时会调用defineReactive里定义的getter,getter又会调用dep.depend()从而把testObj.a对应的watcher加到依赖队列__ob__.dep.subs里。于是新建watcher的同时完成了把watcher自动添加到对应对象的依赖列表这个动作。

小结一下:Vue对象初始化时会给data里对象的所有属性加上getter和setter,添加__ob__属性,并把watch属性对应的watcher放到__ob__.dep.subs依赖列表里。

所以经过初始化,testObj的依赖列表里已经有了属性a和b对应的watcher。

有了以上基础知识我们再来看Vue.set也就是下面的set函数做了些什么。

/** 
 * Set a property on an object. Adds the new property and 
 * triggers change notification if the property doesn't 
 * already exist. 
 */ 
function set (target, key, val) { 
  if (process.env.NODE_ENV !== 'production' && 
    (isUndef(target) || isPrimitive(target)) 
  ) { 
    warn(("Cannot set reactive property on undefined, null, or primitive value: " + ((target)))); 
  } 
  if (Array.isArray(target) && isValidArrayIndex(key)) { 
    target.length = Math.max(target.length, key); 
    target.splice(key, 1, val); 
    return val 
  } 
  if (key in target && !(key in Object.prototype)) { 
    target[key] = val; 
    return val 
  } 
  var ob = (target).__ob__; 
  if (target._isVue || (ob && ob.vmCount)) { 
    process.env.NODE_ENV !== 'production' && warn( 
      'Avoid adding reactive properties to a Vue instance or its root $data ' + 
      'at runtime - declare it upfront in the data option.' 
    ); 
    return val 
  } 
  if (!ob) { 
    target[key] = val; 
    return val 
  } 
  defineReactive(ob.value, key, val); 
  ob.dep.notify(); 
  return val 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.

我们关心的主要就最后这两句:defineReactive(ob.value, key, val); 和ob.dep.notify();。

defineReactive的作用就是让一个对象属性成为响应式。ob.dep.notify()则是通知对象依赖列表里面所有的watcher:数据变化了,看看你是不是要做点啥?具体做什么就是图2 Watcher里面的cb。当我们在vue 里面写了 watch: { p: function(oldValue, newValue) {} } 时候我们就是为p的watcher添加了cb。

所以Vue.set实际上就做了这两件事:

  • 把属性变成响应式 。
  • 通知对象依赖列表里所有watcher数据发生变化。

那么问题来了,既然依赖列表一直包含a和b的watcher,那应该每次Vue.set时候,a和b的cb都应该被调用,为什么结果不是这样呢?奥妙就藏在下面的watcher的run函数里。

/** 
 * Scheduler job interface. 
 * Will be called by the scheduler. 
 */ 
Watcher.prototype.run = function run () { 
  if (this.active) { 
    var value = this.get(); 
    if ( 
      value !== this.value || 
      // Deep watchers and watchers on Object/Arrays should fire even 
      // when the value is the same, because the value may 
      // have mutated. 
      isObject(value) || 
      this.deep 
    ) { 
      // set new value 
      var oldValue = this.value; 
      this.value = value; 
      if (this.user) { 
        try { 
          this.cb.call(this.vm, value, oldValue); 
        } catch (e) { 
          handleError(e, this.vm, ("callback for watcher \"" + (this.expression) + "\"")); 
        } 
      } else { 
        this.cb.call(this.vm, value, oldValue); 
      } 
    } 
  } 
}; 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.

dep.notify通知watcher后,watcher会执行run函数,这个函数才是真正调用cb的地方。我们可以看到有这样一个判断 if (value !==this.value || isObject(value) || this.deep) 就是说值不相等或者值是对象或者是深度watch的时候,都会触发cb回调。所以当我们用Vue.set给对象添加新的对象属性的时候,依赖列表里的每个watcher都会通过这个判断(新添加属性因为{} !== {} 所以value !==this.value成立,已有属性因为isObject(value)),都会触发cb回调。而当我们Vue.set给对象添加新的非对象属性的时候,只有新添加的属性通过value !==this.value 判断会触发cb,其他属性因为值没变所以不会触发cb回调。这就解释了为什么第一次点击按钮b的时候场景一和场景二的效果不一样了。

那既然依赖列表没变为什么第二次点击按钮效果就不一样了呢?

这就是set函数里面这个判断起的作用了:

if (key in target && !(key in Object.prototype)) { 
  target[key] = val; 
  return val 

  • 1.
  • 2.
  • 3.
  • 4.

这个判断会判断对象属性是否已经存在,如果存在的话只是做一个赋值操作。不会走到下面的defineReactive(ob.value, key, val); 和ob.dep.notify();里,这样watcher没收到notify,就不会触发cb回调了。那第二次点击按钮的回调是哪里触发的呢?还记得刚才的defineReactive里定义的setter吗?因为testObj已经成为了响应式,所以进行属性赋值操作会触发这个属性的setter,在set函数最后有个dep.notify();就是它通知了watcher从而触发cb回调。

就算是这样第二次点击不是应该a和b都触发的吗?依赖列表不是一直包含有a和b的watcher吗?

这里就要涉及到另一个概念“依赖收集”,不同于__ob__.dep.subs这个依赖列表,响应式对象还有一个依赖列表,就是defineReactive里面定义的var dep,每个属性都有一个dep,以闭包形式出现,我暂且称它为内部依赖列表。在前面的set函数判断里,判断通过会执行target[key]= val; 这句赋值语句会首先触发getter,把属性key对应的watcher添加到内部依赖列表,这个步骤就是Vue官网那张图里的“collect as dependencies”;然后触发setter,调用dep.notify()通知watcher执行watcher.run。因为这时候内部依赖列表只有一个watcher也就是属性对应的watcher。所以只触发了属性本身的回调。

根据以上分析我们还原一下两个场景:

场景1:Vue.set 一个对象属性

  • 点击按钮a: Vue.set把属性a变成响应式,通知依赖列表数据变化,依赖列表中watcher-a发现数据变化,执行a的回调。
  • 点击按钮b: Vue.set把属性b变成响应式,通知依赖列表数据变化,依赖列表中watcher-a发现a是对象,watcher-b发现数据变化,均满足触发cb条件,于是执行a和b的回调。
  • 再点击按钮a: Vue.set给a属性赋值,触发getter收集依赖,内部依赖列表收集到依赖watcher-a,触发setter通知内部依赖列表数据变化,watcher-a发现数据变化,执行a的回调。
  • 再点击按钮b: Vue.set给b属性赋值,触发getter收集依赖,内部依赖列表收集到依赖watcher-b,触发setter通知内部依赖列表数据变化,watcher-b发现数据变化,执行b的回调。

场景2:Vue.set 一个非对象属性

  • 点击按钮a: Vue.set把属性a变成响应式,通知依赖列表数据变化,依赖列表中watcher-a发现数据变化,执行a的回调。
  • 点击按钮b: Vue.set把属性b变成响应式,通知依赖列表数据变化,watcher-b发现数据变化,执行b的回调。
  • 再点击按钮a: Vue.set给a属性赋值,触发getter收集依赖,内部依赖列表收集到依赖watcher-a,触发setter,发现数据没变化,返回。
  • 再点击按钮b: Vue.set给b属性赋值,触发getter收集依赖,内部依赖列表收集到依赖watcher-b,触发setter,发现数据没变化,返回。

原因总结:

  • Vue响应式对象有内部、外部两个依赖列表。
  • Vue.set有添加属性、修改属性两种功能。
  • Watcher在判断是否需要触发回调时有对象属性、非对象属性的区别。

结论:

  • 用Vue.set添加对象属性,对象的所有属性都会触发一次响应。
  • 用Vue.set修改对象属性,只有当前修改的属性会触发一次响应。

我个人觉得Vue.set这种添加和修改不一致的表现是vue的一个缺陷。还没看Vue 3.0代码,看过的朋友可以告诉我下,是不是也有这样的问题?

规避方法:

添加一个对象属性会让所有属性触发响应这个特性应该不是我们想要的效果。目前没想到好的解决方法,只能在data里定义对象时先把对象的属性全写上。避免使用Vue.set设置对象属性。

 

责任编辑:赵宁宁 来源: 前端下午茶
相关推荐

2020-10-21 09:36:40

Vue项目技巧

2020-08-11 06:53:33

Vue前端代码

2020-05-18 09:33:27

前端开发工具

2022-08-08 11:13:35

API接口前端

2022-08-30 23:54:42

MySQL数据库工具

2022-10-27 09:55:00

2017-08-10 16:54:47

MySQL优化MySQL

2019-07-17 10:55:40

Kubernetes工具Katacoda

2020-02-13 18:05:18

数组reduce前端

2023-08-29 08:41:42

2015-09-22 10:03:25

大数据秘诀

2015-09-23 10:27:04

大数据秘诀

2017-11-02 06:51:38

5G移动网络技术

2017-09-22 09:10:41

2024-12-23 07:00:00

C++并发编程

2011-03-31 10:46:54

LinuxCLI软件

2017-08-16 18:03:12

Docker安全工具容器

2020-06-04 13:52:00

CRM选型

2024-06-05 11:36:28

2009-05-31 09:02:23

点赞
收藏

51CTO技术栈公众号