Vue源码学习-聊聊Vue实例是怎么来的

Posted by Yeoman on 2018-08-20

1. 前言

生命周期这个概念贯穿了我们整个Vue组件从创建到销毁,并且我们在开发中也经常需要用到几个核心的生命周期钩子函数,beforeCreated(),created(),beforeMounted(),mounted(),destroyed()等等。因此我觉得顺着这个脉络来梳理Vue源码的整体脉络是比较合适的。先来一张官方文档的图:

本文的目的是理清组件构造部分源码的脉络,涉及到某些非主线功能的复杂逻辑后续单独写文章补充

2. Vue的构造函数

这张图中的第一步,就是调用Vue这个构造函数。那么就先来看看Vue的构造函数到底长什么样子。

我们先从这个构造函数的入口文件开始看起,入口文件非常简洁。

2.1 加工原型链

之所以挂载在原型链上是因为这些属性都是Vue实例需要用到的

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

可以看出来,这五个函数的入参都是Vue构造器,也就是Vue构造器会在这5个函数中进行加工处理,分别来看下这5个函数的核心部分,这时候不用去看细节的代码。

2.1.1 initMixin

1
2
3
4
export function initMixin (Vue: Class<Component>) {
Vue.prototype._init = function (options?: Object) {
}
}

在Vue的原型链上挂载_init方法,用于Vue实例化的时候调用的入口函数。

2.1.2 stateMixin

1
2
3
4
5
6
7
8
9
Vue.prototype.$set = set
Vue.prototype.$delete = del

Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
}
  1. 挂载$set和$delete方法,用于动态添加和删除可响应式数据,主要是通过observer里的defineReactive函数实现。
  2. 挂载$watch函数,用于监听响应式数据

2.1.3 eventsMixin

1
2
3
4
5
6
7
8
9
10
11
12
13
export function eventsMixin (Vue: Class<Component>) {
Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
}

Vue.prototype.$once = function (event: string, fn: Function): Component {
}

Vue.prototype.$off = function (event?: string | Array<string>, fn?: Function): Component {
}

Vue.prototype.$emit = function (event: string): Component {
}
}

挂载Vue中和事件相关的四个函数,$on,$once,$off,$emit

2.1.4 lifecycleMixin

1
2
3
4
5
6
7
8
9
10
export function lifecycleMixin (Vue: Class<Component>) {
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
}

Vue.prototype.$forceUpdate = function () {
}

Vue.prototype.$destroy = function () {
}
}
  1. 挂载_update函数,这个函数后续会用于patch更新
  2. 挂载$forceUpdate和$destroy函数

2.1.5 renderMixin

1
2
3
4
5
6
7
8
9
10
export function renderMixin (Vue: Class<Component>) {
// install runtime convenience helpers
installRenderHelpers(Vue.prototype)

Vue.prototype.$nextTick = function (fn: Function) {
}

Vue.prototype._render = function (): VNode {
}
}
  1. installRenderHelpers函数会在原型链上挂载一系列工具函数,用于后续在Render函数中调用
  2. 挂载$nextTick函数
  3. 挂载_render函数,这个函数内部会调用真正的Render函数

可以看到,我们常用的API都是在这个阶段挂载到构造器的原型链上的。

2.2 静态属性和方法

这部分 之所以挂载到Vue的静态属性上,是因为并不是实例所需要的,不让实例上有冗余代码

实际上,Vue平台无关代码的入口文件是src/index.js(指的是不包含web和weex的差异代码)。

1
2
3
4
import Vue from './instance/index'
import { initGlobalAPI } from './global-api/index'

initGlobalAPI(Vue)

这个文件中关键的代码是初始化Vue的全局API,也就是静态属性和方法。(其他逻辑都是给服务端渲染用的)

经过initGlobalAPI函数之后,我们看看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
// 这几个全局API是暴露给开发者使用的,都比较熟悉
Vue.set
Vue.delete
Vue.nextTick
Vue.mixin
Vue.extend
Vue.component
Vue.directive
Vue.filter
// 引入插件使用
// 会调用插件的install函数,回调入参就是Vue构造器
Vue.use

// 这个util对象暴露了一些Vue中的工具函数
// 我理解应该是暴露给Vue的插件开发者使用的
// 比如vue-router中就调用了Vue.util.defineReactive来完成响应式路由。
Vue.util = {
warn,
extend,
mergeOptions,
defineReactive
}

// 后续会和我们实例化Vue时候传入的options合并成最终的$options
Vue.options = {
components: {
KeepAlive
},
directives: Object.create(null),
filters: Object.create(null),
_base: Vue
}

至此,我们的Vue构造函数就完成了,当前还有一部分的平台相关的代码,后续学习过程中遇到了再补充吧。

3. 初始化Vue实例

有了构造函数,我们就可以调用new Vue()来进行实例化了,上面可以看到构造函数的入口方法只有一个_init函数,可见这个函数很重要,看下完整代码。

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
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
// 这个_uid作为实例的唯一标志,还有一个_cid作为构造函数的唯一标志
vm._uid = uid++

// 这部分Vue的性能追踪代码
// Vue一共在四个阶段加了追踪,这是初始化阶段的埋点
// Vue文档:https://cn.vuejs.org/v2/api/#performance
// 性能追踪是依赖window.performance对象的
// https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure

let startTag, endTag
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
startTag = `vue-perf-start:${vm._uid}`
endTag = `vue-perf-end:${vm._uid}`
mark(startTag)
}

// a flag to avoid this being observed
// 这个标志是为了避免Vue实例本身被监听系统监听
vm._isVue = true

// 这部分代码是合并前面提到的Vue.options和外部传进来的options变成$options,挂载到实例上
// 这里的mergeOptions函数很关键,内容也很多,尤大把它作为工具函数单独抽出来了
// mixins,extend等的实现都依赖它,同时也在Vue.utils上暴露给插件开发者
// 后续写extend等实现的时候再细看这部分

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

// 这里先mark,后续准备写一下[写Proxy在Vue中的实践],到时候细看

/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
initProxy(vm)
} else {
vm._renderProxy = 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 (process.env.NODE_ENV !== 'production' && 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)
}
}

这段代码和生命周期无关的部分已经全部写上注释了,接下来我们重点看下中间那段代码做了什么事情:

3.1 initLifecycle

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
function initLifecycle (vm) {
var options = vm.$options;

// locate first non-abstract parent
var parent = options.parent;
if (parent && !options.abstract) {
while (parent.$options.abstract && parent.$parent) {
parent = parent.$parent;
}
parent.$children.push(vm);
}

vm.$parent = parent;
vm.$root = parent ? parent.$root : vm;

vm.$children = [];
vm.$refs = {};

// 这部分属性都是Vue内部用来标记当前的生命周期
vm._watcher = null;
vm._inactive = null;
vm._directInactive = false;
vm._isMounted = false;
vm._isDestroyed = false;
vm._isBeingDestroyed = false;
}
  1. 将当前实例加到父实例的$children数组里,设置当前组件的$parent属性是父实例
  2. 这个函数叫初始化生命周期,也就是初始化了vm上那些用来记录组件当前生命周期状态的标记位。

3.2 initEvents

1
2
3
4
5
6
7
8
9
export function initEvents (vm: Component) {
vm._events = Object.create(null)
vm._hasHookEvent = false
// init parent attached events
const listeners = vm.$options._parentListeners
if (listeners) {
updateComponentListeners(vm, listeners)
}
}
  1. 初始化父实例的事件

这时候会回调beforeCreated这个钩子函数,和文档的图描述的一直,可以发现这个阶段只是初始化了生命周期和事件。

3.3 initRender

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
export function initRender (vm: Component) {
vm._vnode = null // the root of the child tree
vm._staticTrees = null // v-once cached trees
const options = vm.$options
const parentVnode = vm.$vnode = options._parentVnode // the placeholder node in parent tree
const renderContext = parentVnode && parentVnode.context
vm.$slots = resolveSlots(options._renderChildren, renderContext)
vm.$scopedSlots = emptyObject
// bind the createElement fn to this instance
// so that we get proper render context inside it.
// args order: tag, data, children, normalizationType, alwaysNormalize
// internal version is used by render functions compiled from templates
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
// normalization is always applied for the public version, used in
// user-written render functions.
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

// $attrs & $listeners are exposed for easier HOC creation.
// they need to be reactive so that HOCs using them are always updated
const parentData = parentVnode && parentVnode.data

/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, () => {
!isUpdatingChildComponent && warn(`$attrs is readonly.`, vm)
}, true)
defineReactive(vm, '$listeners', options._parentListeners || emptyObject, () => {
!isUpdatingChildComponent && warn(`$listeners is readonly.`, vm)
}, true)
} else {
defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, null, true)
defineReactive(vm, '$listeners', options._parentListeners || emptyObject, null, true)
}
}
  1. 这部分代码涉及到比较多的陌生属性,得以后回头再看,但是我们可以发现$slots,$vnode这两个很重要的属性都是在这里进行初始化的
  2. 使$listeners,$attr这两个属性变成响应式,这两个属性都是创建高阶组件用的,以后用到再整理了- -

3.4 initState

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export function initState (vm: Component) {
vm._watchers = []
const 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)
}
}
  1. 初始化$props属性,通过defineReactive使得props属性是响应式的
  2. 初始化methods,把methods直接挂载到vm上,这也是我们为什么可以直接通过this调用到方法的原因
  3. 初始化$data,使得data属性是响应式的,这里注意,代码里通过Object.defineProperty代理了$data和$props的属性,使得可以直接在vm上访问到data和props
  4. 初始化watch选项,通过new Watcher()设置监听

写到这里,我们再回过头看_init函数,我们发现在created钩子函数阶段我们完成了上述内容的初始化,而在beforeCreated阶段,我们不能做和data,props,methods,watch等相关的操作。

3.5 $mount

最后一句调用了$mount函数来挂载视图,我们可以想到从$mount调用到callHook(vm, 'mounted')这句函数被执行,这中间发生了很多事,Vue的响应式系统也是在这个过程中完成的,这部分不作为本文要写的内容,但在这里我们可以知道在created钩子中视图还没有加载,还不能进行视图相关操作。

3.6 one more thing

从上述代码我们可以发现,生命周期钩子的回调都是通过一个callHook函数完成的,我们来看下这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function callHook (vm, hook) {
// #7573 disable dep collection when invoking lifecycle hooks
pushTarget();
var handlers = vm.$options[hook];
if (handlers) {
for (var i = 0, j = handlers.length; i < j; i++) {
try {
handlers[i].call(vm);
} catch (e) {
handleError(e, vm, (hook + " hook"));
}
}
}
if (vm._hasHookEvent) {
vm.$emit('hook:' + hook);
}
popTarget();
}

可以发现,在回调钩子函数的时候实际上并不单纯调用一个函数,而是一个数组,这是为什么呢?

想象一个我们平时经常使用的场景,在组件里有mounted()钩子,在mixin里同样定义了mounted()钩子,这种情况下其实是在前面提到的mergeOptions()函数里会把同名的钩子函数合并成一个数组,然后依次调用。

总结

上述的源码整理基本可以看出来一个实例是如何被创建的过程,虽然还是遗留了一些问题,但是秉承前文提的先理清整理脉络,再逐个击破的原则,后续需要继续补充学习的,比如:

  1. extend的实现原理
  2. $slots的实现原理
  3. Proxy在Vue中的实践
  4. 等等