深入了解Vue响应式系统

前言

前面几篇文章一直都以源码分析为主,其实枯燥无味,对于新手玩家来说很不友好。这篇文章主要讲讲 Vue 的响应式系统,形式与前边的稍显 不同吧,分析为主,源码为辅,如果能达到深入浅出的效果那就更好了。

什么是响应式系统

「响应式系统」一直以来都是我认为 Vue 里最核心的几个概念之一。想深入理解 Vue ,首先要掌握「响应式系统」的原理。

从一个官方的例子开始

由于 Vue 不允许动态添加根级响应式属性,所以你必须在初始化实例前声明所有根级响应式属性,哪怕只是一个空值:


var vm = new Vue({
  data: {
    // 声明 message 为一个空值字符串
    message: ''
  },
  template: '<div>{{ message }}</div>'
})
// 之后设置 `message`
vm.message = 'Hello!'
复制代码

如果你未在 data 选项中声明 message,Vue 将警告你渲染函数正在试图访问不存在的属性。

当然,仅仅从上面这个例子我们也只能知道,Vue不允许动态添加根级响应式属性。这意味我们需要将使用到的变量先在data函数中声明。

抛砖?引玉

新建一个空白工程,加入以下代码

export default {
    name: 'JustForTest',
    data () {
        return {}
    },
    created () {
        this.b = 555
        console.log(this.observeB)
        this.b = 666
        console.log(this.observeB)
    },
    computed: {
    	observeB () {
            return this.b
    	}
    }
}
复制代码

运行上述代码,结果如下:

555
555
复制代码
在上面的代码中我们做了些什么?
  1. 没有在 data 函数中声明变量(意味着此时没有根级响应式属性)
  2. 定义了一个 computed 属性 —— observeB ,用来返回(监听)变量b
  3. 使用了变量 b 同时赋值 555 ,打印 this.observeB
  4. 使用了变量 b 同时赋值 666 ,打印 this.observeB
打印结果为什么都是555

有段简单的代码可以解释这个原因:

function createComputedGetter (key) {
  return function computedGetter () {
    var watcher = this._computedWatchers && this._computedWatchers[key];
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate();
      }
      if (Dep.target) {
        watcher.depend();
      }
      return watcher.value
    }
  }
}
...
Watcher.prototype.evaluate = function evaluate () {
  this.value = this.get();
  this.dirty = false;
};
复制代码

createComputedGetter函数返回一个闭包函数并挂载在computed属性的getter上,一旦触发computed属性的getter, 那么就会调用computedGetter

显然,输出 555 是因为触发了 this.observeBgetter ,从而触发了 computedGetter ,最后执行 Watcher.evalute()
然而,决定 watcher.evalute() 函数执行与否与 watcherwatcher.dirty 的值是否为空有关

深入了解响应式系统

Object.defineProperty

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

那么这个函数应该怎么使用呢?给个官方的源码当做例子:

function def (obj, key, val, enumerable) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  });
}
def(value, '__ob__', this);
复制代码

gettersetter

上面提到了 Object.defineProperty 函数,其实这个函数有个特别的参数 —— descriptor(属性描述符),简单看下MDN 上的定义:

对象里目前存在的属性描述符有两种主要形式:数据描述符和存取描述符。数据描述符是一个具有值的属性,该值可能是可写的,也可能不是 可写的。存取描述符是由getter-setter函数对描述的属性。描述符必须是这两种形式之一;不能同时是两者。

其中需要特别提到的就是 gettersetter,在 descriptor(属性描述符)中分别代表 get 方法和 set 方法

get

一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。当访问该属性时,该方法会被执行,方法执行时没有参数传入, 但是会传入this对象(由于继承关系,这里的this并不一定是定义该属性的对象)。

set

一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。当属性值修改时,触发执行该方法。该方法将接受唯一参数, 即该属性新的参数值。

小结
  1. 对象在被访问时会触发getter
  2. 对象在被赋值是会触发setter
  3. 利用getter我们可以知道哪些对象被使用了
  4. 利用setter我们可以知道哪些对象被赋值了

依赖收集

Vue基于Object.defineProperty函数,可以对变量进行依赖收集,从而在变量的值改变时触发视图的更新。简单点来讲就是: Vue需要知道用到了哪些变量,不用的变量就不管,在它(变量)变化时,Vue就通知对应绑定的视图进行更新。 举个例子:

 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();
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) { return }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      childOb = !shallow && observe(newVal);
      dep.notify();
    }
  });
复制代码

这段代码做了哪些事情呢?主要有以下几点:

  • 对于 obj[key],定义它的 getset 函数
  • obj[key] 被访问时,触发 get 函数,调用 dep.depend 函数收集依赖
  • obj[key] 被赋值时,调用 set 函数,调用 dep.notify 函数触发视图更新

如果你再深入探究下去,那么还会发现 dep.notify 函数里还调用了 update 函数,而它恰好就是 Watcher 类所属 的方法,上面所提到的 computed 属性的计算方法也恰好也属于 Watcher

Observer

前面所提到的 Object.defineProperty 函数到底是在哪里被调用的呢?答案就是 initData 函数和 Observer类。 可以归纳出一个清晰的调用逻辑:

  • 初始化 data 函数,此时调用 initData 函数
  • 在调用 initData 函数时,执行 observe 函数,这个函数执行成功后会返回一个 ob 对象
  • observe 函数返回的 ob 对象依赖于 Observer 函数
  • Observer 分别对对象和数组做了处理,对于某一个属性,最后都要执行 walk 函数
  • walk 函数遍历传入的对象的 key 值,对于每个 key 值对应的属性,依次调用 defineReactive$$1 函数
  • defineReactive$$1 函数中执行 Object.defineProperty 函数
  • ...

感兴趣的可以看下主要的代码,其实逻辑跟上面描述的一样,只不过步骤比较繁琐,耐心阅读源码的话还是能看懂。

initData
function initData (vm) {
  var data = vm.$options.data;
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {};
  if (!isPlainObject(data)) {
    data = {};
    ...
  }
  // proxy data on instance
  var keys = Object.keys(data);
  var props = vm.$options.props;
  var methods = vm.$options.methods;
  var i = keys.length;
  while (i--) {
    var key = keys[i];
    ...
    if (props && hasOwn(props, key)) {
        ...
    } else if (!isReserved(key)) {
      proxy(vm, "_data", key);
    }
  }
  // observe data
  observe(data, true /* asRootData */);
}
复制代码
observe
function observe (value, asRootData) {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  var ob;
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__;
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value);
  }
  if (asRootData && ob) {
    ob.vmCount++;
  }
  return ob
}
复制代码
Observer
var Observer = function Observer (value) {
  this.value = value;
  this.dep = new Dep();
  this.vmCount = 0;
  def(value, '__ob__', this);
  if (Array.isArray(value)) {
    if (hasProto) {
      protoAugment(value, arrayMethods);
    } else {
      copyAugment(value, arrayMethods, arrayKeys);
    }
    this.observeArray(value);
  } else {
    this.walk(value);
  }
};
复制代码

更加方便的定义响应式属性

文档中提到,Vue 建议在根级声明变量。通过上面的分析我们也知道,在 data 函数中 声明变量则使得变量变成「响应式」的,那么是不是所有的情况下,变量都只能在 data 函数中 事先声明呢?

$set

Vue 其实提供了一个 $set 的全局函数,通过 $set 就可以动态添加响应式属性了。

export default {
	data () {
        return {}
    },
    created () {
        this.$set(this, 'b', 666)
    },
}
复制代码

然而,执行上面这段代码后控制台却报错了
[Vue warn]: Avoid adding reactive properties to a Vue instance or its root $data at runtime - declare it upfront in the data option.

其实,对于已经创建的实例,Vue 不允许动态添加根级别的响应式属性。 $set 函数的执行逻辑:

  • 判断实例是否是数组,如果是则将属性插入
  • 判断属性是否已定义,是则赋值后返回
  • 判断实例是否是 Vue 的实例或者是已经存在 ob 属性(其实也是判断了添加的属性是否属于根级别的属性),是则结束函数并返回
  • 执行 defineReactive$$1,使得属性成为响应式属性
  • 执行 ob.dep.notify(),通知视图更新

相关代码:

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
  }
  c(ob.value, key, val);
  ob.dep.notify();
  return val
}
复制代码

数组操作

为了变量的响应式,Vue 重写了数组的操作。其中,重写的方法就有这些:

  • push
  • pop
  • shift
  • unshift
  • splice
  • sort
  • reverse

那么这些方法是怎么重写的呢?
首先,定义一个 arrayMethods 继承 Array

var arrayProto = Array.prototype;
var arrayMethods = Object.create(arrayProto);
复制代码

然后,利用 object.defineProperty,将 mutator 函数绑定在数组操作上:

def(arrayMethods, method, function mutator () { ... })
复制代码

最后在调用数组方法的时候,会直接执行 mutator函数。源码中,对这三种方法做了特别 处理:

  • push
  • unshift
  • splice

因为这三种方法都会增加原数组的长度。当然如果调用了这三种方法,会再调用一次 observeArray 方法(这里的逻辑就跟前面提到的一样了)
最后的最后,调用 notify 函数

核心代码:

methodsToPatch.forEach(function (method) {
 // cache original method
 var original = arrayProto[method];
 def(arrayMethods, method, function mutator () {
   var args = [], len = arguments.length;
   while ( len-- ) args[ len ] = arguments[ len ];

   var result = original.apply(this, args);
   var ob = this.__ob__;
   var inserted;
   switch (method) {
     case 'push':
     case 'unshift':
       inserted = args;
       break
     case 'splice':
       inserted = args.slice(2);
   }
   if (inserted) { ob.observeArray(inserted); }
   // notify change
   ob.dep.notify();
   return result
 });
});
复制代码

总结

「响应式原理」借助了这三个类来实现,分别是:

  • Watcher
  • Observer
  • Dep

初始化阶段,利用 getter 的特点,监听到变量被访问 ObserverDep 实现对变量的「依赖收集」, 赋值阶段利用 setter 的特点,监听到变量赋值,利用 Dep 通知 Watcher,从而进行视图更新。

参考资料

深入响应式原理

扫描下方的二维码或搜索「tony老师的前端补习班」关注我的微信公众号,那么就可以第一时间收到我的最新文章。

转载于:https://juejin.im/post/5cc27c65f265da037a3cf14b

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值