Vue2数据响应式原理解析

所谓数据响应式就是建立响应式数据依赖(调用了响应式数据的操作)之间的关系,当响应式数据发生变化时,可以通知那些使用了这些响应式数据的依赖操作进行相关更新操作,可以是DOM更新,也可以是执行一些回调函数。从Vue2到Vue3都使用了响应式,那么它们之间有什么区别?

  • Vue2响应式:基于Object.defineProperty()实现的。
  • Vue3响应式:基于Proxy实现的。

那么它们之间有什么区别?为什么Vue3会选择Proxy替代defineProperty?我们先看看下面两个例子:

defineReactive(data,key,val){
    Object.defineProperty(data,key,{
      enumerable:true,
      configurable:true,
      get:function(){
        console.log(`对象属性:${key}访问defineReactive的get!`)
        return val;
      },
      set:function(newVal){
        if(val===newVal){
          return;
        }
        val = newVal;
        console.log(`对象属性:${key}访问defineReactive的set!`)
      }
    })
}

let obj = {};
this.defineReactive(obj,'name','sapper');
// 修改obj的name属性
obj.name = '工兵';
console.log('obj',obj.name);
// 为obj添加age属性
obj.age = 12;
console.log('obj',obj);
console.log('obj.age',obj.age);
// 为obj添加数组属性
obj.hobby = ['游戏', '原神'];
obj.hobby[0] = '王者';
console.log('obj.hobby',obj.hobby);

// 为obj添加对象属性
obj.student = {school:'大学'};
obj.student.school = '学院';
console.log('obj.student.school',obj.student.school);

Vue2数据响应式原理解析_第1张图片
从上图可以看出使用defineProperty定义了包含name属性的对象obj,然后添加age属性、添加hobby属性(数组)、添加student属性并分别访问,都没有触发obj对象中的get、set方法。也就是说defineProperty定义对象不能监听添加额外属性或修改额外添加的属性的变化,我们再看看这样一个例子:

let obj = {};
// 初始化就添加hobby
this.defineReactive(obj,'hobby',['游戏', '原神']);
// 改变数组下标0的值
obj.hobby[0] = '王者';
console.log('obj.hobby',obj.hobby);

Vue2数据响应式原理解析_第2张图片

假如我们一开始就为obj添加hobby属性,我们发现修改数组下标0的值,并没有触发obj里的set方法,也就是说defineProperty定义对象不能监听根据自身数组下标修改数组元素的变化,注意地,如果是直接用defineProperty定义数组元素是可以监听的,但是对于数组比较大的时候就很牺牲性能,尤神考虑到性能就没有使用这种方法。那么我们继续看一下Proxy代理的对象例子:

// proxy实现
let targetProxy = {name:'sapper'};
let objProxy = new Proxy(targetProxy,{
    get(target,key){
      console.log(`对象属性:${key}访问Proxy的get!`)
      return target[key];
    },
    set(target,key,newVal){
      if(target[key]===newVal){
        return;
      }
      console.log(`对象属性:${key}访问Proxy的set!`)
      target[key]=newVal;
      return target[key];
    }
})
// 修改objProxy的name属性
objProxy.name = '工兵';
console.log('objProxy.name',objProxy.name);
// 为objProxy添加age属性
objProxy.age = 12;
console.log('objProxy.age',objProxy.age);
// 为objProxy添加hobby属性
objProxy.hobby = ['游戏', '原神'];
objProxy.hobby[0] = '王者';
console.log('objProxy.hobby',objProxy.hobby);
// 为objProxy添加对象属性
objProxy.student = {school:'大学'};
objProxy.student.school = '学院';
console.log('objProxy.student.school',objProxy.student.school);

Vue2数据响应式原理解析_第3张图片

从上图是不是发现了Proxy与defineProperty的明显区别之处了,Proxy能支持对象添加或修改触发get、set方法,不管对象内部有什么属性。所以

  • Object.defineProperty()**:defineProperty定义对象不能监听**添加额外属性修改额外添加的属性的变化;defineProperty定义对象不能监听根据自身数组下标修改数组元素的变化。我们看看Vue里的用法例子:

  • 
     data() {
       return {
         name: 'sapper',
         student: {
           name: 'sapper',
           hobby: ['原神', '天涯明月刀'],
         },
       };
     },
     methods: {
       deleteName() {
         delete this.student.name;
         console.log('删除了name', this.student);
       },
       addItem() {
         this.student.age = 21;
         console.log('添加了this.student的属性', this.student);
       },
       updateArr() {
         this.student.hobby[0] = '王者';
         console.log('更新了this.student的hobby', this.student);
       },
      }
      
    

    Vue2数据响应式原理解析_第4张图片

从图中确实可以修改data里的属性,但是不能及时渲染,所以Vue2提供了两个属性方法解决了这个问题:Vue.$setVue.$delete

注意不能直接this._ data.age这样去添加age属性,也是不支持的。

this.$delete(this.student, 'name');// 删除student对象属性name
this.$set(this.student, 'age', '21');// 添加student对象属性age
this.$set(this.student.hobby, 0, '王者');// 更新student对象属性hobby数组

Vue2数据响应式原理解析_第5张图片

  • Proxy:解决了上面两个弊端,proxy可以实现:

  • 可以直接监听对象而非对象属性,可以监听对象添加额外属性的变化;

    const user = {name:'张三'}
    const obj = new Proxy(user,{
    get:function (target,key){
      console.log("get run");
      return target[key];
    },
    set:function (target,key,val){
      console.log("set run");
      target[key]=val;
      return true;
    }
    })
    obj.age = 22;
    console.log(obj); // 监听对象添加额外属性打印set run!  
    
    • 可以直接监听数组的变化
    const obj = new Proxy([2,1],{
    get:function (target,key){
      console.log("get run");
      return target[key];
    },
    set:function (target,key,val){
      console.log("set run");
      target[key]=val;
      return true;
    }
    })
    obj[0] = 3;
    console.log(obj); // 监听到了数组元素的变化打印set run!  
    
  • Proxy 返回的是一个新对象,而 Object.defineProperty 只能遍历对象属性直接修改。

  • 支持多达13 种拦截方法,不限于 apply、ownKeys、deleteProperty、has 等等是Object.defineProperty 不具备的。

总的来说,Vue3响应式使用Proxy解决了Vue2的响应式的诟病,从原理上说,它们所做的事情都是一样的,依赖收集依赖更新

Vue2响应式原理

这里基于Vue2.6.14版本进行分析

Vue2响应式:通过Object.defineProperty()对每个属性进行监听,当对属性进行读取的时候就会触发getter,对属性修改的时候就会触发setter。首先我们都知道Vue实例中有data属性定义响应式数据,它是一个对象。我们看看下面例子的data:

data(){
 return {
  name: 'Sapper',
  hobby: ['游戏', '原神'],
  obj: {
    name: '张三',
    student: {
      major: '软件工程',
      class: '1班',
    }
  }
 }
}

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从上图我们可以看到,data中的每一个属性都会带 __ob__ 属性,它是一个Observer对象,其实Vue2中响应式的关键就是这个对象,在data中的每一个属性都会带get、set方法,而Vue源码中其实把get、set分别定义为reactiveGetter、reactiveSetter,这些东西怎么添加进去的。Vue2又是怎么数据变化同时实时渲染页面?先看看下面的图:

Vue2数据响应式原理解析_第6张图片

Observer,监视者类,监视数据的变化,在数据变化时告诉通知者,在这个类中将数据的所有属性用 Object.defineProperty 重新定义一遍,绑定了存取器(getter/setter)

Dep,通知者类,通知订阅者更新视图,因为一个数据可能被多处使用,所以一个通知者会存储多位订阅者

Watcher,订阅者类,用于存储数据变化后要执行的更新函数,调用更新函数可以使用新的数据更新视图

总结:数据变化时,监视者能够立即捕获到变化,同时告诉通知者,通知者在通知数据相关的订阅者执行更新函数,获取新的数据

(1)给data属性创建Observer实例:通过初注册响应式函数initState中调用了initData函数实现为data创建Observer实例。函数如下:

function initData(vm: Component) {
  // 获取组件中声明的data属性
  let data: any = vm.$options.data
  // 对new Vue实例下声明、组件中声明两种情况的处理
  data = vm._data = isFunction(data) ? getData(data, vm) : data || {}
  ...
  // observe data
  const ob = observe(data) // 为data属性创建Observer实例
  ob && ob.vmCount++
}

(2)通过Observer实例把data中所有属性转换成getter/setter形式来实现响应性:对data属性分为两种情况处理:对象属性处理(defineReactive实现)和数组属性处理。数组怎么处理(后面再详细说明)

注意地,由于Vue实例的data永远都是一个对象,所以data里面包含的数组类型只有对象属性数组属性

Vue2数据响应式原理解析_第7张图片
(3) 在getter收集依赖,在setter中触发依赖:当读取data中的数据时,会在get方法中收集依赖,当修改data中的数据时,会在set方法中通知依赖更新。defineReactive方法中主要是做四件事情:创建Dep实例给对象属性添加get/set方法收集依赖通知依赖更新

Vue2数据响应式原理解析_第8张图片

从上面我们知道了dep.depend()实现了依赖收集,dep.notify()实现了通知依赖更新,那么Dep类究竟做了什么?我们先看看下面的图:

Vue2数据响应式原理解析_第9张图片

创建订阅者Watcher,保存更新函数与数据表达式,并将此订阅者存入一个全局变量中(表示进入依赖收集阶段),然后执行这个更新函数

执行函数会时会根据表达式访问数据,触发了监视者的绑定在其身上的 getter,getter 从全局变量获取订阅者,存入其绑定的通知者

每个数据访问结束时,表示本轮的依赖收集完成,清除全局变量中的订阅者

然后针对模板中用到的每一个响应式数据,都会重复以上的过程。对于多层嵌套的数据,也是转换成字符串一层层访问,监视者会为一路上所有数据的通知者添加本轮的订阅者

从图中我们得明确一点,谁使用了变化的数据,也就是说哪个依赖使用了变化的数据,其实就是Dep.taget,它就是我们需要收集的依赖,是一个Watcher实例对象,其实Watcher对象有点类似watch监听器,我们先看一个例子:

vm.$watch('a.b.c',function(newVal,oldVal)){....}

怎么监听多层嵌套的对象,其实就是通过.分割为对象,循环数组一层层去读数据,最后一后拿到的就是想要对的数据。

export function parsePath (path){
 const segment = path.split('.');
 return function(obj){
 ...
   for(let i=0;i<segment.length;i++){
     if(!obj) return;
     obj = obj[segment[i]]
   }
   return obj
 }
}

当嵌套对象a.b.c属性发生变化时,就会触发第二个参数中的函数。也就是说a.b.c就是变化的数据,当它的值发生变化时,通知Watcher,接着Watcher触发第二个参数执行回调函数。我们看看Watcher类源码,是不是发现了cb其实就与watch的第二参数有异曲同工之妙。

export default class Watcher implements DepTarget {
  vm?: Component | null
  cb: Function
  deps: Array<Dep>
  ...
  constructor(vm: Component | null,expOrFn: string | (() => any),cb: Function,...) {
    ...
    this.getter = parsePath(expOrFn)// 解析嵌套对象
    ...
  }
  get() { // 读取数据
    ...
    return value
  }

  addDep(dep: Dep) {
    ...
    dep.addSub(this)//添加依赖
    ...
  }
  cleanupDeps() {// 删除依赖
    ...
    dep.removeSub(this)
    ...
  }
  update() {// 通知依赖更新
   this.run()
   ...
  }

  run() {
   ...
   this.cb.call(this.vm, value, oldValue)
  }
  ...
  depend() { // 收集依赖
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }
  ...
}

举例说明:

html代码

<div id="app">
  {{ obj.message }}
div>

js代码

let data = {
  obj: {
    message: 'Hello Vue!',
  },
}

new Vue({
  el: '#app',
  data,
})

setTimeout(() => {
  data.obj = {
    message: 'Obj have changed!',
  }
}, 1000)
setTimeout(() => {
  data.obj.message = 'Message have changed!'
}, 2000)

流程如下:

(1)Vue 拿到了 data 这个对象,创建一个监视者,绑定到对象的 __ob__ 属性上

(2)创建监视者会将data对象身上的所有属性Object.defineProperty 重新一遍,在定义的同时,为每一个数据创建它的通知者。通知者会在闭包环境中创建,只有该数据的存取器能够访问到。

(3)如果对象的值中还有对象,会递归上面的过程

(4)处理完 data 后,将其所有属性映射到 Vue 实例身上(允许vm.xxx直接访问)

解析模板

数据监视完毕后,Vue 会解析模板

模板解析的内容较为复杂,这一过程会创建虚拟节点 vnode,匹配到 {{ }} v- 等响应式写法,会根据当前节点的类型、响应式语法等建立该节点的更新函数 patch ,将数据与视图绑定

本文主要是帮助理解响应式更新的逻辑,模板解析语法并不是本文的重点,所以在之后的讲解与实现中使用的还是 DOM 节点

  1. Vue 会根据 el: '#app' 配置项获取根 DOM 元素,遍历其所有子节点

  2. 发现html文本节点的内容是 {{ obj.message }},检测到特定的响应式写法,建立更新函数并提取数据表达式(字符串 'obj.message'

  3. 因为要操作的是只是文本节点的内容,所以更新函数较为简单(下方代码)

    const patch = (value) => {
        node.textContent = value
    }
    
  4. 创建订阅者,保存更新函数与数据表达式,并将此订阅者存入一个全局变量中(表示进入依赖收集阶段),然后执行这个更新函数

  5. 执行函数会时会根据表达式访问数据,触发了监视者的绑定在其身上的 getter,getter 从全局变量获取订阅者,存入其绑定的通知者

  6. 每个数据访问结束时,表示本轮的依赖收集完成,清除全局变量中的订阅者

  7. 然后针对模板中用到的每一个响应式数据,都会重复以上的过程。对于多层嵌套的数据,也是转换成字符串一层层访问,监视者会为一路上所有数据的通知者添加本轮的订阅者

  8. 模板解析完成时,只创建了一个订阅者但被添加到了四个通知者中(如下图)

Vue2数据响应式原理解析_第10张图片

以后修改数据就会触发监视者的 setter,setter 就能告诉通知者,通知其内部的订阅者执行更新函数修改视图,实现了数据的响应式

如果修改的数据值是一个对象,会先为其创建监视者,再告诉通知者发布订阅,执行更新函数访问这些数据时,所有子数据的新通知者又存储了之前解析模板时创建的订阅者

还是根据例子来讲解更新流程

  1. 第一次修改的是 data.obj,是一个对象,原对象的监视者、Dep2、Dep4被移除
  2. 触发 obj 的 setter,新值是一个对象,为其创建监视者,同时创建了空的 Dep2、Dep4
  3. 告诉 Dep3,通知其中的订阅者执行更新函数
  4. 更新函数执行访问到了 objobj.message,将此订阅者又添加进 Dep2 和 Dep4 中
  5. 第二次修改的是data.obj.message
  6. 触发 message 的 setter,告诉 Dep4,通知其中的订阅者更新视图

实现对数组的监听

从最开始的例子,我们了解对象以及嵌套对象的监听,但是Object.defineProperty是用来监听对象指定属性的变化,不支持数组监听,那么数组又是怎么监听?我们上面说了data中的数据被赋予响应性都是在Observer中实现的,那么监听的实现也是在Observer对象中实现的,先对数组的特定方法做自定义处理,为了拦截数组元素通知依赖更新,然后才通过observeArray函数遍历创建Observer实例,主要分为两种情况:

// 源码Observer类中对数组处理的部分代码
if (Array.isArray(value)) {
  if (hasProto) {
    protoAugment(value, arrayMethods)
  } else {
    copyAugment(value, arrayMethods, arrayKeys)
  }
  this.observeArray(value)
}
  • 当浏览器支持__ proto __ 对象:强制赋值当前arrayMethods给target的__ proto __ 对象,直接给当前target数组带上自定义封装的数组方法,从而实现监听数组变化。其实arrayMethods处理后就是下面这样一个对象:

    Vue2数据响应式原理解析_第11张图片

    protoAugment(value, arrayMethods)
    
    function protoAugment (target, src: Object) {
    target.__proto__ = src
    }
    
  • 当浏览器不支持__ proto __ 对象:遍历数组元素通过defineProperty定义为元素带上自定义封装的原生数组方法,由于自定义数组方法中做了拦截通知依赖更新,从而实现监听数组的变化。

    const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
    console.log(arrayKeys);// ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
    copyAugment(value, arrayMethods, arrayKeys)
    function copyAugment (target: Object, src: Object, keys: Array<string>) {
    for (let i = 0, l = keys.length; i < l; i++) {
      const key = keys[i];
        //遍历数组元素,给每一个元素绑上数组的方法
      def(target, key, src[key])// 遍历数组元素通过为元素带上
    }
    }
    

    对数组的Array原生方法做了自定义封装的源码如下,在自定义方法中拦截通知依赖更新。

    Vue2数据响应式原理解析_第12张图片

    // 遍历target实现创建Observer实例
    observeArray (items: Array<any>) {
    	for (let i = 0, l = items.length; i < l; i++) {
            observe(items[i])
        }
    }
    

Vue2响应式原理小结:

  • 给data创建Observer实例
  • Observer类实现对数据封装getter、setter的响应性
  • 针对数组类型数据,自定义封装Array原生方法,在封装过程中拦截执行通知依赖更新
  • 真正通过Watcher通知依赖更新,通过run方法中的cb回调函数,实现类似watch侦听器第二参数中监听变化后的操作

你可能感兴趣的:(Vue2原理,javascript,前端,vue.js)