今天我们来说下vue实例的$mount中都发生了什么。$mount是Vue原型上的方法,是Vue实例化的最后一步。$mount分为带编译器版本和不带编译器版本。我们以下面的代码为例,来讲下在$mount时都发生了什么。
实例代码如下(来源于codesandbox的默认vue项目代码):
1 | // main.js |
$mount是vue实例化中不可缺少的一部分,它将template转化成虚拟dom,然后根据虚拟dom渲染真实dom节点并进行挂载。在这个过程中,主要讲以下几点:
- mountComponent中都发生了什么
- 渲染watcher(renderWatcher)的执行过程
- entry-runtime.js和entry-runtime-with-compiler.js的区别及适用场景
1. mountComponent中发生了什么?
在Vue实例化完成的最后一步(即_init原型方法中),如果初始化的参数里有el,则自动调用$mount方法。否则,需要手动调用$mount方法并传入挂载节点。
实例代码中,是在new Vue()之后手动调用了$mount方法,并传入了App组件。下面找到$mount方法的定义:
1 | // src/platforms/web/runtime/index.js |
由上可知,$mount获取到传入的App, 并且去query。在query中,由于App经过vue-loader编译后是一个Object,所以在query中被直接返回了。所以然后紧接着调用mountComponent函数。
1 | // src/core/instance |
在mountComponent中,renderWatcher是最关键的地方。通过renderWatcher,将render函数转换成vnode,然后通过vm._update,将vnode渲染成真实的dom。
2. 渲染watcher(renderWatcher)的执行过程
渲染watcher的执行过程,就是Watcher实例化的过程。
1 | // src/core/observer/watcher.js |
由以上代码可知,在renderWatcher的执行过程中,this.get()的执行是最为重要的。而this.get()的执行则主要切换了Dep.target,执行了updateComponent函数。
updateComponent中, 主要执行了vm._update(vm._render(), hydrating)函数。下面着重来分析vm._update的过程。
vm._render的执行过程
_render是Vue原型上的方法,定义在src/core/instance/render.js中,下面分析下_render方法主要做了什么。
其实_render主要做了一件事,调用render函数生成vnode(虚拟dom)并返回。
1. 从vm.$options取出render和_parentVnode。在上边的例子中,new Vue({ render: h => h(App) }).$mount('#app')。
在这里, _parentVnode为undefined。
2. 设置_parentVnode为vm.$vnode,即当前vue实例的占位符vnode。
3. 设置currentRenderingInstance = vm, 同时执行渲染函数render,取得render返回的vnode。
4. 然后将currentRenderingInstance置为null
5. 返回vnode,并设置vnode.parent为_parentVnode.
在这里render函数主要调用了 render.call(vm, vm.$createElement)。对应于例子中, h就是vm.$createElement。就是根据传入的tag或者component,生成对应的vnode的一个函数。下面来分析下vm.$createElement函数。 vm.$createElement也定义在render.js文件中,在vm._init中执行挂载。vm.$createElement定义如下:
1 | // 最后一个true用以标示是用户主动调用,即定义了render函数。而非由编译器调用。 |
createElement最终会调用_createElement,下面来分析下_createElement。
1 | // src/core/vdom/create-element.js |
接下来会调用createComponent函数来创建组件。在createComponent中,主要做了下面几件事:
1 | export function createComponent ( |
至于Vue.extend及其它函数的细节,就不详细讲了。大概明白它主要做了什么就好。
至此,vm._render的过程就进行完了,主要就是根据render函数创建并返回了vnode。
vm._update过程
vm._update主要是将vnode转换成真实dom。在这个过程中,包含了真实dom节点的增删改查操作,dom节点属性的操作及虚拟节点vnode的patchDiff过程。
vm._update也是Vue原型上的方法,定义在src/core/instance/lifecycle.js中。在_update中,主要就是取出之前的渲染vnode, 然后新旧vnode进行patch。在patch中将vnode的改动映射到真实dom上。在这个过程中,尤其是新旧vnode都存在时,通过patchDiff算法,找出最小的更改点,尽可能重用dom或者尽可能少地操作dom,达到一个相对较好的效果。
在patch的过程中,activeInstance为当前vm实例。当patch过程完成后,还原activeInstance为之前的vm实例。
第一次进行渲染时,preVnode为undefined。所以会进入if分支。
1 | Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { |
vm.__patch也是Vue原型上的方法。__patch__最终调用了src/core/vdom/patch中的createPatchFunction方法,它的返回值就是__patch__函数。
在调用createPatchFunction时,传入了nodeOps和modules。nodeOpts和modules都是平台相关的。这也就是为什么Vue能在多个平台上工作的原因。以weex为例,Vue负责生成vnode,在createPatchFunction时传入平台相关的节点操作和modules,提前固化相关的节点操作和模块配置,以便在patch的过程中生成平台相关的节点和进行相关的节点操作。
在createPatchFunction中,提取了modules中的'create', 'activate', 'update', 'remove',
'destroy'的钩子函数,并存储在cbs中,以便后期的patch函数调用。最后,返回了patch函数。
最终工作的还是createPatchFunction返回的patch函数。这是整个patch过程的核心。下面来看下这个patch函数。
1 | return function patch (oldVnode, vnode, hydrating, removeOnly) { |
对于示例代码而言,最终会走到createElm中。createElm(vnode, [], body, textnode)。
在createElm中,对vnode进行处理,最终通过调用nodeOpts中的各种操作,完成对dom节点的增删改查操作。同时,通过调用对应的钩子函数,完成对dom属性的变更操作。
在createElement之后,如果当前的vnode有parent,即当前vnode是一个渲染vnode,而vnode.parent是一个组件占位符vnode。更新vnode.parent所绑定的elm为创建新节点后的vnode的elm。同时调用相关的钩子函数完成占位符节点的更新操作。
之后,移除oldVnode,增加vnode到parentElm。将子组件由子到父调用mounted钩子函数。
至此patch的初始创建过程完成。最后返回新创建或者更改后的最新的vnode.elm即真实的dom节点。
下面来看下createElm的代码。createElm主要做了以下几件事:
要说的都在注释里了,就不再额外阐述了。总之createElm就做了创建并插入dom节点这件事。
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
// src/core/vdom/patch.js
function createElm (
vnode,
insertedVnodeQueue,
parentElm,
refElm,
nested,
ownerArray,
index
) {
if (isDef(vnode.elm) && isDef(ownerArray)) {
// This vnode was used in a previous render!
// now it's used as a new node, overwriting its elm would cause
// potential patch errors down the road when it's used as an insertion
// reference node. Instead, we clone the node on-demand before creating
// associated DOM element for it.
vnode = ownerArray[index] = cloneVNode(vnode)
}
vnode.isRootInsert = !nested // for transition enter check
// 1. 尝试去创建组件,如果当前vnode是占位符vnode,则会返回true,
// 也就不走之后的流程了。示例代码中第一次到达此处的是一个组件的vnode,
// 它是占位符vnode,所以会进入createComponent执行创建VueComponent实例。
if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
return
}
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
// 如果tag存在,此处的tag应为一个dom节点的tagname。组件的tag会在前边createComponent被处理掉。
if (isDef(tag)) {
if (process.env.NODE_ENV !== 'production') {
if (data && data.pre) {
creatingElmInVPre++
}
}
2. // 获取到vnode.tag,创建真实的dom节点
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)
setScope(vnode)
// 此处移除了weex里的逻辑,只留下web平台的代码
// 3. 调用createChildren,对vnode的children进行深度遍历,直到
createChildren(vnode, children, insertedVnodeQueue)
if (isDef(data)) {
// 调用对应的钩子函数
invokeCreateHooks(vnode, insertedVnodeQueue)
}
// 4. 将当前vnode.elm插入到paremtElm中。完成dom创建操作
insert(parentElm, vnode.elm, refElm)
if (process.env.NODE_ENV !== 'production' && data && data.pre) {
creatingElmInVPre--
}
} else if (isTrue(vnode.isComment)) {
// 如果是注释vnode,直接创建注释节点并插入
vnode.elm = nodeOps.createComment(vnode.text)
insert(parentElm, vnode.elm, refElm)
} else {
// 否则,把当前vnode视为文本节点,创建文本节点并插入
vnode.elm = nodeOps.createTextNode(vnode.text)
insert(parentElm, vnode.elm, refElm)
}
}
1 | // src/core/vdom/patch.js |
既然createElm的主要逻辑在createComponent中,那就看下createComponent吧。在createComponent中,根据当前的组件vnode,会生成VueComponent实例并执行相应的初始化过程,最后调用子组件实例的$mount方法。进行子组件相关dom节点的创建工作。
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
// src/core/vdom/patch.js
function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
let i = vnode.data
if (isDef(i)) {
const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
if (isDef(i = i.hook) && isDef(i = i.init)) {
// 1. 调用组件vnode.data.hook上的init方法,执行了子组件构造函数的_init函数,并且执行了子组件的$mount方法。
i(vnode, false /* hydrating */)
}
// 在调用init钩子后,如果当前vnode是一个子组件,它应该已经创建
// 了一个组件实例并且挂载了它(即调用了$mount方法。
// 子组件也已经设置了elm属性,elm是子组件生成的对应的dom节点。
if (isDef(vnode.componentInstance)) {
// componentInstance就是子组件对应的vm实例。
// 清空子组件vnode.data.pendingInsert,并把它存在insertedVnodeQueue上。
// 设置当前vnode.elm属性为componentInstance的$el。
initComponent(vnode, insertedVnodeQueue)
// 将vnode对应的dom节点插入到父节点指定的位置。
insert(parentElm, vnode.elm, refElm)
if (isTrue(isReactivated)) {
reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
}
return true
}
}
}
在生成子组件vm实例时,会调用vm实例上的$mount方法。而$mount方法又要走到patch function中。最终也会进入到createElm方法中。不过这时调用createComponent会返回false。因为此时的vnode.tag为App.vue中的父级div()。
之后进入createElm的第二步、第三步、第四步。这个过程就不再赘述了。反正就是一个createElm和createChildren互相调用的过程。在这个过程中,创建了对应的dom节点,并通过调用invokeCreateHook函数完成对dom属性的操作。
最后经过createElm,已经生成了一个属性完备的dom节点,并且已经插入到了body中。初次的vnode渲染dom就完成了。
1 | // src/core/vdom/patch.js |
entry-runtime.js和entry-runtime-with-compiler.js的区别及适用场景
从字面意思看,两个文件就差了compiler即编译器。翻阅代码可得,entry-runtime-with-compiler版本是对entry-runtime.js版本的扩展,即重写了entry-runtime.js中Vue的原型方法$mount。
在vue-cli初始化项目时,会让我们选择使用哪个版本的vue,官方推荐的是选择with-compiler版本的。
其实,with-compiler版本比runtime版本多的就是一个编译器,就是在初始化vue时的render函数。如果没有选择这个版本,我们需要在初始化vue实例时手动传入render函数配置项。而不能直接以sfc(单文件组件)的方式来写vue。
而在手动传入render函数时,我们要以创建虚拟dom的形式传入参数。例如:
1 | new Vue({ |
而使用带compiler版本的,我们可以这么写:
1 | <div id='app'>hello,world</div> |
这期就分析到这里了。拜拜~