乐趣区

关于前端:vue源码分析响应式系统三

上一节,咱们深入分析了以 data,computed 为数据创立响应式零碎的过程,并对其中依赖收集和派发更新的过程进行了具体的剖析。然而在应用和剖析过程中仍然存在或多或少的问题,这一节咱们将针对这些问题开展剖析,最初咱们也会剖析一下 watch 的响应式过程。这篇文章将作为响应式系统分析的完结篇。

7.12 数组检测

在之前介绍数据代理章节,咱们曾经具体介绍过 Vue 数据代理的技术是利用了 Object.defineProperty,Object.defineProperty 让咱们能够不便的利用存取描述符中的 getter/setter 来进行数据的监听, 在 get,set 钩子中别离做不同的操作,达到数据拦挡的目标。然而 Object.definePropertyget,set办法只能检测到对象属性的变动,对于数组的变动 (例如插入删除数组元素等操作),Object.defineProperty 却无奈达到目标, 这也是利用 Object.defineProperty 进行数据监控的缺点,尽管 es6 中的 proxy 能够完满解决这一问题,但毕竟有兼容性问题,所以咱们还须要钻研 VueObject.defineProperty的根底上如何对数组进行监听检测。

7.12.1 数组办法的重写

既然数组曾经不能再通过数据的 getter,setter 办法去监听变动了,Vue的做法是对数组办法进行重写,在保留原数组性能的前提下,对数组进行额定的操作解决。也就是从新定义了数组办法。

var arrayProto = Array.prototype;
// 新建一个继承于 Array 的对象
var arrayMethods = Object.create(arrayProto);

// 数组领有的办法
var methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
];

arrayMethods是基于原始 Array 类为原型继承的一个对象类,因为原型链的继承,arrayMethod领有数组的所有办法,接下来对这个新的数组类的办法进行改写。

methodsToPatch.forEach(function (method) {
  // 缓冲原始数组的办法
  var original = arrayProto[method];
  // 利用 Object.defineProperty 对办法的执行进行改写
  def(arrayMethods, method, function mutator () {});
});

function def (obj, key, val, enumerable) {
    Object.defineProperty(obj, key, {
      value: val,
      enumerable: !!enumerable,
      writable: true,
      configurable: true
    });
  }

这里对数组办法设置了代理,当执行 arrayMethods 的数组办法时,会代理执行 mutator 函数,这个函数的具体实现,咱们放到数组的派发更新中介绍。

仅仅创立一个新的数组办法合集是不够的,咱们在拜访数组时,如何不调用原生的数组办法,而是将过程指向这个新的类,这是下一步的重点。

回到数据初始化过程,也就是执行 initData 阶段,上一篇内容花了大篇幅介绍过数据初始化会为 data 数据创立一个 Observer 类,过后咱们只讲述了 Observer 类会为每个非数组的属性进行数据拦挡,从新定义 getter,setter 办法, 除此之外对于数组类型的数据,咱们无意跳过剖析了。这里,咱们重点看看对于数组拦挡的解决。

var Observer = function Observer (value) {
  this.value = value;
  this.dep = new Dep();
  this.vmCount = 0;
  // 将__ob__属性设置成不可枚举属性。内部无奈通过遍历获取。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);
  }
}

数组解决的分支分为两个,hasProto的判断条件,hasProto用来判断以后环境下是否反对 __proto__ 属性。而数组的解决会依据是否反对这一属性来决定执行 protoAugment, copyAugment 过程,

// __proto__属性的判断
var hasProto = '__proto__' in {};

当反对 __proto__ 时,执行 protoAugment 会将以后数组的原型指向新的数组类arrayMethods, 如果不反对__proto__,则通过代理设置,在拜访数组办法时代理拜访新数组类中的数组办法。

// 间接通过原型指向的形式

function protoAugment (target, src) {target.__proto__ = src;}

// 通过数据代理的形式
function copyAugment (target, src, keys) {for (var i = 0, l = keys.length; i < l; i++) {var key = keys[i];
    def(target, key, src[key]);
  }
}

有了这两步的解决,接下来咱们在实例外部调用 push, unshift 等数组的办法时,会执行 arrayMethods 类的办法。这也是数组进行依赖收集和派发更新的前提。

7.12.2 依赖收集

因为数据初始化阶段会利用 Object.definePrototype 进行数据拜访的改写,数组的拜访同样会被 getter 所拦挡。因为是数组,拦挡过程会做非凡解决,前面咱们再看看 dependArray 的原理。

function defineReactive###1() {
  ···
  var childOb = !shallow && observe(val);

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

childOb是标记属性值是否为根底类型的标记,observe如果遇到根本类型数据,则间接返回,不做任何解决,如果遇到对象或者数组则会递归实例化 Observer,会为每个子属性设置响应式数据,最终返回Observer 实例。而实例化 Observer 又回到之前的老流程:增加 __ob__ 属性,如果遇到数组则进行原型重指向,遇到对象则定义getter,setter,这一过程后面剖析过,就不再论述。

在拜访到数组时,因为 childOb 的存在,会执行 childOb.dep.depend(); 进行依赖收集,该 Observer 实例的 dep 属性会收集以后的 watcher 作为依赖保留,dependArray保障了如果数组元素是数组或者对象,须要递归去为外部的元素收集相干的依赖。

function dependArray (value) {for (var e = (void 0), i = 0, l = value.length; i < l; i++) {e = value[i];
      e && e.__ob__ && e.__ob__.dep.depend();
      if (Array.isArray(e)) {dependArray(e);
      }
    }
  }

咱们能够通过截图看最终依赖收集的后果。

参考 前端进阶面试题具体解答

收集前

收集后

7.12.3 派发更新

当调用数组的办法去增加或者删除数据时,数据的 setter 办法是无奈拦挡的,所以咱们惟一能够拦挡的过程就是调用数组办法的时候,后面介绍过,数组办法的调用会代理到新类 arrayMethods 的办法中, 而 arrayMethods 的数组办法是进行重写过的。具体咱们看他的定义。

 methodsToPatch.forEach(function (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);
          break
      }
      if (inserted) {ob.observeArray(inserted); }
      // notify change
      ob.dep.notify();
      return result
    });
  });

mutator是重写的数组办法,首先会调用原始的数组办法进行运算,这保障了与原始数组类型的办法一致性,args保留了数组办法调用传递的参数。之后取出数组的 __ob__ 也就是之前保留的 Observer 实例,调用 ob.dep.notify(); 进行依赖的派发更新,后面晓得了。Observer实例的 depDep的实例,他收集了须要监听的 watcher 依赖,而 notify 会对依赖进行从新计算并更新。具体看 Dep.prototype.notify = function notify () {} 函数的剖析,这里也不反复赘述。

回到代码中,inserted变量用来标记数组是否是减少了元素,如果减少的元素不是原始类型,而是数组对象类型,则须要触发 observeArray 办法,对每个元素进行依赖收集。

Observer.prototype.observeArray = function observeArray (items) {for (var i = 0, l = items.length; i < l; i++) {observe(items[i]);
  }
};

总的来说。数组的扭转不会触发 setter 进行依赖更新,所以 Vue 创立了一个新的数组类,重写了数组的办法,将数组办法指向了新的数组类。同时在拜访到数组时仍旧触发 getter 进行依赖收集,在更改数组时,触发数组新办法运算,并进行依赖的派发。

当初咱们回过头看看 Vue 的官网文档对于数组检测时的注意事项:

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

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

显然有了上述的剖析咱们很容易了解数组检测带来的弊病,即便 Vue 重写了数组的办法,以便在设置数组时进行拦挡解决,然而不论是通过索引还是间接批改长度,都是无奈触发依赖更新的。

7.13 对象检测异样

咱们在理论开发中常常遇到一种场景,对象 test: {a: 1} 要增加一个属性 b, 这时如果咱们应用test.b = 2 的形式去增加,这个过程 Vue 是无奈检测到的,理由也很简略。咱们在对对象进行依赖收集的时候,会为对象的每个属性都进行收集依赖,而间接通过 test.b 增加的新属性并没有依赖收集的过程,因而当之后数据 b 产生扭转时也不会进行依赖的更新。

了解决这一问题,Vue提供了 Vue.set(object, propertyName, value) 的静态方法和 vm.$set(object, propertyName, value) 的实例办法,咱们看具体怎么实现新属性的依赖收集过程。

Vue.set = set
function set (target, key, val) {
    //target 必须为非空对象
    if (isUndef(target) || isPrimitive(target)
    ) {warn(("Cannot set reactive property on undefined, null, or primitive value:" + ((target))));
    }
    // 数组场景,调用重写的 splice 办法,对新增加属性收集依赖。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
    }
    // 拿到指标源的 Observer 实例
    var ob = (target).__ob__;
    if (target._isVue || (ob && ob.vmCount)) {
      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
    }
    // 手动调用 defineReactive,为新属性设置 getter,setter
    defineReactive###1(ob.value, key, val);
    ob.dep.notify();
    return val
  }

依照分支分为不同的四个解决逻辑:

  1. 指标对象必须为非空的对象,能够是数组,否则抛出异样。
  2. 如果指标对象是数组时,调用数组的 splice 办法,而后面剖析数组检测时,遇到数组新增元素的场景,会调用 ob.observeArray(inserted) 对数组新增的元素收集依赖。
  3. 新增的属性值在原对象中曾经存在,则手动拜访新的属性值,这一过程会触发依赖收集。
  4. 手动定义新属性的 getter,setter 办法,并通过 notify 触发依赖更新。

7.14 nextTick

在上一节的内容中,咱们说到数据批改时会触发 setter 办法进行依赖的派发更新,而更新时会将每个 watcher 推到队列中,期待下一个 tick 到来时再执行 DOM 的渲染更新操作。这个就是异步更新的过程。为了阐明异步更新的概念,须要牵扯到浏览器的事件循环机制和最优的渲染机会问题。因为这不是文章的主线,我只用简略的语言概述。

7.14.1 事件循环机制

  1. 残缺的事件循环机制须要理解两种异步队列:macro-taskmicro-task
  2. macro-task常见的有 setTimeout, setInterval, setImmediate, script 脚本, I/ O 操作,UI 渲染
  3. micro-task常见的有 promise, process.nextTick, MutationObserver
  4. 残缺事件循环流程为:
    4.1 micro-task空,macro-task队列只有 script 脚本,推出 macro-taskscript工作执行,脚本执行期间产生的 macro-task,micro-task 推到对应的队列中
    4.2 执行全副micro-task 里的微工作事件
    4.3 执行DOM 操作,渲染更新页面
    4.4 执行web worker 等相干工作
    4.5 循环,取出macro-task 中一个宏工作事件执行,反复 4 的操作。

从下面的流程中咱们能够发现,最好的渲染过程产生在微工作队列的执行过程中,此时他离页面渲染过程最近,因而咱们能够借助微工作队列来实现异步更新,它能够让简单批量的运算操作运行在 JS 层面,而视图的渲染只关怀最终的后果,这大大降低了性能的损耗。

举一个这一做法益处的例子:
因为 Vue 是数据驱动视图更新渲染,如果咱们在一个操作中反复对一个响应式数据进行计算,例如 在一个循环中执行 this.num ++ 一千次,因为响应式零碎的存在,数据变动触发 settersetter 触发依赖派发更新,更新调用 run 进行视图的从新渲染。这一次循环,视图渲染要执行一千次,很显著这是很节约性能的,咱们只须要关注最初第一千次在界面上更新的后果而已。所以利用异步更新显得分外重要。

7.14.2 根本实现

Vue用一个 queue 收集依赖的执行,在下次微工作执行的时候对立执行 queueWatcherrun 操作, 与此同时,雷同 idwatcher不会反复增加到 queue 中, 因而也不会反复执行屡次的视图渲染。咱们看 nextTick 的实现。

// 原型上定义的办法
Vue.prototype.$nextTick = function (fn) {return nextTick(fn, this)
};
// 构造函数上定义的办法
Vue.nextTick = nextTick;

// 理论的定义
var callbacks = [];
function nextTick (cb, ctx) {
    var _resolve;
    // callbacks 是保护微工作的数组。callbacks.push(function () {if (cb) {
        try {cb.call(ctx);
        } catch (e) {handleError(e, ctx, 'nextTick');
        }
      } else if (_resolve) {_resolve(ctx);
      }
    });
    if (!pending) {
      pending = true;
      // 将保护的队列推到微工作队列中保护
      timerFunc();}
    // nextTick 没有传递参数,且浏览器反对 Promise, 则返回一个 promise 对象
    if (!cb && typeof Promise !== 'undefined') {return new Promise(function (resolve) {_resolve = resolve;})
    }
  }

nextTick定义为一个函数,应用形式为 Vue.nextTick([callback, context] ), 当callback 通过 nextTick 封装后,callback会在下一个 tick 中执行调用。从实现上,callbacks是一个保护了须要在下一个 tick 中执行的工作的队列,它的每个元素都是须要执行的函数。pending是判断是否在期待执行微工作队列的标记。而 timerFunc 是真正将工作队列推到微工作队列中的函数。咱们看 timerFunc 的实现。

1. 如果浏览器执行 Promise, 那么默认以Promsie 将执行过程推到微工作队列中。

var timerFunc;

if (typeof Promise !== 'undefined' && isNative(Promise)) {var p = Promise.resolve();
  timerFunc = function () {p.then(flushCallbacks);
    // 手机端的兼容代码
    if (isIOS) {setTimeout(noop); }
  };
  // 应用微工作队列的标记
  isUsingMicroTask = true;
}

flushCallbacks是异步更新的函数,他会取出 callbacks 数组的每一个工作,执行工作,具体定义如下:

function flushCallbacks () {
  pending = false;
  var copies = callbacks.slice(0);
  // 取出 callbacks 数组的每一个工作,执行工作
  callbacks.length = 0;
  for (var i = 0; i < copies.length; i++) {copies[i]();}
}

2. 不反对promise, 反对MutataionObserver

else if (!isIE && typeof MutationObserver !== 'undefined' && (isNative(MutationObserver) ||
    // PhantomJS and iOS 7.x
    MutationObserver.toString() === '[object MutationObserverConstructor]'
  )) {
    var counter = 1;
    var observer = new MutationObserver(flushCallbacks);
    var textNode = document.createTextNode(String(counter));
    observer.observe(textNode, {characterData: true});
    timerFunc = function () {counter = (counter + 1) % 2;
      textNode.data = String(counter);
    };
    isUsingMicroTask = true;
  }

3. 如果不反对微工作办法,则会应用宏工作办法,setImmediate会先被应用

 else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    // Fallback to setImmediate.
    // Techinically it leverages the (macro) task queue,
    // but it is still a better choice than setTimeout.
    timerFunc = function () {setImmediate(flushCallbacks);
    };
  }

4. 所有办法都不适宜,会应用宏工作办法中的setTimeout

else {timerFunc = function () {setTimeout(flushCallbacks, 0);
  };
}

nextTick 不传递任何参数时,能够作为一个 promise,例如:

nextTick().then(() => {})

7.14.3 应用场景

说了这么多原理性的货色,回过头来看看 nextTick 的应用场景,因为异步更新的原理,咱们在某一时间扭转的数据并不会触发视图的更新,而是须要等下一个 tick 到来时才会更新视图,上面是一个典型场景:

<input v-if="show" type="text" ref="myInput">

// js
data() {show: false},
mounted() {
  this.show = true;
  this.$refs.myInput.focus();// 报错}

数据扭转时,视图并不会同时扭转,因而须要应用nextTick

mounted() {
  this.show = true;
  this.$nextTick(function() {this.$refs.myInput.focus();// 失常
  })
}

7.15 watch

到这里,对于响应式零碎的剖析大部分内容曾经剖析结束,咱们上一节还遗留着一个问题,Vue对用户手动增加的 watch 如何进行数据拦挡。咱们先看看两种根本的应用模式。

// watch 选项
var vm = new Vue({
  el: '#app',
  data() {
    return {num: 12}
  },
  watch: {num() {}}
})
vm.num = 111

// $watch api 形式
vm.$watch('num', function() {}, {
  deep: ,
  immediate: ,
})

7.15.1 依赖收集

咱们以 watch 选项的形式来剖析 watch 的细节,同样从初始化说起,初始化数据会执行 initWatch,initWatch 的外围是createWatcher

function initWatch (vm, watch) {for (var key in watch) {var handler = watch[key];
      // handler 能够是数组的模式,执行多个回调
      if (Array.isArray(handler)) {for (var i = 0; i < handler.length; i++) {createWatcher(vm, key, handler[i]);
        }
      } else {createWatcher(vm, key, handler);
      }
    }
  }

  function createWatcher (vm,expOrFn,handler,options) {
    // 针对 watch 是对象的模式,此时回调回选项中的 handler
    if (isPlainObject(handler)) {
      options = handler;
      handler = handler.handler;
    }
    if (typeof handler === 'string') {handler = vm[handler];
    }
    return vm.$watch(expOrFn, handler, options)
  }

无论是选项的模式,还是 api 的模式,最终都会调用实例的 $watch 办法,其中 expOrFn 是监听的字符串,handler是监听的回调函数,options是相干配置。咱们重点看看 $watch 的实现。

Vue.prototype.$watch = function (expOrFn,cb,options) {
    var vm = this;
    if (isPlainObject(cb)) {return createWatcher(vm, expOrFn, cb, options)
    }
    options = options || {};
    options.user = true;
    var watcher = new Watcher(vm, expOrFn, cb, options);
    // 当 watch 有 immediate 选项时,立刻执行 cb 办法,即不须要期待属性变动,立即执行回调。if (options.immediate) {
      try {cb.call(vm, watcher.value);
      } catch (error) {handleError(error, vm, ("callback for immediate watcher \"" + (watcher.expression) + "\""));
      }
    }
    return function unwatchFn () {watcher.teardown();
    }
  };
}

$watch的外围是创立一个 user watcher,options.user 是以后用户定义 watcher 的标记。如果有 immediate 属性,则立刻执行回调函数。
而实例化 watcher 时会执行一次 getter 求值,这时,user watcher会作为依赖被数据所收集。这个过程能够参考 data 的剖析。

var Watcher = function Watcher() {
  ···
  this.value = this.lazy
      ? undefined
      : this.get();}

Watcher.prototype.get = function get() {
  ···
  try {
    // getter 回调函数,触发依赖收集
    value = this.getter.call(vm, vm);
  } 
}

7.15.2 派发更新

watch派发更新的过程很好了解,数据产生扭转时,setter拦挡对依赖进行更新,而此前 user watcher 曾经被当成依赖收集了。这个时候依赖的更新就是回调函数的执行。

7.16 小结

这一节是响应式零碎构建的完结篇,data,computed如何进行响应式零碎设计,这在上一节内容曾经详细分析,这一节针对一些非凡场景做了剖析。例如因为 Object.defineProperty 本身的缺点,无奈对数组的新增删除进行拦挡检测,因而 Vue 对数组进行了非凡解决,重写了数组的办法,并在办法中对数据进行拦挡。咱们也重点介绍了 nextTick 的原理,利用浏览器的事件循环机制来达到最优的渲染机会。文章的最初补充了 watch 在响应式设计的原理,用户自定义的 watch 会创立一个依赖,这个依赖在数据扭转时会执行回调。

退出移动版