tips:本系列博客的代码部分(示例等除外),均出自vue源码内容,版本为2.6.14。但是为了增加易读性,会对不相关内容做选择性省略。如果大家想了解完整的源码,建议自行从官方下载。https://github.com/vuejs/vuehttps://github.com/vuejs/vue

本系列博客 ( 不定期更新 ):

【VUE】源码分析 - computed计算属性的实现原理_依然范特西fantasy的博客-CSDN博客

【VUE】源码分析 - watch侦听器的实现原理_依然范特西fantasy的博客-CSDN博客

【VUE】源码分析 - 数据劫持的基本原理_依然范特西fantasy的博客-CSDN博客

一,基本实现原理

1,核心原理:defineProperty 和 Array的实例方法劫持

defineProperty是JS的元编程手段之一。Object类型,可以通过defineProperty来实现对数据的劫持监听。

vue中的defineReactive函数正是做了这样一件事。其简化形式如下:

function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      //收集依赖
      return val
    },
    set(newValue) {
      //触发依赖
      val = newValue
    },
  });
}

通过对obj的defineProperty,在其get和set函数中做了某些操作,其中就包括了依赖的收集,和对依赖的触发。get和set函数对defineReactive函数闭包中val的操作,从而实现私有变量的对外隐蔽。

而对于数组类型的数据劫持,vue给出的方案是对其某些实例方法的进行劫持。

const methodsToPatch = [
  "push",
  "pop",
  "shift",
  "unshift",
  "splice",
  "sort",
  "reverse",
];

methodsToPatch.forEach(function (method) {
  const original = Array.property[method];
  def(arrayMethods, method, function mutator(...args) {
    //拦截Array的几个原型方法,监听触发,并下发通知
    const result = original.apply(this, args);
    return result;
  });
});

当使用这几种方法操作数组的时候,就可以正常的触发依赖了。

2,Watcher简介

Watcher通俗的理解,就是vue中观察者实例,其有三种类型:当前组件实例的Watcher,watch的回调函数,computed的get函数。

我们暂时不需要深究其具体的实现细节,只需要做如下理解:这些观察者会在数据变化的时候接收通知,并给出相应的行为。

3,Dep简介

Dep,即为一个个的依赖。在一个vue实例中,会有很多Dep实例,其中每一个数据,都会有其对应的Dep。比如:

data(){
  return {
    information:{
      name:{
        shortName:'abc'
      }
    }
  }
}

上述例子中,information、name、shortName 都存在其相应的Dep。而Dep,就是去通知Watcher更新的那一个角色。一个Dep会有很多Watcher,一个Watcher也会有多个Dep。

以上是对数据劫持基本知识点的简介,下面步入正题。

二,收集依赖的实现

在收集依赖的过程中,Object和Array在vue中有不同的实现。

1,Object类型的依赖收集

通过上述的介绍我们知道,Object依赖的收集是在defineProperty的get中实现的,那么现在先单独来看defineProperty中的get函数:

function defineReactive(obj, key, value) {
  const dep = new Dep();
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      if (Dep.target) {
        dep.depend();
      }
      return value;
    },
  });
}

与之前所说的一样,每一个key,都会在其defineReactive函数闭包中创建一个Dep。而Dep.target,是一个全局唯一的属性,会在每一次需要收集依赖的时候,将某个Watcher设置为Dep.target,并在执行完之后立即重置为null。如下就是Watcher中设置target的过程:

get () {
  //设置target
  pushTarget(this)
  //触发get
  let value = this.getter.call(vm, vm)
  //释放target
  popTarget()
  return value
}

而dep.depend(),就是将target添加上此依赖项。

depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

而与此同时,随着target.addDep()触发,target也会被添加至Dep的subs中。

addDep (dep) {
     //此处还会有对重复依赖的过滤,但与现在所讲的内容无关,将其忽略
     this.deps.push(dep)
     dep.addSub(this)
}

以上,就完成了通过get的依赖收集。但是不知道大家有没有注意到这样一个问题:新赋值(一开始就存在)的某个属性,其设置的值也会变成响应式的;但是新设置(一开始不存在)的某个属性,却不会变成响应式的。这是因为,其实在defineReactive的时候,会在set的函数内部,也为新赋值的内容执行"defineReactive"操作。如:

function defineReactive(obj, key, value) {
  const dep = new Dep();
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    set: function reactiveSetter(newVal) {
      val = newVal
      //会在observe递归的将每一个属性都执行defineReactive操作
      observe(newVal);
    },
  });
}

因为当前key原本就存在,因此其set方法中已经实现了对新设置值添加observe。

而如果是一个新设置的key,是没有对其执行过defineReactive的,因此,也就不存在"响应式"了。

2,数组的依赖收集

数组中依赖的收集,就显得简单粗暴许多:

function defineReactive(obj, key, value) {
  const dep = new Dep();
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      if (Array.isArray(value)) {
        //dependArray就是递归数组的每一项,并手动添加依赖
        dependArray(value);
      }
      return value;
    },
  });
}

无论数据类型是什么,其终归是data的某个属性的值。在对data进行defineReactive的时候,如果发现值为数组,那么就会对这个数组进行"暴力依赖"。也就是无论用到的是数组的那一层,都会将数组的每一项的依赖给添加至Watcher上。如:

<template> <div :arr="arr"></div> <template>

data(){
  return {
    arr:[[[[1]]]]
  }
}

arr是一个嵌套了4层的数组,在div中只用到了最外层。可是此时最内层数组的改变,依旧会触发div的响应。而如果是Object类型,则不会有这种问题。对于Object类型,用到了几层,就响应几层的变化。

3,$set的依赖收集

在之前有提到过,如果设置的属性是之前没有定义了,那么其就不会再是响应式的了。而vue对于这种情况,也给出了解决方案,this.$set。比如:

data(){
  return {
    info:{}
  }
},
created(){
  //直接新增的name属性并不是响应式的。
  this.info.name = 'zhang san'
  //通过$set新增的age属性是响应式的,对其的改变,能触发页面的刷新,watcher的回调,computed的更新
  this.$set(this.info,'age',22)
}

同样的,由于底层实现的原因,对于数组的某一项的操作,是无法触发响应的。即使它是一开始就存在的某一项:

data(){
  return {
    arr:[1,2,3]
  }
},
created(){
  //不会触发响应!
  arr[1] = 123
  //需要通过$set
  this.$set(arr,1,123)
}

其原因是对于数组的项,并没有通过Object.defineProperty去定义其get和set方法。

实际上,数组也可以通过defineProperty定义,因为数组也是对象的一种。之所以不使用,是因为数组的某些方法在操作项的时候,会表现得很"怪异"。如,unshift会触发下标为0的set,这并没有什么问题,但是同时也会触发其的get方法。并且在实际日常使用中,对数组的操作更多的是基于实例方法的操作,因此,vue选择另外一种方案——操作原型方法,来实现数组的数据劫持。

对于$set,我们暂时不需要去研究其实现原理,只需要知道,在$set内部,也使用了defineReactive(obj, key, val);方法,去为新增的属性添加依赖。

Vue.prototpye.$set = function(obj,key,val){
  defineReactive(obj, key, val);
}

三,如何触发依赖

1,Object类型依赖触发的实现

正如之前所说,Object触发依赖是通过set方法实现的:

function defineReactive(obj, key, value) {
  const dep = new Dep();
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    set() {
      val = newVal;
      dep.notify();
    },
  });
}

其中,在上述set函数中的notify(),就是一个通知所有相关Watcher的方法:

notify() {
  for (let i = 0, l = subs.length; i < l; i++) {
    subs[i].update();
  }
}

因此,只需要相关属性值发生了变化,那么就会触发相应的依赖项。

<template>
      <son1 :information="information"></son1>
      <son2 :name="information.person.name"></son2>
</template>

data(){
  return{
    information:{person:{name:'Fantasy'}}
  }
}

如上述所示,当information发生改变的时候,son1和son2都会接到通知;而当person或者name属性发生改变的时候,只有son2会接到通知。

2,数组依赖触发的实现

相对于数组的暴力添加依赖,数组触发依赖的方式就显得优雅许多。其底层原理就是通过更改一些常见的实例方法,来做某些特殊的操作,以此触发相应依赖:

const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);

const methodsToPatch = [
  "push",
  "pop",
  "shift",
  "unshift",
  "splice",
  "sort",
  "reverse",
];

methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method];
  def(arrayMethods, method, function mutator(...args) {
    const result = original.apply(this, args);
    //__ob__就是Watcher
    const ob = this.__ob__;
    let inserted;
    switch (method) {
      case "push":
      case "unshift":
        inserted = args;
        break;
      case "splice":
        inserted = args.slice(2);
        break;
    }
    //如果Array有新增项的话,为其添加observe
    if (inserted) ob.observeArray(inserted);
    // notify change
    //下发通知
    ob.dep.notify();
    return result;
  });
});

首先简单分析一下上述代码:

1,首先取得Array原始的原型,并基于此原型创建了一个新的"vue的数组原型"。这样做的好处是,除了需要处理的几种方法,其他的数组方法依旧可以通过原型链调用,而不需要额外的添加。

2,定义了一系列需要处理的方法名,并基于这些方法名做循环,为"vue的数组原型"新增methods。

3,在循环的内部,定义这些方法。并且在这些方法之前,会先调用原始的原型方法。意味着vue并不会干预原型方法的调用,只是在此基础上添加了一些额外的操作。

4,根据事件类型的不同,做特殊处理(push,shift,splice)。这三种方法皆可用来做新增操作,因此这一步的处理,其实际上就是对某些新增项的处理。其中,对于新增的项,push,unshift为第一个参数,而splice为第三个参数。

5,对于新增项inserted做observe处理,也就是将其观察起来,变成响应式的数据。这里我们之前并没有讲到,起始对于数组的三种新增行为,也是会自动的将值变为响应式对象的,同Object的set方法一样。

6,下发通知。告诉每一个Watcher当前数组发生了变化。

7,返回结果。并且结果就为原始原型的返回值。这么做的目的,也是尽可能的不去改变原型方法的行为特性。

以上,就是整个数组触发依赖的原理及过程。并且从上述源码中可以看到,vue一共对这7种方法做了劫持处理:push,pop,shift,unshift,splice,sort,reverse。其中5种增删操作,和两种排序操作。也就意味着,如果在这7种方法之外的数组操作,vue是无法监听到的。但实际上,这几种方法已经覆盖了90%的开发场景。

3,$set触发依赖的实现

$set触发依赖有两种:1,通过数组或Object的响应式原理来触发依赖;2,vue响应式原理无法涵盖的场景,则手动触发依赖:

Vue.prototype.$set = function (target, key, val) {
  if (Array.isArray(target)) {
    //对于数组,则先更新其长度,再用splice实现添加并同时触发依赖
    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;
  }
  //如果都不满足,则手动设置,并手动触发依赖
  defineReactive(target, key, val);
  target.__ob__.dep.notify();
  return val;
};

其中有一点,当某个属性已存在之时,$set并不会改变其特性(响应或非响应)。或许是vue有意为之,又或许是个漏洞,谁知道呢!如果觉得疑惑,可以去vue的github下提交代码申请,或许会有人为你解惑。

顺带提一句,我在很多地方,看到一些对于vue的解释是很不准确的。比如子组件和父组件的update生命周期的执行顺序,其实是并没有严格的顺序可言的。但是很多人会将其解释为:

父beforeUpdate -> 子beforeUpdate -> 子undated -> 父updated。

子组件的更新依赖于父组件和父组件传递的props,但并不是说子组件更新了,父组件就一定同时更新。这个现象可以用本篇博客所讲到的依赖添加来解释:若子组件绑定了某个props的子属性,但是父组件中并不依赖于此属性,那么这个时候,若此子属性发生变化,父组件并不会更新,但是子组件会更新。

网上是一个言论过于自由的地方,或许有些人只是出于热心,误传了一些不准确的信息和言论。但是主观而言,应该对所有的这些,非官方的言论抱有怀疑的态度。比如我现在的这篇博客,大家只需要相信50%即可,因为我也只是一个vue的使用者,很多理解是主观的,有偏差的。

如果大家遇到了某些问题无法解决,那么官网一定作为你的第一渠道。其他途径,仅供参考。

文中内容均带有个人理解,并不保证权威。若有错误,欢迎随时批评指正。

Logo

前往低代码交流专区

更多推荐