vue源码系列---this直接获取data和methods

562 阅读5分钟

如何调试vue2源码

我们在平时的项目开发中是直接使用脚手架创建vue项目,然后进行开发的,如果只是想测试一下vue2中某个api或者源码的话,不需要这么麻烦。可以直接引入vue.js。下面介绍一下如何使用简单的方法调试vue2代码。

创建一个文件夹testVue,用来存放自己的调试代码

引入vue.js

  1. 新建一个html文件,我的是index.html 可以使用cdn的方式引入vue.js
<script src="https://unpkg.com/vue@2.6.14/dist/vue.js"></script>

也可以将vue.js下载到本地,然后从本地引入到页面中 index.html代码

<!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>调试vue2代码</title>
  </head>
  <body>
    <!-- 通过cdn的方式引入vue.js文件 -->
    <script src="https://unpkg.com/vue@2.6.14/dist/vue.js"></script>
    <script>
      // 创建一个vue实例
      const vm = new Vue({
        data: {
          name: '赵盼儿',
        },
        methods: {
          sayHello(){
            console.log(`你好呀, ${this.name}`);
          }
        }
      })
      // 打印信息vue实例中的data和method信息
      console.log(vm.name);  // 赵盼儿
      console.log(vm.sayHello());  // 你好呀, 赵盼儿
    </script>
  </body>
  </html>

创建vue实例

  <script src="https://unpkg.com/vue@2.6.14/dist/vue.js"></script>
  <script>
    const vm = new Vue({
      data: {
        name: '赵盼儿',
      },
      methods: {
        sayHello(){
          console.log(`你好呀, ${this.name}`);
        }
      },
    });
    console.log(vm.name);  // 赵盼儿
    console.log(vm.sayHello());  // 你好呀, 赵盼儿
  </script>

全局安装项目启动服务

上面的index.html文件我们可以采用在浏览器中打开的方式查看运行,当前我们也可以使用命令去启动项目。

全局安装项目启动服务命令

npm i -g http-server

启动项目

http-server

这时我们可以发现项目地址已经不再是我们本地路径了,而是ip地址http://127.0.0.1:8080/ 全局安装项目启动服务的好处就是可以通过本地ip去访问页面。

调试vue2代码

以上完成之后就可以正常调试代码了,具体浏览器调试代码的方式在我的这篇文章中已经介绍的很详细了,感兴趣的可以去翻阅。 在 new Vue这一行打上断点。然后在浏览器中刷新页面,在开发工具的源代码下我们可以看到代码停在了new Vue这一行,然后按下F11, 进入函数里,我们可以看到函数运行到了function Vue里面,这部分就是我们想要看到的源码。

data中的数据为什么可以用this直接获取到

通过上面的例子,我们可以知道为什么可以直接用this就能获取到data中的数据和methods中的方法是在vue这个构造函数中实现的,在 查看源码之前,我们假设一下,如果是你,会怎样实现呢?下面介绍一下我的实现方式。

手动实现模拟vue的构造函数

  function newVue(opt) {
    // 将opt的data上面的属性全部绑定到this上
    if(opt.data) {
      for(let key in opt.data) {
        this[key] = opt.data[key];
      }
    }
    // 将opt的methods上面的方法全部绑定到this上
    if(opt.methods) {
      for(let key in opt.methods) {
        this[key] = opt.methods[key];
      }
    }
  }
  // 创建一个vue实例
  const vm = new newVue({
    data: {
      name: '赵盼儿',
    },
    methods: {
      sayHello(){
        console.log(`你好呀, ${this.name}`);
      }
    }
  })
  // 打印信息vue实例中的data和method信息
  console.log(vm.name);  // 赵盼儿
  console.log(vm.sayHello());  // 你好呀, 赵盼儿

查看vue源码

前面我们已经介绍了如何通过调试看到初始化Vue实例到底做了什么,下面我们就重点分析一下初始化后,为什么可以通过this获取到data和methods上面的属性或方法。

我们先把初始化这块的源码搬到这里来,然后具体分析。

  1. Vue构造函数--判断实例
    function Vue (options) {
      if (!(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);

从上述代码中我们可以发现正如我们上面考虑的有一个Vue的构造函数,只是这里有一个关于是不是用了new调用构造函数的判断!(this instanceof Vue),如果使用了new调用才会去初始化,调用它的_init方法,否则就会提示'Vue is a constructor and should be called with the new keyword'。


在这里抛出一个小问题哈,为什么会加上这样一个判断,直接调用它的_init方法不行吗? 在回答这个问题之前,我们先来了解一下this和instanceof的用法

this

this是动态绑定,主要取决于它的运行环境,有:作为对象方法调用,作为函数调用,作为构造函数调用,和使用apply或call调用。

  1. 作为对象方法调用
   let point = {
     x: 0,
     y: 0,
     moveTo: (x, y) => {
       this.x = this.x + x;
       this.y = this.y + y;
     }
   }
   point.moveTo(5, 6);

此时this指的就是point对象。也可以不写成箭头函数的形式,此时this指的也是point对象,箭头函数中this永远指的是他的上一级对象。 2) 作为函数调用

   function copyVal(y) {
     this.x = y;
   }
   copyVal(4);
   x; // 4

上述代码中的函数是在window的环境下调用的,所以this指的是window对象。

   let point = {
     x: 0,
     y: 0,
     moveTo: function(x, y){
       // 内部函数
       let moveX = function(x) {
         this.x = x;
       };
       let moveY = function(y) {
         this.y = y;
       };

       moveX(x);
       moveY(y);
     }
   };
   point.moveTo(5, 6);
   point.x;  // 0
   point.y;  // 0
   x;    // 5
   y;    // 6

上述代码中的执行point.moveTo(5, 6);其实就是在当前环境调用了moveTo定义的函数体,此时没有明确的调用对象的时候,将 对函数的this使用默认绑定到全局的window对象。所以此时this指的是window对象。 3). 作为构造函数调用

   function Point(x, y) {
     this.x = x;
     this.y = y;
   }
   let test = new Point(3, 4);

new 运算符主要做了以下几种方式: 第一步:创建一个空的对象{} 第二步:链接该对象(即设置该对象的构造函数)到另一个对象,即o.proto = Point.prototype 第三步:将步骤1新创建的对象作为this的上下文 第四步:如果该函数没有返回对象,则返回this

所以上述代码中的this指的就是test这个对象。 4) 使用apply或call调用

function add(x, y) {
   console.log(x + y);
}
function del(x, y) {
   console.log(x - y);
}
add.call(del, 3, 2);  // 5

call是直接改变this的指向,上述代码中将del的内部指向了call。

instanceof
  1. instanceof是用来判断一个实例是否属于某种类型
  function Foo() {

  }
  let foo = new Foo();
  console.log(foo instanceof Foo);// true
  1. 在继承关系中用来判断一个实例是否属于它的父类型
  function Aoo() {

  }
  function Foo() {

  }
  Foo.prototype = new Aoo();  // JavaScript原型继承
  let foo = new Foo();
  console.log(foo instanceof Foo);//  true
  console.log(foo instanceof Aoo); // true

如果在调用构造函数的时候没有使用new,那么this instanceof Vue中的this指的就是window, 如果使用了new,this指的就是上下文,才能调用到_init方法。

我们继续说this._init(options)里面的内部实现哈,在这句代码上打个断点,进入函数内部我们会发现,执行了initMixin中绑定在vue原型上的方法。代码如下:

  1. initMixin方法给Vue的原型绑定_init初始化函数
    function initMixin (Vue) {
      Vue.prototype._init = function (options) {
        var vm = this;
        // a uid
        vm._uid = uid$3++;

        var startTag, endTag;
        /* istanbul ignore if */
        if (config.performance && mark) {
          startTag = "vue-perf-start:" + (vm._uid);
          endTag = "vue-perf-end:" + (vm._uid);
          mark(startTag);
        }

        // a flag to avoid this being observed
        vm._isVue = true;
        // merge options
        if (options && options._isComponent) {
          // optimize internal component instantiation
          // since dynamic options merging is pretty slow, and none of the
          // internal component options needs special treatment.
          initInternalComponent(vm, options);
        } else {
          vm.$options = mergeOptions(
            resolveConstructorOptions(vm.constructor),
            options || {},
            vm
          );
        }
        /* istanbul ignore else */
        {
          initProxy(vm);
        }
        // expose real self
        vm._self = vm;
        initLifecycle(vm);
        initEvents(vm);
        initRender(vm);
        callHook(vm, 'beforeCreate');
        initInjections(vm); // resolve injections before data/props
        initState(vm);
        initProvide(vm); // resolve provide after data/props
        callHook(vm, 'created');

        /* istanbul ignore if */
        if (config.performance && mark) {
          vm._name = formatComponentName(vm, false);
          mark(endTag);
          measure(("vue " + (vm._name) + " init"), startTag, endTag);
        }

        if (vm.$options.el) {
          vm.$mount(vm.$options.el);
        }
      };
    }

从上面的代码中我们会发现初始化的时候主要做了以下几件事:

  • initLifecycle(vm); :初始化生命周期
  • initEvents(vm); : 初始化事件
  • initRender(vm); : 初始化render(渲染)
  • callHook(vm, 'beforeCreate'); : 注册beforeCreate钩子函数
  • initInjections(vm); :初始化inject注入数据,这个步骤发生在初始化data/props之前
  • initState(vm); : 初始化state
  • initProvide(vm); :初始化provider, 这个步骤发生在初始化data/props之后
  • callHook(vm, 'created'); :注册created钩子函数

可以看出初始化这个步骤对应的就是生命周期中关于beforeCreate和created两个阶段,我们大致可以猜到data和methods的初始化应该发生在initState()中。

我们可以在initState()这一行打断点,进入函数内部看一下内部实现逻辑。

  1. initState初始化状态
  function initState (vm) {
    vm._watchers = [];
    var opts = vm.$options;
    if (opts.props) { initProps(vm, opts.props); }
    if (opts.methods) { initMethods(vm, opts.methods); }
    if (opts.data) {
      initData(vm);
    } else {
      observe(vm._data = {}, true /* asRootData */);
    }
    if (opts.computed) { initComputed(vm, opts.computed); }
    if (opts.watch && opts.watch !== nativeWatch) {
      initWatch(vm, opts.watch);
    }
  }

initState方法代码不多,我们可以很清楚地发现它主要做了以下几件事: 1)初始化props 2)初始化methods 3) 初始化data 4) 初始化computed计算属性 5) 初始化watch监听

既然是要知道为什么可以用this直接获取data中的属性,那么就在initData函数打个断点,进入函数内部看一下具体做了写什么?

  1. initData初始化data
    function initData (vm) {
      var data = vm.$options.data;
      data = vm._data = typeof data === 'function'
        ? getData(data, vm)
        : data || {};
      if (!isPlainObject(data)) {
        data = {};
        warn(
          'data functions should return an object:\n' +
          'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
          vm
        );
      }
      // 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 (methods && hasOwn(methods, key)) {
            warn(
              ("Method "" + key + "" has already been defined as a data property."),
              vm
            );
          }
        }
        if (props && hasOwn(props, key)) {
          warn(
            "The data property "" + key + "" is already declared as a prop. " +
            "Use prop default value instead.",
            vm
          );
        } else if (!isReserved(key)) {
          proxy(vm, "_data", key);
        }
      }
      // observe data
      observe(data, true /* asRootData */);
    }

以上代码主要做了以下几个事情: 1)给data赋值,如果是函数,就执行getData方法,将返回的数据赋值给data,否则就直接赋值 2)判断data,如果是空对象就直接警告 3)遍历data里面的key(属性) 1) 如果属性在methods中存在,则直接警告 2) 如果属性在props中存在,则直接警告 3) 判断一下如果不是内部私有保留属性(isReserved方法判断属性key是不是以$或者_开头),则使用proxy封装一层代理 4) 监听data,使之成为响应式的数据

我们看看上面会用到的其他方法

    // 判断是是否是真实的对象
    var _toString = Object.prototype.toString;
    function isPlainObject (obj) {
      return _toString.call(obj) === '[object Object]'
    }

    isPlainObject({});   // true
    isPlainObject(null); // false
    isPlainObject({a: 1});  // true
    isPlainObject(undefined);  // false

    // 判断对象上是否具有某属性,从hasOwn({ }, 'hasOwnProperty');可以看出不会通过原型链去查找
    var hasOwnProperty = Object.prototype.hasOwnProperty;
    function hasOwn (obj, key) {
      return hasOwnProperty.call(obj, key)
    }

    hasOwn({ a: undefined }, 'a');   // true
    hasOwn({ }, 'a');   // false
    hasOwn({ }, 'hasOwnProperty');  // false
    hasOwn({ }, 'toString');   // false

    // 判断是否是以_或者$开头
    function isReserved (str) {
      var c = (str + '').charCodeAt(0);
      return c === 0x24 || c === 0x5F
    }
    isReserved('_data');   // true
    isReserved('$options');   // true
    isReserved('data');  // false
    isReserved('options');  // false

    // 判断是否是对象
    function isObject (obj) {
      return obj !== null && typeof obj === 'object'
    }
    isObject({ });  // true
    isObject(null);  // false
    isObject(undefined);  // false
    isObject({ a: 1 });  // true

接下来在getData这一行打上断点,我们看一下getData内部是如何实现的。 4.1 getData获取数据

    function getData (data, vm) {
      // #7573 disable dep collection when invoking data getters
      pushTarget();
      try {
        return data.call(vm, vm)
      } catch (e) {
        handleError(e, vm, "data()");
        return {}
      } finally {
        popTarget();
      }
    }

如果data是函数,就调用函数,执行后获取到的对象就会赋值给initData方法中的data。 比如,我们在vue中写的时候也是按照function来写的。

    data() {
      return {

      }
    }

说到这里我们来提出一个小问题:为什么在组件开发中我们定义data的时候都是函数,而不是使用对象的形式。 在解答这个问题之前,我们先来看一个小例子:

    // 如果是对象
    let Component = function () {
      this.data = this.data;
    }
    Component.prototype.data = {
      a: '1',
      b: '2'
    }
    let component1 = new Component();
    let component2 = new Component();
    component1.data.a = '12';
    console.log(component1);   
    // {
    //   data: {a: '12', b: '2'}
    // }
    console.log(component2);
    // {
    //   data: {a: '12', b: '2'}
    // }

    // 如果是函数
    let Component = function () {
      this.data = this.data();
    }
    Component.prototype.data = function (){
      return {
        a: '1',
        b: '2'
      }
    }
    let component1 = new Component();
    let component2 = new Component();
    component1.data.a = '12';
    console.log(component1);   
    // {
    //   data: {a: '12', b: '2'}
    // }
    console.log(component2);
    // {
    //   data: {a: '1', b: '2'}
    // }

从上面的例子我们可以看出,如果是对象,每个实例就相当于是对原型上的对象的引用拷贝了一份,其中一个实例修改了原型上的数据,另一个实例也会被修改。而如果是函数,那么函数执行完是会返回一个对象的,创建不同的实例是相当于创建了一个新的对象,所以修改其中一个是不会对另外的实例产生影响的。放在vue.js中,也就是说组件可能会被不同的父组件使用,如果data是对象而不是函数的话,其中一个调用修改了组件的data,那么其他的组件的data的数据也会被污染。

4.2 proxy封装代理

    var sharedPropertyDefinition = {
      enumerable: true,
      configurable: true,
      get: noop,
      set: noop
    };
    function proxy (target, sourceKey, key) {
      sharedPropertyDefinition.get = function proxyGetter () {
        return this[sourceKey][key]
      };
      sharedPropertyDefinition.set = function proxySetter (val) {
        this[sourceKey][key] = val;
      };
      Object.defineProperty(target, key, sharedPropertyDefinition);
    }

从上面代码可以看出来,主要用到的就是Object.defineProperty来定义对象。Object.defineProperty有三个参数:第一个参数是属性所在的对象,第二个参数是属性的名字,第三个参数是描述符对象(包括configurable, enumerable, writable, value). 这个方法也是双向数据绑定的核心。 在vue中,根据代理实现了this.xxx则是访问的this._data.xxx。

4.3 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
    }

上述代码主要是监听数据变化,这块比较复杂,我们下次讲到vue源码之实现数据监听的时候会重点讲解

methods中的方法为什么可以用this直接获取到

上面已经讲完了为什么data可以用this直接获取到,我们接着将为什么可以用this直接获取methods中的方法,前面的过程是一样的,只是在执行initState函数的时候,在initMethods这一行代码上打断点,我们看一下这里面是如何实现的

  function initMethods (vm, methods) {
    var props = vm.$options.props;
    for (var key in methods) {
      {
        if (typeof methods[key] !== 'function') {
          warn(
            "Method "" + key + "" has type "" + (typeof methods[key]) + "" in the component definition. " +
            "Did you reference the function correctly?",
            vm
          );
        }
        if (props && hasOwn(props, key)) {
          warn(
            ("Method "" + key + "" has already been defined as a prop."),
            vm
          );
        }
        if ((key in vm) && isReserved(key)) {
          warn(
            "Method "" + key + "" conflicts with an existing Vue instance method. " +
            "Avoid defining component methods that start with _ or $."
          );
        }
      }
      vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm);
    }
  }

以上代码主要做了以下几个事情:

  1. 遍历methos里面的属性,看是不是函数,如果不是给出警告
  2. 判断props里面的数据和methos里面的属性是否相同,存在冲突,如果冲突了给出警告
  3. 判断methos中的属性是不是已经在 new Vue实例 vm 上存在并且以$或者_开头,如果是则给出警告

通过以上代码可以看出主要就是将methos上面的对象通过bind绑定函数的指向为this,这样就可以通过this直接调用methods上面的方法了。

方法绑定

  function polyfillBind (fn, ctx) {
    function boundFn (a) {
      var l = arguments.length;
      return l
        ? l > 1
          ? fn.apply(ctx, arguments)
          : fn.call(ctx, a)
        : fn.call(ctx)
    }

    boundFn._length = fn.length;
    return boundFn
  }

  function nativeBind (fn, ctx) {
    return fn.bind(ctx)
  }

  var bind = Function.prototype.bind
    ? nativeBind
    : polyfillBind;
   function noop (a, b, c) {}

总结

从这次源码中,我们看出之所以能通过this直接访问data和methods主要是请将数据和方法中所有的属性都绑定在vm这个实例上,通过bind指向改变this的指向,遍历属性,然后再通过Object.defineProperty方法来绑定data上面的属性,实现双向数据绑定和监听。