从零阅读Vue.js

Posted by wbq on May 11, 2020

以往Get一些技能之后,总以为记在脑子里就万事大吉了,随着年纪增大越来越发现记忆力是最不靠谱的东西,所以往后的学习我都想记录下来,方便日后翻看。

接触前端前前后后也有很长时间,一直没有特别深入地学习。最近因为工作原因面了很多前端,自己也顺便花了点时间把Vue的源码从头看了一遍,方便面试提问(阴险笑容)。

Vue真的是一个非常了不起的前端框架,源码不压缩情况下一共就1w行+,作者自己也说比较react和vue孰优孰劣是没有意义的。Vue的优势在于他的轻量快捷,像我这样没啥经验的小白都能很快整个项目出来。

废话不多说,我下的是的源码v2.6.11。建个工程,目录特别简单

image

image

真的很简单!平时vue-cli与webpack会帮我们做掉很多事情。我这里都没用,简单点接下去阅读会更加直观, 简单说两句,el属性就是指定页面上控制的区域,可以是实例也可以是css选择器。其实这部分代码就是创建了一个名叫vue的实例而已,那么又是如何完成数据绑定的呢,让我们来到源码的初始入口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//初始化

function Vue (options) {

	console.log("第一句代码");

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
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);

        }

   };

}

可以看到这里类的申明完全使用了es5的写法来向下兼容。

然后入口这里call了两个熟悉的生命周期的函数beforeCreat、Created。而在beforeCreat之前,还有一些操作,例如会进行mergeOptions操作,得到一个$options对象,里面会做一些组件的命名检查,必须以字母开头、中间可以有下划线,数字,但某些关键字是不可以使用的如‘slot,component’,包括html标签也不可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
 * Validate component names
 */
function checkComponents (options) {
  for (var key in options.components) {
    validateComponentName(key);
  }
  console.log("检查组件完毕");
}

function validateComponentName (name) {
  if (!new RegExp(("^[a-zA-Z][\\-\\.0-9_" + (unicodeRegExp.source) + "]*$")).test(name)) {
    warn(
      'Invalid component name: "' + name + '". Component names ' +
      'should conform to valid custom element name in html5 specification.'
    );
  }
  if (isBuiltInTag(name) || config.isReservedTag(name)) {
    warn(
      'Do not use built-in or reserved HTML elements as component ' +
      'id: ' + name
    );
  }
}

之后有初始化代理对象、给vue实例添加与生命周期相关的属性、初始化与父级组件相关的事件等操作。再然后来到了created阶段。

1
2
3
initInjections(vm); 
initState(vm);
initProvide(vm); 

这里的概念:provide/inject,这个是Vue在2.2.0版本新增的一个属性。

引用:这对选项需要一起使用,以允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在起上下游关系成立的时间里始终生效。如果你熟悉 React,这与 React 的上下文特性很相似。

平时没怎么用,这里不赘述了。

让我们来到我认为vue初始化最核心的代码initState()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
    }
}

这里先是对props做了一些初始化的操作.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function initMethods (vm, methods) {
    var props = vm.$options.props;
    for (var key in methods) {
      console.log("key",key);
      {
        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);
    }
 }

之后通过一些规则合并methods对象,合并过程增加了一些策略比如:不能与props选项对象中的某个键同名;不能与vue实例自带的以$或_开关的属性同名;否则它都会给我们一个错误的警告。最后通过bind(methods[key], vm);绑定到vm对象,因此我们可以直接通过this.xx()调用方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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 */);
}

同样的,可以看到这里拿到data之后,做一些检查,一切正常之后来到

1
proxy(vm, "_data", key);
1
2
3
4
5
6
7
8
9
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给传入的参数添加对应的存取器属性。

这步其实很好理解,平时我们我们定义data如:

1
2
3
4
data: {
    name: "曹达华",
    age: 37
}

如果我想获取name属性,明明应该这么写this.data.name,但是我们通过this.name也能获取到,原因就在这,以前一直不理解,看了源码就一目了然了,当调用this.nameget方法会去拿data里面的name。同样的,当调用this.name="曹达华"时,也会修改this.data.name的值。

之后就是第二步,给data添加观察者对象。

1
 observe(data, true /* asRootData */);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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
}

第一次进入这个方法,我们是将整个data对象作为value作为参数传入了方法中,所以第一步判断是否不是个对象data显然是个object,所以会来到下一步,判断该对象是否含有观察者属性,一旦vue监听了对象,就会给属性添加一个__ob__属性。变量用来保存 Observer 实例 贴下代码(第5行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var Observer = function Observer (value) {
    this.value = value;
    this.dep = new Dep();
    this.vmCount = 0;
    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);
    }
};

ok,第一次data显然没有被监听过。来到下一步判断,这个数据对象是否是一个对象或数组,并且处理可”观察“模式,同时不能是服务器端渲染(这里不是特别理解,没怎么用过这个)也要求数据对象是可扩展的,只有这些条件同时满足,才会为这个数据对象添加一个”观察器“对象

1
ob = new Observer(value);

上面Observer类的构造方法里基本就是采用递归的思想(因为对象里可能包含对象、数组,数组里面又可能有数组和对象),把所有有资格的属性带到这里的,下面就是vue实现数据双向绑定的秘密,其实就是重写了属性的get和set方法进行监听,一旦set方法被call到,发现值改变就会去更新node,下面会讲到。好像所有的数据绑定理念都差不多,包括iOS的KVO一样,本质也是重写set方法去监听值的改变。扯远了,先上代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
function defineReactive$$1 (
  obj,
  key,
  val,
  customSetter,
  shallow
) {
  var dep = new Dep();

  var property = Object.getOwnPropertyDescriptor(obj, key);
  if (property && property.configurable === false) {
    return
  }

  // cater for pre-defined getter/setters
  var getter = property && property.get;
  var setter = property && property.set;
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key];
  }

  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: function reactiveSetter (newVal) {
      var value = getter ? getter.call(obj) : val;
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (customSetter) {
        customSetter();
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) { return }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      childOb = !shallow && observe(newVal);
      dep.notify();
    }
  });
}
1
var dep = new Dep();

第一句,怎么理解这个dep,后续会讲到,我第一次碰到也理解了半天,先来看后面

1
2
3
4
var property = Object.getOwnPropertyDescriptor(obj, key);
if (property && property.configurable === false) {
  return
}	

这句挺好理解,如果这个属性property.configurable === flase 就不做后续处理了。

1
2
3
4
5
6
7
const getter = property && property.get
const setter = property && property.set
if ((!getter || setter) && arguments.length === 2) {
  val = obj[key]
}
 
let childOb = !shallow && observe(val)

这段代码的前两句定义了 gettersetter 常量,分别保存了来自 property 对象的 getset函数,我们知道 property 对象是属性的描述对象,一个对象的属性很可能已经是一个访问器属性了,所以该属性很可能已经存在 getset 方法。由于接下来会使用 Object.defineProperty 函数重新定义属性的 setter/getter,这会导致属性原有的 setget 方法被覆盖,所以要将属性原有的 setter/getter 缓存,并在重新定义的 setget 方法中调用缓存的函数,从而做到不影响属性的原有读写操作,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Object.defineProperty(obj, key, {
  enumerable: true,
  configurable: true,
  get: function reactiveGetter () {
    //省略..  
    var value = getter ? getter.call(obj) : val;
    //省略..
    return value
  },
  set: function reactiveSetter (newVal) {
    //省略..
    var value = getter ? getter.call(obj) : val;
    //省略..
  }
});

var childOb = !shallow && observe(val);这句话也挺好理解,就是上面说的递归调用了,因为这里的val可能也是对象或者数组,又会再次调用observe(),当然递归一定会有临界点,那就是该方法的第一句话做了边界的判断,这样就和前面串起来了。

1
2
3
if (!isObject(value) || value instanceof VNode) {
  return
}

好了,做完这些事情,终于来到核心的核心了,重写get\set方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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: function reactiveSetter (newVal) {
        var value = getter ? getter.call(obj) : val;
        /* eslint-disable no-self-compare */
        if (newVal === value || (newVal !== newVal && value !== value)) {
          return
        }
        /* eslint-enable no-self-compare */
        if (customSetter) {
          customSetter();
        }
        // #7981: for accessor properties without setter
        if (getter && !setter) { return }
        if (setter) {
          setter.call(obj, newVal);
        } else {
          val = newVal;
        }
        childOb = !shallow && observe(newVal);
        dep.notify();
      }
});

里面一大坨是个啥我们先不管,总之先搞清楚至此我们已经完成了最开始initData()的全部逻辑。而且到目前为止,我们还没有调用属性的get\set方法,所以里面的逻辑,我们暂时还没有触发。什么时候触发后续会说到。

先来看get方法

1
2
3
4
5
6
7
8
9
 if (Dep.target) {
     dep.depend();
     if (childOb) {
         childOb.dep.depend();
         if (Array.isArray(value)) {
             dependArray(value);
         }
     }
 }

这个到底在干嘛呢。 之前说到每个属性都会创建一个var dep = new Dep();这个dep对象里面其实是封装一个数组。

1
2
3
4
var Dep = function Dep () {
    this.id = uid++;
    this.subs = [];
};

里面保存了值与被调用地方的关系,一说也可以叫依赖。啥意思呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>从零阅读</title>
    <script src="vue.js"></script>
</head>
<body>
<div id="app">
    <p></p>
    <div></div>
    <button @click="test()"></button>
</div>
<script>
    // 创建一个Vue的实例对象
    let vue = new Vue({
        // 将来需要控制界面上的哪个区域
        el: '#app',
        // 告诉Vue的实例对象, 被控制区域的数据是什么
        data: {
            name: "曹达华",
        },

        methods:{
            test(){
                this.name = "周星星"
            }
        },
    });

</script>
</body>
</html>

举个最最简单的例子,这里的p标签,div标签全部使用了我name的属性,那么我在点击按钮后通过test()方法修改了name的值,为什么两个标签都会改变呢。原因就在这,这就好比,小太监收到了皇帝的指令,他要告诉大臣A,又要告诉大臣B,他需要打两个电话,通知的人越多,他要打的电话也就越多。因为get方法会在每个标签第一次获取的值的时候多次调用,所以通过dep.depend();这里会保存多个依赖关系。这里注意每个依赖关系只会收集1次,收集的流程后面会讲到,继续看这个方法实现。

ok,明白了这个原因,让我们先来看看这个依赖是怎么添加的,首先 Dep.target 存在的话说明有依赖需要被收集,有人会问这里会不会重复收集,作者在这里做了很多层的保护,所以每个依赖关系只会收集1次,如果 Dep.target 不存在就意味着不需要收集,Dep.target是一个全局的变量,怎么赋值规则后面说。

接下来有点绕了,首先第一句dep.depend();很好理解,就是把当前的属性的依赖收集一次,那么

1
2
3
4
5
6
if (childOb) {
    childOb.dep.depend();
    if (Array.isArray(value)) {
        dependArray(value);
    }
}

这里又把子对象依赖又收集了一次,我举个例子:

1
2
3
4
5
6
data: {
    person:{
        name: "曹达华",
        age: 37
    }
}

depend完长这样:

1
2
3
4
5
6
7
8
data: {
    person:{
        name: "曹达华",
        age: 37,
        __ob__: (Observer){value, dep, vmCount}//childOb.dep是这里的dep
    }
    __ob__: (Observer){value, dep, vmCount}//
}

为什么要这么设计呢?刚开始我也被绕晕了,同样的依赖person对象内部的观察者的dep里面也要放一份呢。

收集的依赖的触发时机是在使用 $setVue.set 给数据对象添加新属性时触发,我们知道由于 js 语言的限制,在没有 Proxy 之前 Vue 没办法拦截到给对象添加属性的操作。所以 Vue 才提供了 $setVue.set 等方法让我们有能力给对象添加新属性的同时触发依赖。

这时来到这里源码大概1000多行的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function set (target, key, val) {
    if (isUndef(target) || isPrimitive(target)
       ) {
        warn(("Cannot set reactive property on undefined, null, or primitive value: " + ((target))));
    }
    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
    }
    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$$1(ob.value, key, val);
    ob.dep.notify();
    return val
}

哦。。明白了,为啥明明get/set方法里已经有dep对象在闭包里了,Observer类为什么还有个dep属性,因为当我们在外部调用Vue.set(this.person, 'height', 160)给person对象新增属性的时候,根本无法触发get方法,所以我们必须有个另外的变量dep把这个依赖缓存起来,方便后续调用。

总结,如果现有属性发生变化,会直接触发set中闭包中的dep的进行notify。如果是新增,删除属性这种操作,无法触发set方法,那么开发人员必须通过Vue.set方法去手动触发notify,作为初学者的我刚开始不解我明明console.log数据变化了呀,页面怎么没变化啊 就是因为直接通过this.person.xx == xxx这种方式新增属性,无法触发数据绑定。

好了弄懂这个,后面就很简单了。

1
2
3
if (Array.isArray(value)) {
    dependArray(value);
}

这里就是如果读取的属性值是数组,那么需要调用 dependArray 函数逐个触发数组每个元素的依赖收集。

收集完依赖,来说说set方法是如何触发的了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
set: function reactiveSetter (newVal) {
    var value = getter ? getter.call(obj) : val;
    /* eslint-disable no-self-compare */
    if (newVal === value || (newVal !== newVal && value !== value)) {
        return
    }
    /* eslint-enable no-self-compare */
    if (customSetter) {
        customSetter();
    }
    // #7981: for accessor properties without setter
    if (getter && !setter) { return }
    if (setter) {
        setter.call(obj, newVal);
    } else {
        val = newVal;
    }
    childOb = !shallow && observe(newVal);
    dep.notify();
}

前面几句很好理解,如果值没变化,就retrun了,不鸟他。

如果之前实现了set的话,会call之前定义的方法,确保逻辑的缜密。

有时候塞进来的数值可能也是一个对象或者数组,这个时候我们需要继续对他们进行观测,所以我们看到这里再次调用了observe(newVal)

最后就是对之前我们收集的依赖进行通知,dep.notify()去进行各个dom元素数据的刷新。

剩下就是notify里到底做了什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
Dep.prototype.notify = function notify () {
  // stabilize the subscriber list first
  var subs = this.subs.slice();
  if (!config.async) {
    // subs aren't sorted in scheduler if not running async
    // we need to sort them now to make sure they fire in correct
    // order
    subs.sort(function (a, b) { return a.id - b.id; });
  }
  for (var i = 0, l = subs.length; i < l; i++) {
    subs[i].update();
  }
};
1
2
3
4
5
6
7
8
9
10
Watcher.prototype.update = function update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true;
  } else if (this.sync) {
    this.run();
  } else {
    queueWatcher(this);
  }
};

可以看到就是对直接收集的依赖原来是一个个Watch对象,而notify()就是进行update() ,至于watch对象是什么时候初始化的,并且是是怎么初始化,包括后面是怎么刷新(异步,同步)的。这就关系到一些抽象语法树的解析,渲染队列的知识。又是一大堆复杂的逻辑,感觉又能写很久,所以有机会再写。

回到最初的initData()方法,执行完最后就是对计算属性computedwatch属性的解析,这很简单,就不展开了。之后就是callcreated 生命周期方法,至此代码完成了所有初始化的工作。之后就是挂载元素和上面的创建Watch对象了。

以上就是Vue组件初始化的全过程,漏了很多,有机会再补。

参考资料

[HcySunYang](https://github.com/HcySunYang)/vue-design