【Vue2深度学习】Vue常用实例方法的实现原理

187 阅读3分钟

前言

Vue.js内部,有下面这样一段代码,其中先定义了Vue构造函数,然后分别调用了initMixinstateMixineventsMixinlifecycleMixinrenderMixin这5个函数,并加Vue构造函数当做参数传给了这5个函数。

import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}
​
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
​
export default Vue

这5个函数的作用就是向Vue的原型中挂载方法。

而我们今天要详细介绍的,正是挂载在Vue原型上的方法,即Vue.prototype上的方法。

事件相关的实例方法

与事件相关的实例方法有4个,分别是vm.$onvm.$oncevm.$offvm.$emit。这4个方法是在eventsMixin中挂载到Vue构造函数的prototype属性中的。下面分别介绍一下它们的内部实现原理。

vm.$on

vm.$on用来监听当前实例上的自定义事件。事件的实现方式,其实就是在注册事件时将回调函数收集起来,在触发事件时将收集起来的回调函数依次调用即可。Vue.js的实现方式也是如此,其代码如下:

/**
* 参数
* {string | Array<string>} event
* { Function } callback
*/
Vue.prototype.$on = function (event, fn) {
    var vm = this;
    if (Array.isArray(event)) {
        for (var i = 0, l = event.length; i < l; i++) {
          this.$on(event[i], fn);
        }
     } else {
        (vm._events[event] || (vm._events[event] = [])).push(fn);
     }
     return vm
};

在上面代码中,当event参数为数组时,需要遍历数组,将其中的每一项递归调用vm.$on,使回调可以被注册到数组中每项事件名所指定的事件列表中。当event参数不为数组时,就像事件列表中添加回调。

vm. _events是一个对象,用来存储事件。我们在执行new Vue()时,Vue会执行this.init 方法进行一系列初始化操作,其中,就会在Vue.js的实例上创建一个_events属性,用来存储事件,其代码如下:

vm.__events = Object.create(null)
vm.$off

vm.$off用来移除自定义事件监听器。如果没有提供参数,则移除所有的事件监听器;如果只提供了事件,则移除该事件所有的监听器;如果同时提供了事件与回调,则只移除这个回调的监听器。其具体实现代码如下:

/**
* 参数
* {string | Array<string>} event
* { Function } callback
*/
​
Vue.prototype.$off = function (event, fn) {
      var vm = this;
      // 移除所有事件的监听器
      if (!arguments.length) {
        vm._events = Object.create(null);
        return vm
      }
      // event支持数据
      if (Array.isArray(event)) {
        for (var i = 0, l = event.length; i < l; i++) {
          this.$off(event[i], fn);
        }
        return vm
      }
      // 移除该事件的所有监听器
      var cbs = vm._events[event];
      if (!cbs) {
        return vm
      }
      if (arguments.length === 1) {
        vm._events[event] = null;
        return vm
      }
      // specific handler
      var cb;
      var i = cbs.length;
      while (i--) {
        cb = cbs[i];
        if (cb === fn || cb.fn === fn) {
          cbs.splice(i, 1);
          break
        }
      }
      return vm
    };

我们可以看到上面有一个判断条件,当arguments.length为0时,说明没有任何参数,这时就需要移除所有的事件监听器,因此我们重置了vm._envents属性。

由于vm.$off的第一个参数event支持数组,所以记下来需要处理event参数为数组的情况。也就是将数组遍历一遍,然后数组中的每一项调用vm.$off即可。

再接下来,我们需要处理只提供了事件名的情况。如果只提供了事件名,则移除该事件所有的监听器。即将this._event重置为空即可。

最后,我们来处理最后一种情况:如果同时提供了事件与回调,那么只移除这个回调的监听器。即将参数中提供的事件名从vm._events上取出列表,然后从列表中找到与参数中提供的回调函数相同的那个函数,并将它从列表中移除。

vm.$once

vm.$once监听一个自定义事件,但是只触发一次,在第一次触发之后移除监听器。可见,vm.$oncevm.$on的区别是前者只能被触发一次,所以实现这个功能的一个思路是:在vm.$once中调用vm.$on来实现监听自定义事件的功能,当自定义事件触发后会执行拦截器,将监听从事件列表中移除。具体代码如下:

/**
* 参数
* {string | Array<string>} event
* { Function } callback
*/
​
Vue.prototype.$once = function (event, fn) {
      var vm = this;
      function on () {
        vm.$off(event, on);
        fn.apply(vm, arguments);
      }
      on.fn = fn;
      vm.$on(event, on);
      return vm
 };

当自定义事件被触发时,会先执行函数on,然后手动执行函数fn,并将参数arguments传递给函数fn,这就实现了vm.$once的功能。

vm.$emit

vm.$emit用来触发当前实例上的事件。前面我们介绍过,所有的事件监听器回调函数都会存储在vm._events中,所以触发事件的实现思路是使用事件名从vm._events中取出对应的事件监听器回调函数列表,然后依次执行列表中的监听器回调并将参数传递给监听器回调。具体代码如下:

/**
* 参数
* { string } event
* { ...args }
*/
​
Vue.prototype.$emit = function (event) {
      var vm = this;
      var cbs = vm._events[event];
      if (cbs) {
        cbs = cbs.length > 1 ? toArray(cbs) : cbs;
        var args = toArray(arguments, 1);
        for (var i = 0, l = cbs.length; i < l; i++) {
            try{
                cbs[i].apply(vm, args);   
            } catch(e) {
                
            }
          
        }
      }
      return vm
    };

这里我们使用eventvm._events中取出事件监听器回调函数列表,并将其赋值给变量cbs。如果cbs存在,则循环它,依次调用每一个监听器回调并将所有参数传给监听器回调。

toArray的作用是将类似数组的数据转换成真正的数组,它的第二个参数是起始位置。

生命周期相关的实例方法

与生命周期相关的实例方法有4个,分别是vm.$mountvm.$forceUpdatevm.$nextTickvm.$destroy。其中有两个方法是从lifecycleMixin中挂载到Vue构造函数的原型上的,分别是vm.$forceUpdatevm.$destroyvm.$nextTick方法是从renderMixin中挂载到Vue构造函数的原型上的。而vm.$mount方法则是在跨平台的代码中挂载到Vue构造函数的原型上的。

vm.$forceUpdate

vm.$forceUpdate的作用是迫使Vue.js实例重新渲染。即执行实例watcherupdate方法,就可以让实例重新渲染,其代码如下:

 Vue.prototype.$forceUpdate = function () {
    var vm = this;
    if (vm._watcher) {
        vm._watcher.update();
   }
};

vm._watcher就是Vue.js实例的watcherVue.js的自动渲染通过变化侦测来侦测数据,即当数据发生变化时,Vue.js实例重新渲染。而vm.$forceUpdate是手动通知Vue.js实例重新渲染。

vm.$destroy

vm.$destroy的作用是完全销毁一个实例,它会清理该实例与其他实例的连接,并解绑其全部指令及监听器,同时会触发beforeDestroydestroyed的钩子函数。

这个方法并不是很常用,大部分场景下并需要销毁组件,只需要使用v-if或者v-for等指令已数据驱动的方式控制子组件的生命周期即可。下面我们来看一看vm.$destory的实现原理

  Vue.prototype.$destroy = function () {
      var vm = this;
      // 防止vm.$destroy被反复执行
      // 对属性_isBeingDestroyed进行判断,如果它为true,说明Vue.js实例正在被销毁
      if (vm._isBeingDestroyed) {
        return
      }
      // 调用callHook函数触发beforeDestroy的钩子函数
      callHook(vm, 'beforeDestroy');
      vm._isBeingDestroyed = true;
      // 删除自己与父级之间的连接
      var parent = vm.$parent;
      if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) { 
          //如果当前实例有父级,同时父级没有被销毁且不是抽象组件,那么将自己从父级的子列表中删除
        remove(parent.$children, vm);
      }
      // 销毁实例自身的watcher
      if (vm._watcher) {
        vm._watcher.teardown();
      }
      // 销毁用户使用vm.$watch所创建的watcher实例
      var i = vm._watchers.length;
      while (i--) {
        vm._watchers[i].teardown();
      }
      // 添加_isDestroyed属性来表示Vue.js实例已被销毁。
      vm._isDestroyed = true;
      // 在vnode树上触发destroy钩子函数解绑指令
      vm.__patch__(vm._vnode, null);
      // 触发destroyed钩子函数
      callHook(vm, 'destroyed');
      // 最后移除实例上的所有事件监听器
      vm.$off();
  };

从上述代码可以看出,vm.$destroy的实现需要经过以下步骤:

  1. 需要在销毁组件之前,触发beforeDestroy钩子函数。
  2. 需要清理当前组件与父组件之间的连接。
  3. 销毁实例上的所有watcher,也就是将实例上所有的依赖追踪断掉。
  4. 触发destroyed钩子函数
  5. 移除实例上的所有事件监听器
vm.$nextTick

vm.$nextTick接收一个回调函数作为参数,它的作用是将回调延迟到下次DOM更新周期之后执行。它与全局方法Vue.nextTick一样,不同的是回调this自动绑定到调用它的实例上。

因为vm.$nextTickVue.nextTick是相同的,所以nextTick的具体实现并不是在Vue原型上的$nextTick方法中,而是抽象成了nextTick方法供两个方法共用。代码如下:

 Vue.prototype.$nextTick = function (fn) {
     return nextTick(fn, this)
 };

可以看到,vm.$nextTick的具体实现在nextTick中,所以接下来我们详细介绍nextTick方法的实现方式。

var nextTick = (function () {
    // callbacks用来存储所有需要执行的回调函数
    var callbacks = [];
     pending用来标志是否正在执行回调函数
    var pending = false;
    // 用来触发执行回调函数
    var timerFunc;
​
    // nextTickHandler函数用来执行callbacks里存储的所有回调函数
    function nextTickHandler () {
      pending = false;
      var copies = callbacks.slice(0);
      callbacks.length = 0;
      for (var i = 0; i < copies.length; i++) {
        copies[i]();
      }
    }
    //接下来是将触发方式赋值给timerFunc
    if (typeof Promise !== 'undefined' && isNative(Promise)) {
       // 先判断是否原生支持promise,如果支持,则利用promise来触发执行回调函数
      var p = Promise.resolve();
      var logError = function (err) { console.error(err); };
      timerFunc = function () {
        p.then(nextTickHandler).catch(logError);
        if (isIOS) { setTimeout(noop); }
      };
    } else if (typeof MutationObserver !== 'undefined' && (
        isNative(MutationObserver) ||
        MutationObserver.toString() === '[object MutationObserverConstructor]'
      )) {
       // 否则,如果支持MutationObserver,则实例化一个观察者对象,观察文本节点发生变化时,触发执行所有回调函数。
      var counter = 1;
      var observer = new MutationObserver(nextTickHandler);
      var textNode = document.createTextNode(String(counter));
      observer.observe(textNode, {
        characterData: true
      });
      timerFunc = function () {
        counter = (counter + 1) % 2;
        textNode.data = String(counter);
      };
    } else {
       //如果都不支持,则利用setTimeout设置延时为0。
      timerFunc = function () {
        setTimeout(nextTickHandler, 0);
      };
    }
    // 最后是queueNextTick函数。因为nextTick是一个即时函数,所以queueNextTick函数是返回的函数,接受用户传入的参数,用来往callbacks里存入回调函数。
    return function queueNextTick (cb, ctx) {
      var _resolve;
      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();
      }
      if (!cb && typeof Promise !== 'undefined') {
        return new Promise(function (resolve, reject) {
          _resolve = resolve;
        })
      }
    }
  })();

从上述代码可知,nextTick的关键在于timeFunc(),该函数起到延迟执行的作用。

从上面的介绍,可以得知timeFunc()一共有三种实现方式。

  • Promise
  • MutationObserver
  • setTimeout

其中PromisesetTimeout很好理解,是一个异步任务,会在同步任务以及更新DOM的异步任务之后回调具体函数。

MutationObserver是HTML5中的新API,是个用来监视DOM变动的接口。他能监听一个DOM对象上发生的子节点删除、属性修改、文本内容修改等等。

vm.$mount

我们并不常用这个方法,其原因是如果在实例化Vue.js时设置了el选项,会自动把Vue.js实例挂载到DOM元素上,但理解这个方法却非常重要,因为无论我们在实例化Vue.js时是否设置el选项,想让Vue.js实例具有关联的DOM元素,只有使用vm.$mount方法这一种途径。

$mount方法在多个文件中被定义,如:

  • src/platform/web/entry-runtime-with-compiler.js (构建时版本)
  • src/platform/web/runtime/index.js(运行时版本)
  • src/platform/weex/runtime/index.js(跨平台开发)

之所以有多个地方,是因为$mount实现是和平台、构建方式都相关的下面,我们选择compiler构建时版本分析

/*把原本不带编译的$mount方法保存下来,在最后会调用。*/
const mount = Vue.prototype.$mount
/*挂载组件,带模板编译*/
Vue.prototype.$mount = function () {
// query方法,实际上是对el参数做了一个转化,el可能是string 或者 element。如果是string,将返回document.querySelector(el) 
  el = el && query(el)
​
  // 对 el 做了限制,Vue 不能挂载到 body、HTML 这样的跟节点上,因为它会替换掉这些元素
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && warn(
      `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
    )
    return this
  }
  
  const options = this.$options
  /*处理模板templete,编译成render函数,render不存在的时候才会编译template,否则优先使用render*/
  if (!options.render) { // render函数不存在
    let template = options.template
    // 如果存在template配置项,根据特点进行不同判断处理
    if (template) {
        // 当template是字符串的时候
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {//template可能是"#xx",那么根据id获取element内容
          template = idToTemplate(template)
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }
      } else if (template.nodeType) {//如果template存在nodeType(DOM节点),那么获取template.innerHTML 内容
        template = template.innerHTML
      } else {
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {//如果template配置项不存在template,但是存在el,那么根据el获取对应的element内容
      template = getOuterHTML(el)
    }
    if (template) {
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }
      // 经过上面的处理,将获取的template做为参数调用compileToFunctions方法
      // compileToFunctions方法会返回render函数方法,render方法会保存到vm.$options下面
      // 这里会有render以及staticRenderFns两个返回,这是vue的编译时优化,static静态不需要在VNode更新时进行patch,优化性能
      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns
​
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`vue ${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  // 调用const mount = Vue.prototype.$mount保存下来的不带编译的mount
  return mount.call(this, el, hydrating)
}

从上述代码我们可以看出做了以下几件事

  • 由于el参数有两种类型,可能是string 或者 element,调用query方法,统一转化为Element类型
  • 如果没有手写render函数, 那么先获取template内容。再将template做为参数,调用compileToFunctions方法,返回render函数。
  • 最后调用mount.call,这个方法实际上会调用src/platform/web/runtime/index.js的mount方法,如下:
Vue.prototype.$mount = function (){
  /*获取DOM实例对象*/
  el = el && inBrowser ? query(el) : undefined
  /*挂载组件*/
  return mountComponent(this, el, hydrating)
}

数据相关的实例方法

与数据相关的实例方法有3个,分别是vm.$watchvm.$setvm.$delete

其中,vm.$watch用于观察一个表达式或函数在Vue实例上的变化。回调函数调用时,会从参数得到新数据(new value) 和旧数据(old value)。vm.$watch其实是对Watcher的一种封装,通过Watcher完全可以实现vm.$watch的功能,但是vm.$watch中的参数deepimmediateWatcher中所没有的。

vm.$set用来向响应式对象中添加一个属性,并确保这个新属性同样是响应式的,且触发视图更新。它必须用于向响应式对象上添加新属性,因为对于 Vue 来说,当我们向object数据里添加一对新的key/valueVue是无法观测到的;而对于Array型数据,当我们通过数组下标修改数组中的数据时,Vue也是是无法观测到的。

vm.$delete用来删除对象的属性。如果对象是响应式的,确保删除能触发更新视图。这个方法主要用于避开 Vue 不能检测到属性被删除的限制。

vm.$watchvm.$setvm.$delete这三个实例方法的内部实现原理已经在《【Vue2深度学习】变化侦测篇-变化侦测相关的API》一文中详细介绍了,这里就不再赘述了.