Vue.js源码学习 —— Vue实例挂载的实现

如果您是刚开始准备阅读Vue.js的源码,建议先看一下本系列的Vue.js源码学习 —— 起步,相信会对您后面的阅读有很大帮助。

到目前为止我们已经知道虚拟节点是如何创建的,组件是如何创建的,patch的过程是如何实现的,那从Vue实例的初始化到最终patch被执行又经过了哪些过程呢?今天我们就来看一下Vue实例对象的挂载过程。

今天这篇文章要看的代码主要位于文件core/instance/lifecycle.js中和文件core/instance/render.js中。

我没有在文章中大量的贴源码,一定要将源码clone到本地哦。

预备知识

举一个使用Vue最简单的例子:

1
2
3
4
5
6
new Vue({
el: '#app',
render (h) {
return h('p', 'Hello World')
}
})

从前面的分析我们知道经过patch后,会把DOM元素<div id="#app"></div>替换成render(h)返回的虚拟节点关联的元素,也就是<p>Hello World</p>,页面上显示出了内容。能够触发patch的执行,是因为在Vue实例的初始化中调用了$mount方法,然后一系列的调用过程最终执行到了patch

1
2
3
4
5
6
7
8
export function initMixin (Vue: Class<Component>) {
Vue.prototype._init = function (options?: Object) {
// ...
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
}

看代码是在有el选项时就会执行$mount方法,那上面的例子也可以改成下面这样:

1
2
3
4
5
new Vue({
render (h) {
return h('p', 'Hello World')
}
}).$mount('#app')

说到 挂载 说的就是Vue实例的原型方法$mount内部的一系列执行过程。

Vue.prototype.$mount

在文件platforms/web/entry-runtime-with-compiler.js中,我们看见过Vue.prototype.$mount的实现,今天来具体看一下。抽出关键路径,代码如下:

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
// 将原来的 $mount 缓存起来
const mount = Vue.prototype.$mount
// 重新 $mount
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
// 挂载的元素,模板或 render 返回的节点会替换 el
el = el && query(el)

const options = this.$options
// resolve template/el and convert to render function
if (!options.render) {
let template = options.template
if (template) {
// 如果 template 的值是以'#'开头的,会将该 template 的值作为选择符,
// 并使用匹配元素的 innerHTML 作为模板。
if (typeof template === 'string') {
if (template.charAt(0) === '#') {
template = idToTemplate(template)
}
}
// 如果 template 的值是DOM元素,其该DOM元素的 innerHTML 作为模板
else if (template.nodeType) {
template = template.innerHTML
}
// 其他情况视为无效的 template,开发环境下给出告警信息
else {
if (process.env.NODE_ENV !== 'production') {
warn('invalid template option:' + template, this)
}
return this
}
} else if (el) {
// 没有传 template 时,直接使用 el 元素的 outerHTML 作为模板
template = getOuterHTML(el)
}

// 如果通过上面拿到了 template,就将 template 编译成 render 函数
if (template) {
const { render, staticRenderFns } = compileToFunctions(template, {
outputSourceRange: process.env.NODE_ENV !== 'production',
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this)
// 将 render 函数放到 options 上
options.render = render
// 如果是静态的内容会被放在staticRenderFns中
options.staticRenderFns = staticRenderFns
}
}
// 调用最上面缓存的 $mount
return mount.call(this, el, hydrating)
}

可以看到,在选项中有render函数时,会忽略template选项,我们之前举的例子都是使用render函数返回页面内容的,现在看一下使用template选项的写法。

1
2
3
<script type="text/x-template" id="demo">
<p>{{ message }}</p>
</script>
1
2
3
4
5
6
7
8
9
10
11
12
new Vue({
el: '#app',
template: '#demo',
data: { message: 'Hello World' }
})

// 或者 template 直接传DOM节点
new Vue({
el: '#app',
template: document.getElementById('demo'),
data: { message: 'Hello World' }
})

也可以不传template选项,直接将elouterHTML当做模板:

1
<div id="app">{{ message }}</div>
1
2
3
4
new Vue({
el: '#app',
data: { message: 'Hello World'}
})

那么之前的Vue.prototype.$mount是在那定义的呢?

其实这个我们之前也看到过,在文件platforms/web/runtime/index.js中:

1
2
3
4
5
6
7
8
// public mount method
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}

这里实际调用的是mountComponent函数,那我们就进到这个函数看看做了什么事情。

mountComponent

mountComponent函数位于文件core/instance/lifecycle.js中,整理了一下它的关键流程如下:

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
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
if (!vm.$options.render) {
vm.$options.render = createEmptyVNode
// ... 没有render选项时给出告警信息
}
// 调用组件生命周期钩子 beforeMount
callHook(vm, 'beforeMount')

let updateComponent = () => {
// 调用 vm._render() 获得组件节点
// 在vm._update()中会触发__patch__,更新页面
vm._update(vm._render(), hydrating)
}

// we set this to vm._watcher inside the watcher's constructor
// since the watcher's initial patch may call $forceUpdate (e.g. inside child
// component's mounted hook), which relies on vm._watcher being already defined
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false

// manually mounted instance, call mounted on self
// mounted is called for render-created child components in its inserted hook
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}

总结一下mountComponent函数做的事情:

  • 判断是否有render选项,如果没有在开发环境下给出告警信息。因为后面要依赖render的返回值,所以必须先确保它可用。

  • 调用beforeMount钩子,通知用户组件要开始挂载了。

  • 执行vm._update更新页面。

    虽然没有updateComponent,而是将它传入给了Watcher,但是在Watcher的构造器中会立马执行它。至于为什么要将updateComponent放入Watcher等后面看到响应系统部分的代码时再找答案。

  • 如果vm.$vnode是空的,设置标识已经挂载了,然后调用mounted钩子,通知用户组件已经挂载完成。

    为什么要判断vm.$vnode是空的时,才说明挂载完成了呢?搜索整个项目你会发现给vm.$vnode赋值的地方有两处,一个是在Vue.prototype._render方法中:

    1
    2
    3
    const { render, _parentVnode } = vm.$options
    // ...
    vm.$vnode = _parentVnode

    一处是在updateChildComponent函数中:

    1
    2
    vm.$options._parentVnode = parentVnode
    vm.$vnode = parentVnode

    而在看createComponent代码时我们知道parentVnode来源于在内部创建组件时,所以如果vm.$vnode不为空说明正在挂载的是模板中依赖的组件,而整个挂载过程还没有完成。

Vue.prototype._render

Vue.prototype._render函数的主要目的就是要拿到render函数返回的vnode,好用于下一步patch

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
export function renderMixin (Vue: Class<Component>) {
Vue.prototype._render = function (): VNode {
const vm: Component = this
const { render, _parentVnode } = vm.$options

if (_parentVnode) {
vm.$scopedSlots = normalizeScopedSlots(
_parentVnode.data.scopedSlots,
vm.$slots,
vm.$scopedSlots
)
}

// set parent vnode. this allows render functions to have access
// to the data on the placeholder node.
vm.$vnode = _parentVnode
// render self
let vnode
currentRenderingInstance = vm
vnode = render.call(vm._renderProxy, vm.$createElement)
currentRenderingInstance = null
// if the returned array contains only a single node, allow it
if (Array.isArray(vnode) && vnode.length === 1) {
vnode = vnode[0]
}
// return empty vnode in case the render function errored out
if (!(vnode instanceof VNode)) {
// 如果 render 函数返回的不是 VNode类型的值,将 vnode 重置为空节点
vnode = createEmptyVNode()
}
// set parent
vnode.parent = _parentVnode
return vnode
}
}

_render中的处理流程如下:

  • 设置vm.$scopedSlotsvm.$slots的值,因为在render函数中可能会用到了vm.$scopedSlotsvm.$slots

    normalizeScopedSlots函数的实现涉及的东西比较多,后面说到v-slot的实现时,再具体来看这个函数都做了什么。

  • 设置vm.$vnode为选项中的_parentVnode,在要渲染子组件时它会有值。

  • currentRenderingInstance记录当前正在渲染的实例对象,这个变量在异步组件的实现时会用到。

  • 执行render函数,在这里将vm.$createElement传了过去。vm._renderProxyvm的代理对象,使得我们可以直接用vm.xxx就能访问到vm.$data.xxx,后面我会专门用一篇文章来讲数据代理的实现。在函数执行后,将currentRenderingInstance置为空。

  • 如果render函数的返回值是一个数组,并且只有一个元素,就取出这个元素再赋值给vnode。最后判断vnode的类型必须是VNode,否则将vnode重置为一个空节点。

  • 设置vnode.parent_parentVnode,并将vnode返回。

Vue.prototype._update

Vue.prototype._update函数中的关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export function lifecycleMixin (Vue: Class<Component>) {
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode
// 设置新的_vnode
vm._vnode = vnode
// Vue.prototype.__patch__ is injected in entry points
// based on the rendering backend used.
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
// ...
// updated hook is called by the scheduler to ensure that children are
// updated in a parent's updated hook.
}
}

prevVnode为空时,说明是第一次进行渲染,会将vm.$el作为oldVnode传给patch函数。如果此时vm是开发者创建的实例,vm.$el就是模板要替换的DOM元素。如果vm是模板中子组件实例,vm.$elundefined。这也是为什么我们在Vue.js源码学习 —— patch中举的例子看到的现象:

1
2
3
4
5
6
// 第一次渲染,`vm`是开发者创建的实例
// oldVnode
<div id="app"></div>

// vnode 也是 h('p', children) 返回的vnode,vnode.parent 是 undefined
VNode {tag: "p", data: undefined, children: Array(2), text: undefined, elm: undefined, …}
1
2
3
4
5
6
// 第一次渲染子组件
// oldVnode
undefined

// vnode 也是 h('p', 'No items found.') 返回的vnode
VNode {tag: "p", data: undefined, children: Array(1), text: undefined, elm: undefined, …}

进行patch后,页面中就会显示出内容了,整个挂载过程结束。在浏览器中打断点可以看到上面的调用过程:

到目前为止我们已经把从拿到模板到渲染成DOM的整个过程都分析完了。下一篇文章我们将要看lifecycle.js中的其他方法,这样Vue实例的整个生命周期做的事情就都可以连起来了。

本文作者:意林
本文链接:http://shinancao.cn/2020/01/25/Vue-Source-Code-08/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!