vue2的响应式原理介绍——Object.defineProperty

2,133 阅读7分钟

大家好,我是前端GGBond,今天我们来聊一下vue2的响应式原理。

什么是响应式呢?

数据发生变化后,会重新对页面渲染,这就是Vue响应式,如下图

响应式图例

想完成这个过程,我们需要做些以下几点:

  • 数据劫持 / 数据代理,侦测数据的变化
  • 依赖收集,收集视图依赖了哪些数据
  • 发布订阅模式,数据变化时,自动“通知”需要更新的视图部分,并进行更新

今天我们重点聊一下数据劫持的实现,以及vue在响应式监听中存在的问题,以及对应的解决方法。

一、针对对象的监听实现

前置知识、Object.defineProperty介绍(对象劫持)

定义:Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。

基本使用

语法:Object.defineProperty(obj, prop, descriptor)

参数:

1.     obj,要添加属性的对象

2.     prop,要定义或修改的属性的名称或 [Symbol]

3.     descriptor,要定义或修改的属性描述符

前两个参数都很好理解,这里重点说一下第三个参数:属性描述符。

对象里目前存在的属性描述符,包括configurable、enumerable、value、writable、get、set。

而vue中针对对象的监听,主要是通过属性描述符的最后两个属性,get及set

get

属性的 getter 函数,当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象(由于继承关系,这里的this并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。

set

属性的 setter 函数,当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。默认为 undefined

下面通过代码展示:

定义一个响应式函数defineReactive  

const obj = {
    foo''
  }

  function update() {
    console.log('obj.foo更新了', obj.foo)
  }

  function defineReactive(obj, key, val) {
    Object.defineProperty(obj, key, {
      get() {
        console.log(`get ${key}:${val}`);
        return val
      },
      set(newVal) {
        if (newVal !== val) {
          val = newVal
          update()
        }
      }
    })
  }

调用defineReactive,数据发生变化触发update方法,实现数据响应式

defineReactive(obj, 'foo', '')
setTimeout(()=>{
    obj.foo = new Date().toLocaleTimeString()
[]()},1000)

 上面的情况可以监听到对象一个属性的变化。

但是,在对象存在多个属性的情况下,需要进行遍历。

function observe(obj) {
    if (typeof obj !== 'object' || obj == null) {
        return
    }
    Object.keys(obj).forEach(key => {
        defineReactive(obj, key, obj[key])
    })
}

如果存在嵌套对象的情况,还需要在defineReactive中进行递归

function defineReactive(obj, key, val) {
    observe(val)
    Object.defineProperty(obj, key, {
        get() {
            console.log(`get ${key}:${val}`);
            return val
        },
        set(newVal) {
            if (newVal !== val) {
                val = newVal
                update()
            }
        }
    })
}

当给key赋值为对象的时候,还需要在set属性中进行递归

set(newVal) {
    if (newVal !== val) {
        observe(newVal) // 新值是对象的情况
        notifyUpdate()
    }
}

上述例子能够实现对一个对象的基本响应式,但仍然存在诸多问题。

比如现在对一个对象进行删除与添加属性操作,无法劫持到:

const obj = {
    foo: "foo",
    bar: "bar"
}
observe(obj)
delete obj.foo // no ok
obj.jar = 'xxx' // no ok

这些问题如何解决?我们后面再来讲。

二、针对数组的监听

首先说一下,vue针对数组的响应式并没有用到Object.defineProperty,主要原因有以下两点:

1.Object.defineProperty ,可以监听到数组属性变化,但因为性能消耗严重,所以成为废案。

其实Object.defineProperty,可以监听到数组属性变化,以下是测试代码:

let testArray = [0];
function test(data, key, val) {
  Object.defineProperty(data, key, {
    get() {
      console.log(val);
    },
    set(newV) {
      if (newV !== val) {
        val = newV;
        console.log('检测到变更');
      }
    },
  });
}
test(testArray, 0, aa[0]);
testArray[0] = 1

l  直接复制代码控制台输出

stickPictur11e.png

网上其实很多人都在说Object.defineProperty是不能通过下标来修改数组的数据。但是自己测试怎么是可以检测到修改的?难道是他们说的都是错误的?

再来继续测试一下length的变化,

testArray.length = 5

stickPicture22.png

l  我们来看控制台输出,数据的长度是修改成功的,看来真的是他们的答案有误。

l  于是我继续寻找,终于找到了,确实不是Object.defineProperty()的问题,是vue本身做了限制,当数据是数组时,会停止对数据属性的监测,

stickPicture.png

还有一个问题则是,如果存在深层的嵌套对象关系,需要深层的进行监听,造成了性能的极大问题。

所以, Object.defineProperty ****是有监控数组下标变化的能力的, 只是在 Vue2 的实现中,从性能/体验的性价比考虑,放弃了这个特性。

2.Object.defineProperty无法监听到数组api的变化

当我们对一个数组api进行监听的时候,发现Object.defineProperty并不那么好使了

const arrData = [1,2,3,4,5];

arrData.forEach((val,index)=>{

    defineProperty(arrData,index,val)

})

arrData.push() // no ok

arrData.pop()  // no ok

[]()arrDate[0] = 99 // ok

可以看到数据的api无法劫持到,从而无法实现数据响应式,

所以在Vue2中,针对数组的监听没有用到Object.defineProperty,而是增加了set、delete API,并且对数组api方法进行一个重写。

stickPictur33e.png 与此同时,官网也提到,Vue 不能检测以下数组的变动:

  1. 当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue
  2. 当你修改数组的长度时,例如:vm.items.length = newLength

小结

Vue 不能检测以下对象的变动:

1.  检测不到对象属性的添加和删除

Vue 不能检测以下数组的变动:

1.      当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue

2.      当你修改数组的长度时,例如:vm.items.length = newLength

针对这些不能监听变化的问题,vue肯定给出了解决方案,具体有哪些呢?

 

三、对象的手动更新

1.更新对象的单个属性——vue.set()

对于已经创建的实例,Vue 不允许动态添加根级别的响应式 property。但是,可以使用 Vue.set(object, propertyName, value) 方法向嵌套对象添加响应式 property。vue.set()这个api,其实就是做一次Object.defineProperty。语法是:

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

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

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

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 })

四、数组的手动更新

1.索引赋值更新无效的问题,例如vue.items[indexOfItem] = newValue

为了解决.items[indexOfItem] = newValue 更新无效的问题,以下两种方式都可以实现和 vm.items[indexOfItem] = newValue 相同的效果,同时也将在响应式系统内触发状态更新:

// Vue.set Vue.set(vm.items, indexOfItem, newValue)
// Array.prototype.splice 
vm.items.splice(indexOfItem, 1, newValue)

你也可以使用 vm.$set 实例方法,该方法是全局方法 Vue.set 的一个别名:

vm.$set(vm.items, indexOfItem, newValue)

2.watch中的deep监听

如果数组中带有对象,需要对每个属性进行遍历监听,如果嵌套对象,需要深层监听,造成性能问题。所以,vue默认是不会做深度监听的。

但如果就是要深度监听怎么办呢?watch的专门有个deep的api,解决这个问题。

deep:代表深度监听,它有两个值分别是是true或false,不仅能监听到数组中对象的变化,也监听到该对象的属性变化。

3.修改数组的长度无法更新的问题,例如:vm.items.length = newLength

为了解决第二类问题,你可以使用 splice:

vm.items.splice(newLength)

五、另一种解决方法——$forceUpdate()手动刷新dom

vm.$forceUpdate()

示例:迫使 Vue 实例重新渲染。注意它仅仅影响实例本身和插入插槽内容的子组件,而不是所有子组件。(说人话就是只更新自己这个组件,不会更新子组件)

参考文章: 一文搞懂Object.defineProperty和Proxy,Vue3.0为什么采用Proxy?

vue对通过下标修改数组监听不到,和Object.defineProperty无关(这个锅它不背)