【Vue源码初探】一.Vue响应式原理

一.Vue响应式原理

一.初始化数据

首先我们准备一份测试代码:
在dist/index.html文件下:

引入我们自己的vue.js,创建一个Vue类的实例

<!--
 * @Author: Pan Jingyi
 * @Date: 2022-10-04 14:52:34
 * @LastEditTime: 2022-10-06 18:39:30
-->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="app" style="color:aqua; background: yellow">
    <div style="color: red">{
   
   { firstname }}</div>
  </div>
  <script src="vue.js"></script>
  <script>
    //响应式的数据变化,数据变化了可以监控到
    //数据的取值 和 更改值我们要监控到
    const vm = new Vue({
      
      
      data: {
      
       //代理数据
        firstname: '珠',
        lastname: '峰'
      },
      el: '#app', //将数据解析到el元素上
      computed: {
      
      
        fullname(){
      
      
          return this.firstname + this.lastname;
        }
      }
    })
  </script>
</body>
</html>

在src/index.js 文件中初始化代码:

在vue源码中,并没有使用一个vue的class类,而是使用了一个构造函数,然后在构造函数的原型上增加方法。

function Vue(options){
    
     //options是用户的选项
	this._init(options)
}
Vue.prototype._init = function(){
    
     //扩展方法
  
}
export default Vue;

思路是将扩展方法抽离出来,封装成每个文件。

接下来创建一个init.js,用来初始化文件。

//init.js
export function initMixin(Vue){
    
    
	Vue.prototype._init = function(){
    
     //扩展方法
  
	}
}

然后在index.js文件中引入init.js文件:

//index.js
import {
    
     initMxin } from './init';
function Vue(options){
    
     //options是用户的选项
	this._init(options)
}

initMxin(Vue); //扩展了init方法,执行该函数,Vue构造函数原型上就有了_init方法
export default Vue;

接下来我们就要开始在init.js文件中编写初始化操作的相关代码了。

首先我们需要将用户传入的数据绑定到vue上。

  1. 默认vue的属性使用 开头: 开头: 开头:options, $attr, $set…

  2. 开始初始化状态:就是挂载属性,方法,计算属性… 封装一个initState函数,参数为我们的vue实例vm

  3. 如何初始化:将用户的数据绑定到vm实例上 -> vm._data = vm.$options.data

    我们的options上就是用户传入的数据

  4. 然后我们对data数据进行劫持 -> defineProperty

  5. 劫持完毕后,访问不太方便:vm._data.name。

    如何解决?将 vm._data 用 vm 来代理

      //将 vm._data 用 vm 来代理
      for(let key in data){
          
          
        proxy(vm, '_data', key);
      }
    
  6. 实现将 vm._data 用 vm 来代理的函数:

    //将 vm._data 用 vm 来代理
    function proxy(vm, target, key){
          
          
      Object.defineProperty(vm, key, {
          
          
        get(){
          
          
          return vm[target][key];
        },
        set(newValue){
          
          
          vm[target][key] = newValue
        }
      })
    }
    

一个问题:

关于将 vm._data 用 vm 来代理:

我们对data进行劫持,关于data的这一坨代码进行劫持完毕后,我们需要访问到这些数据:

如何访问?我们当时是将data从$options中取出,然后赋给了一个属性_data上,那么也就是说,我们的访问data是通过 vm.-data 实现的,为了方便,我们再次进行代理。

//init.js
export function initMixin(Vue) {
    
     //就是给Vue增加init方法的
  Vue.prototype._init = function(options){
    
     //用于初始化话操作
    // vm.$options 就是获取用户的配置

    const vm = this;
    vm.$options = options; //将用户的选项挂载到实例上

    //初始化状态:就是挂载属性,方法,计算属性...
    initState(vm);
  }
}

function initState(vm){
    
    
  const opts = vm.$options; //获取所有选项
  if(opts.data) {
    
     //是否有data属性,执行初始化data的函数
    initData(vm);
  }
  if(opts.computed){
    
    
    initComputed(vm);//初始化计算属性
  }
  //这后面还可以往后续...
  if(opts.watch){
    
    
        initWatch(vm);
  }
}

//执行初始化data的函数
function initData(vm){
    
    
  let data = vm.$options.data; //data可能是函数或者对象
  // 这里我们只是拿到了用户的数据,但是得把数据绑定到vm上,如何绑定呢?用 _data
  data = typeof data === 'function' ? data.call(vm) : data; //data是用户返回的对象

  vm._data = data //我将返回的对象放到了_data上
  //现在已经成功绑定了,但是访问不太方便:vm._data.name
  //怎么变方便呢? -> 将 vm._data 用 vm 来代理

  // 数据绑定完之后该干什么呢?接下来是对数据进行劫持 vue2里采用了一个api defineProperty
  observe(data)

  //将 vm._data 用 vm 来代理
  for(let key in data){
    
    
    proxy(vm, '_data', key);
  }
}


//将 vm._data 用 vm 来代理
function proxy(vm, target, key){
    
    
  Object.defineProperty(vm, key, {
    
    
    get(){
    
    
      return vm[target][key];
    },
    set(newValue){
    
    
      vm[target][key] = newValue
    }
  })
}

二.递归属性劫持

上面说到,我们对数据进行初始化,并且,实现了通过vm.data来访问。

在初始化的过程中,我们要对数据进行劫持。

这里使用observe(data)函数。

  • 接下来我们还要在observe(data)函数中判断一下,如果这个data已经被劫持过了,那就直接返回,不用再劫持一遍,如何实现? -> 当对象劫持过了以后,我们给对象增加一个属性,标志着这个对象已经被劫持了。那么就需要我们起初在进入对象劫持的函数的时候,就首先判断一下当前对象有没有被劫持过(即当前对象身上有没有那个属性)。 该过程后面我们会实现
export function observe(data) {
    
    
  //只对对象进行劫持
    if(typeof data !== 'object' || data == null){
    
    
        return;
    }
  
    //如果一个对象被劫持过了,那就不需要再被劫持了(要判断一个对象是否被劫持过,可以增添一个属性,用属性来判断是否被劫持过)
  //...
    return new Observer(data);
}

接下来我们声明一个Observer的类,用于劫持data

  • 我们在constructor中使用 Object.defineProperty来进行劫持
class Observer {
    
     // 观测值
    constructor(value){
    
    
          //Object.defineProperty只能劫持已经存在的属性(vue里面会为此单独写一些api $set $delete)
      
        this.walk(value);
    }
    walk(data){
    
     // 让对象上的所有属性依次进行观测
        let keys = Object.keys(data);
        for(let i = 0; i < keys.length; i++){
    
    
            let key = keys[i];
            let value = data[key];
            defineReactive(data,key,value);
        }
    }
}
function defineReactive(data,key,value){
    
    
    observe(value); //如果值是对象的话,再次进行劫持(就是对象里面包含着对象) -> 递归
  // 使用 Object.defineProperty来进行劫持
    Object.defineProperty(data,key,{
    
    
        get(){
    
    
            return value
        },
        set(newValue){
    
    
            if(newValue == value) return;
            observe(newValue);//如果设置的值是对象,那就需要继续观测
            value = newValue
        }
    })
}

三.数组方法的劫持

上面我们对对象进行了劫持,现在要开始对数组进行劫持。

数组不可能对数组的每一项都进行代理,这样会浪费性能,并且用户很少通过vm.arr[888]的方式来修改数组(即用户很少直接使用索引来修改数组)。

一般修改数组的方法:push shift…

在进入constructor函数中时,上面我们是直接开始劫持,现在我们修改一下,在劫持之前进行判断:是数组还是对象。

  • 如果数组的某一项是对象,我们也需要监测

  • 实现数组的监测:我们需要重写数组的方法,使用户调用数组的常见方法(push unshift pop…)时,我们可以监测到

import {
    
    arrayMethods} from './array';
class Observer {
    
     // 观测值
    constructor(value){
    
    
        if(Array.isArray(value)){
    
    
            value.__proto__ = arrayMethods; // 重写数组原型方法
            this.observeArray(value); //这里实现了对数组某一项是对象进行监测的情况
        }else{
    
    
            this.walk(value);
        }
    }
    observeArray(value){
    
    
        for(let i = 0 ; i < value.length ;i ++){
    
    
            observe(value[i]); //如果数组的某一项是对象,那就继续进行监测
        }
    }
}

重新数组原型方法

  • 我们需要保留数组的原有方法,使数组也可以正常调用它原生的方法(reverse concat…)
  • 并且需要重写部分方法
let oldArrayProtoMethods = Array.prototype;//获取数组的原型
export let arrayMethods = Object.create(oldArrayProtoMethods);//这里就实现了保留数组原有的方法
let methods = [//找到所有变异方法(这些方法会修改原数组)
    'push',
    'pop',
    'shift',
    'unshift',
    'reverse',
    'sort',
    'splice'
];
methods.forEach(method => {
    
    
    arrayMethods[method] = function (...args) {
    
     //这里重写了数组的方法
        const result = oldArrayProtoMethods[method].apply(this, args); //内部调用原来的方法

        let inserted;//我们需要对新增的数据再次进行劫持,该变量存放新增的数据值
        switch (method) {
    
    
            case 'push':
            case 'unshift':
                inserted = args;
                break;
            case 'splice':
                inserted = args.slice(2)
            default:
                break;
        }
        if (inserted){
    
    
          // 对新增的每一项进行观测
          //...
        }
        return result
    }
})

在上面的代码中,我们提到: 需要对对新增的每一项进行观测。

那就需要继续调用Observer类上的observeArray(*data*)方法,

但是我们在这里又调用不到,如何实现呢?

既然要对新增的数据进行观测:那么还是要调用最初监测数组的方法来对新增数据进行观测

那个新增的方法在c实例的observeArray函数,所以我们要拿到Observe类上的data

怎么拿? 在Observe类中,我们需要绑定了this到__ob__属性上,即data.__ob__ = this;,其中this指的就是Observe实例,这样我们就将Observe实例绑定到了data的__ob__属性上,并且在这里:根据这里被调用的位置可知,这里的this就是class Observe的实例对象

所以,我们直接拿一个变量来承接Observe实例 -> let ob = this.__ob__;(在上面已经声明过了)

此时ob身上就有了Observer实例,那么也就有了observeArray函数,现在就可以实现对新增的数据进行观测了。

首先在Observer类上绑定一下__ob__属性:

class Observer{
    
    
	constructor(data){
    
    
		data.__ob__ = this;//绑定了this到`__ob__`属性上
		if(Array.isArray(data)){
    
     //是数组
			//...
		}else{
    
     //是对象
			this.walk(data);
		}
	}
	walk(data){
    
    
		//...
	}
}

然后在重新数组方法的遍历中实现对新增数据的观测

const ob = this.__ob__;

if (inserted) ob.observeArray(inserted); // 对新增的每一项进行观测

关于__ob__属性

data.ob = this; //将this实例绑定到__ob__属性上

不仅是将this绑定到__ob__属性上,方便再次对新增的数组内容进行观测(具体观测在array.js文件中,上面已经体现过了)

而且也给数据加了一个标识:因为在最初的入口中,我们需要判断当前数据是否被劫持过了,如果已经被劫持了,那就直接返回:

如果一个对象被劫持过了,那就不需要再被劫持了(要判断一个对象是否被劫持过,可以增添一个实例,用实例来判断是否被劫持过)

既然是用一个实例来判断当前数据是否被劫持过了:

那么这个 __ob__ 属性就是一个标识,如果数据已经被劫持了,那么它的实例身上一定有一个__ob__的标识

//__ob__的具体代码在 observe(data)函数中体现
export function observe(data) {
    
    
  //对对象进行劫持
  if(typeof data !== 'object' || data == null){
    
    
    return; //只对对象进行劫持
  }

  if(data.__ob__ instanceof Observer){
    
     //说明这个对象被代理过了
    return data.__ob__;
  }
  //如果一个对象被劫持过了,那就不需要再被劫持了(要判断一个对象是否被劫持过,可以增添一个实例,用实例来判断是否被劫持过)

  return new Observer(data); //我们返回的是一个被劫持过的对象
}

增加这个__ob__属性,其实会有一点问题 -> 会产生死循环

因为给当前类的实例增加了这个属性的值是this代指当前对象

当我们对进行属性是对象时进行劫持并且遍历各个属性值时,走到defineReactive函数(这个函数是对对象的每一项进行监测的函数),进行set设置时,如果出现设置的值是一个新的对象,又会走observe函数,然后又会进到类的实例中,在类的实例中,又会对对象进行劫持,然后就会产生死循环

如何解决? 将这个属性变成不可枚举型的,

    Object.defineProperty(data, '__ob__', {
    
    
      value: this,
      enumerable: false //将__ob__变成不可枚举型(循环时无法获取到)
    })
    //上面就是一个定义属性,不必要再写 data.__ob__ = this; 这句代码

猜你喜欢

转载自blog.csdn.net/weixin_52834435/article/details/127261061