【若川视野 x 源码共读】第23期 | 为什么 Vue2 this 能够直接获取到 data 和 methods

659 阅读5分钟

前言

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

开胃菜

在原因之前,我们先来回顾一下Vue2的简单使用吧。console打印结果是什么?

const vm = new Vue({
  data: {
    msg: "Hello Vue";
  },
  methods: {
    getMsg() {
      console.log(this.msg);
    }
  }
});
console.log(vm.msg); // Hello Vue
console.log(vm.getMsg()); // Hello Vue

很简单,通过vm可以直接访问到msggetMsg,并且getMsg里可以直接读取到上下文this指向的当前的实例。那么Vue是如何实现的呢?通过函数方法获取this指向,我们可以通过函数的callapply以及bind来实现,那么Vue会不会也是这样实现的呢?

我们通常写的类函数,如何做到Vue的效果呢?

function MyVue(options) {
​
}
​
const vm = new MyVue({
  data: {
    msg: "Hello MyVue"
  },
  methods: {
    getMsg() {
      console.log(this.msg);
    }
  }
})
console.log(vm.name); // undefined
console.log(vm.getMsg()); // Uncaught TypeError: vm.getMsg is not a function

接下来,我们一起来探索下Vue是如何实现的吧!

源码剖析

在剖析源码之前,我们先在本地新建一个index.html文件,在body标签内加上如下代码

<script src="https://unpkg.com/vue@2.6.14/dist/vue.js"></script>
<script>
  const vm = new Vue({
    data: {
      msg: 'Hello Vue',
    },
    methods: {
      getMsg(){
        console.log(this.msg);
      }
    }
  });
  console.log(vm.msg);
  console.log(vm.getMsg());
</script>

在浏览器运行之后,我们点开Sources,在const vm = new Vue({所在行打上断点,刷新之后按F11进入Vue构造函数。

Snipaste_2022-11-29_22-29-48.jpg

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构造函数,很简单吧。

首先通过this instanceof Vue检验是否使用new关键词。

然后再通过_init方法,完善options里的所有配置项,那么_init方法是打哪儿来的?没有明显的声明地方,那我们可以猜想是不是在Vue构造函数的原型上property

我们在this._init(options);所在行再打上断点,按F11进入_init函数;

_init 初始化函数

我们先看一下_init函数里都做了哪些

function initMixin (Vue) {
  Vue.prototype._init = function (options) {
    var vm = this; // vm就是我们new出来的实例对象!!!
    // 省略部分代码
    // 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');
    // 省略部分代码
  };
}

我们在initMixin里看到很重要的一行代码Vue.prototype._init,跟我们之前的猜测是一样的,所以,也体现出对于js底层基本功的重要性了。

在这个方法里,我们着重看下initState(vm)方法,看看它会不会给我们带来惊喜。

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);
  }
}

这个方法里,主要是做了options里其他配置项的初始化,包括propsmethodsdatacomputedwatch,留意下这个顺序!!!

顺着这个顺序我们先来看下initMethods方法吧

initMethods 初始化方法

function initMethods (vm, methods) {
  var props = vm.$options.props;
  for (var key in methods) {
    // 省略部分代码,此处做了一些校验
    // 判断 methods 中的每一项是不是函数,如果不是警告。
    // 判断 methods 中的每一项是不是和 props 冲突了,如果是,警告。
    // 判断 methods 中的每一项是不是已经在 new Vue实例 vm 上存在,而且是方法名是保留的 _ $ (在JS中一般指内部变量标识)开头,如果是警告。
    vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm);
  }
}

在这个方法里,我们看到两个熟悉的方法noopbind方法,这两个方法我们在Vue2工具函数这一期里说到过,不了解的可以看一下。

这个方法里,通过遍历methods里每一个方法,将它直接绑定到vm(即new出来的实例)上,看到这儿有没有豁然开朗,原来能直接读取到method是这么实现的,那其余能够直接读取到的,是不是也是这么实现的呢?带着这个猜想我们继续来看看initData方法吧

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. 判断options里的data是不是一个函数,如果不是函数,直接使用,默认为空对象;如果是一个函数,那需要先通过执行getData来拿到返回的结果;
  2. 判断最终的data是不是一个普通对象,如果不是则提示警告;
  3. 判断data里的每个key是否已经在propsmethods里声明过,如果已存在,则提示警告,这个主要与代码执行顺序有关了。
  4. 判断每个key是不是内部私有的保留属性,详情请看Vue里关于data的介绍
  5. 最后做一层代理,将数据代理到_data上。

这里也验证了我们之前的猜想,也是去遍历data里的每一项,将其绑定至vm上,此处Vue又引申出来两个方法getDataproxy,我们简单看下吧。

getData 获取data函数返回结果

function getData (data, vm) {
  pushTarget();
  try {
    return data.call(vm, vm)
  } catch (e) {
    handleError(e, vm, "data()");
    return {}
  } finally {
    popTarget();
  }
}

这个方法里执行了data函数,获取到其返回值。

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);
}

此处呢,我们可以看到,Vue是通过Object.defineProperty(非常重要的一个API)来代理data里的每一项数据,也就是说,我们在使用vm.xx的时候,是通过vm._data.xx来读取的。

简化版的Vue

在这里,我们不考虑其他异常因素,简单实现一下

function MyVue(options) {
  if(!(this instanceof MyVue)) {
    throw Error('需要通过new使用');
  }
  const vm = this;
  vm.$options = options;
  
  if(options.data) {
    initData(vm)
  }

  if(options.methods) {
    initMethods(vm)
  }
}

function initData(vm) {
  let data = vm.$options.data;
  data = typeof data === 'function' ? data.call(vm, vm) : (data || {});
  const keys = Object.keys(data);
  keys.forEach(key => {
    Object.defineProperty(vm, key, {
      enumerable: true,
      configurable: true,
      get() { return data[key]; },
      set(val) { data[key] = val; }
    })
  })
}

function initMethods(vm) {
  const methods = vm.$options.methods;
  const keys = Object.keys(methods);
  keys.forEach(key => {
    vm[key] = methods[key].bind(vm);
  })
}

var vm = new MyVue({
  data() {
    return {
      msg: "Hello MyVue"
    }
  },
  methods: {
    getMsg() {
      console.log(this.msg)
    }
  }
})

console.log(vm.msg) // Hello MyVue
console.log(vm.getMsg()) // Hello MyVue

总结

为什么 Vue2 this 能够直接获取到 data 和 methods?

是因为Vue遍历了methodsdata里的每一项,将其绑定到new出来的实例vm上。

通过bind指定了函数里thisvm

通过Object.defineProperty,将data里的每一项数据代理至_data对象上,访问 this.xxx,就是访问this._data.xxx