【源码共读】为什么 Vue2 this 能够直接获取到 data 和 methods ?

215 阅读3分钟

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
这是源码共读的第23期,链接: 为什么 Vue2 this 能够直接获取到 data 和 methods ? 源码揭秘!

1. 环境源码准备

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

全局安装http-server, 启动index.html;

npm i -g http-server
http-server .

image.png

通过 http://localhost:8082/ 打开 index.html 页面

2. 调试 1)F12 打开调试, source面板,第二行 打上断点 image.png 2)刷新页面 F11 进入函数,此时断点就走进了Vue构造函数

image.png

3. 走进Vue源码

3.1 Vue构造函数
  function Vue (options) {
     // options是一个对象,就是创建 vm实例时传入的,
     // options对象具有data、props、watch、computed、methods等一些属性
    if (!(this instanceof Vue)
    ) {
      warn('Vue is a constructor and should be called with the `new` keyword');
    }
    this._init(options);
  }

if(!this instanceof Vue) 判断是不是用了new关键词调用构造函数

3.2 _init初始化函数
var uid$3 = 0;
function initMixin(Vue){
    Vue.prototype._init = function(options){
        var vm = this;
        vm._uid = uid$3++;
        // a flag to avoid this being observed
        vm._isVue = true
        // merge options
        if(options && options._isComponent){
            initInternalComponent(vm, options)
        } else {
        // mergeOptions: 第一个参数是父, 第二个参数是, 第三个参数是vm实例
        vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm)
        }
    }
    // expose real self
    vm._self = vm;
    initLifecycle(vm);
    initEvents(vm);
    initRender(vm);
    callHook(vm, 'beforeCreate');
    initInjection(vm);  // resolve injections before data/props
    initState(vm);      // 初始化状态
    initProvide(vm);   // resolve provide after data/props
    callHook(vm, 'create') 
}
3.3 initState 初始化状态

image.png

这个函数主要实现功能是:
// 初始化 props
// 初始化 methods
// 监测数据
// 初始化 computed
// 初始化 watch

// Firefox has a "watch" function on Object.prototype...
var nativeWatch = ({}).watch;
  
function initState(vm){
    vm._watchers = [];
    var opts = vm.$options;
    if(opts.props) {initProps(vm, opt.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)
    }
}
3.4 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
                )
            }
            // 判断每一项是不是 和props 冲突了
            if(props && hasOwn(props, key)){
                 warn(
            ("Method \"" + key + "\" has already been defined as a prop."),vm
              );
            }
            // 判断每一项是不是已经在 new Vue 中存在, 且方法名是保留的 _或者 $(js中一般指内部变量标识)开头
            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)
    }
}
3.4.1 initMethods中使用的工具函数
  1. hasOwn 是否是自身属性
var hasOwnProperty = Object.prototype.hasOwnProperty;
function hasOwn(obj, key){
    return hasOwnProperty.call(obj, key)
}
  1. isReserved 是否是内部私有保留的字符串$和_开头
function isReserved(str){
    // str转为字符串后的首个字符 
    var c = (str + '').chartCode(0)
    return c === 0 0x24 || c === 0x5F
}
  1. noop 空函数
function noop(a,b,c){}
  1. bind 改变 this 指向
function pollyfillBind(fn, ctx){
    function boundFn(a){
        // l 存在 且 l > 1, 传的是数组
        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 = Object.prototype.bind ? nativeBind : pollyfillBind;

initMethods函数是遍历vm.options.methods中每一项,传入到 methods 对象,并且使用 bind 绑定函数的thisvm,也就是new Vue的实例对象。

这就是为什么可以通过 this 直接访问到 methods 里面的函数的原因
3.5 initData 初始化data
function initData(vm){
    var data = vm.$options.data;
    // 给 _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
    // keys为 data 数据的属性名
    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]
        {
            // 判断 data 中定义的数据 是否和 props 中冲突
            if(props && hasOwn(props, key)){
              warn(
              "The data property \"" + key + "\" is already declared as a prop. " +
              "Use prop default value instead.",
              vm
            );
          }
         } 
          // 判断 data 中定义的数据 是否和 methods 中冲突
          if(methods && hasOwn(methods, keys)){
             warn(
               ("Method \"" + key + "\" has already been defined as a data property."),
                vm
             );
          } else if(!isReserved(key)){
              // 定义 _data 并使用内部定义的 proxy 方法代理所有属性(实际上是将属性代理到vm实例上,支持this.attr这种形式的支持调用) 
              proxy(vm, '_data', key)
          }
     }
     // 观察data中所有属性,实现data响应式的具体处理
     observe(data, true, /* asRootData*/)
} 
3.5.1 initData 中使用到的工具函数
  1. getData 获取数据
// 是函数时调用函数,执行获取对象
function getData(data, vm){
    pushTarget();
    try{
    return data.call(vm, vm)
    } catch(e){
        handleError(e, vm, "data()")
    } finally{
        popTarget()
    }
}
 Dep.target = null;
 var targetStack = [];

  function pushTarget (target) {
    targetStack.push(target);
    Dep.target = target;
  }

  function popTarget () {
    targetStack.pop();
    Dep.target = targetStack[targetStack.length - 1];
  }

  1. 判断是否是纯对象
  var _toString = Object.prototype.toString
  function isPlainObject (obj) {
    return _toString.call(obj) === '[object Object]'
  }
  1. proxy 代理 其实就是用 Object.definePreperty 定义对象
// 还有其他属性,value: 获取属性时返回的值, writable: 是否可写
var sharePropertyDefinition = {
    enumerable: true,   // 可否通过for...in 遍历
    configurable: true, // 可否修改、删除属性
    get: noop, 
    set: noop
}
function proxy(target, sourceKey, key){
    sharedPropertyDefinition.get = function proxyGetter(){
        // 访问的是this._data 中的 key
        return this[sourceKey][key]
    }
     sharedPropertyDefinition.set = function proxySetter(val){
        // 访问的是this._data 中的 key
        this[sourceKey][key] = val
    }
    // target: 属性所在的对象, key: 属性名 sharedPropertyDefinition: 一个对象, 对key进行描述
    Object.defineProperty(target, key, sharedPropertyDefinition)
}

4. observe 监听数据变化的

function observe(value, asRootData){
    // 如果不是对象或者是虚拟dom对象则返回
    if(!isObject(value) || value instance of VNode){
        return
    }
    var ob;
    // 存在 __ob__ 属性则代表该对象之前已经观察过了
    if(hasOwn(value, '__ob__') && value.__ob__ instanceof Observe){
        ob = value.__ob__;
    } else if(
    shouldObserve && // // 当前允许观察
    !isServerRendering() && 
    (Array.isArray(value) || isPlainObject(value)) && // 只允许对数组和简单对象进行观察
    // 该对象是可开展的,即可以给它添加新属性 且它不能是Vue实例
    Object.isExtensible(value) && !value._isVue){
        ob = new Observer(value)
    }
    // 统计有多少个Vue实例对象将该对象作为根数据
    if(asRootData && ob){
        ob.vmCount++;
    }
    return ob;
}

observe 方法会判断一个对象是否已经被观察过,如果没有,那么当该数据是数组或简单对象的话会给它创建一个 observer 实例,接下来看 Observer

export class Observer {
  value;
  dep;
  vmCount; // 使用该对象作为根数据的vm数量

  constructor(value) {
    // 目标对象
    this.value = value
    // 实例化一个依赖收集对象
    this.dep = new Dep()
    // vm数量
    this.vmCount = 0
    // 给目标对象添加一个被观察过了的标志
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      // 如果浏览器支持使用__proto__属性
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
}

Observer 构造函数定义了几个变量,然后给目标对象添加了一个被观察到标志位,使用了 def 方法,这个方法也是源码中很常见的一个方法

export function def (obj, key, val, enumerable) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,// 是否可枚举
    writable: true,// 可写
    configurable: true// 可配置、可删除
  })
}

5. 简化版实现


function Person(options){
  let vm = this;
   vm.$options = options;
   var opts = vm.$options;
   if(opts.data){
    initData(vm)
   }

   if(opts.methods){
    initMethods(vm, opts.methods)
   }

   function initData(vm){
    const data = vm._data = vm.$options.data;
    const keys = Object.keys(data)
    var i = keys.length;
    while(i--){
      var key = keys[i]
      proxy(vm, '_data', key)
    }
   }

   function initMethods(vm, methods){
    for(var key in methods){
      vm[key] = typeof methods[key] !== 'function' ? noop : methods[key].bind(vm)
    }
   }

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

var p = new Person({
  data: {
     name: '多啦A梦'
  },

  methods: {
    getName(){
      console.log('name>>>', this.name)
    }
  }
})

思考:

  1. 如何理解data.call(vm, vm)
data = vm.$options.data
改变this指向,将 data函数的this指向组件实例vm: data 改为 第一个vm是为了执行函数上下文, 并将 第二个 vm 作为参数传入。

总结:

  1. this能够直接访问到methods里面函数的原因: 通过 bindthis 指向改成了 new Vue的实例 vm
bind(methods[key], vm)
  1. this能够直接访问到data里面数据的原因: data 中数据会在 vm 实例上的 _data 中又存储一份, 当访问 this.xxx 访问的是 Object.defineProperty 代理后的 this._data.xxx
Object.defineProperty(target, key, sharedPropertyDefinition)
// sharedPropertyDefinition.get --> this[_data][key]
// Object.defineProperty(vm, key, sharedPropertyDefinition)

so this.XXX 指向this._data.XXX
  1. vue中vm指向问题

    a. 被vue所管理的函数,再写成普通函数后,里面的this指向才是vm或者组件实例对象 b. 而不被vue管理的函数,例如 ajax 回调函数等,需要写成箭头函数,这样里面的 this 才指向 vm 或组件实例对象