Vue 组件 patch
上一篇的最后一个示例中提到,在 VNode 的构建过程中,并没有看到 App 组件内部引入的 HelloWorld 组件相关的内容,这是为什么呢?接下来就来看一下原因
# 生成真实 DOM
还是回到 mountComponent
方法中的渲染 Watcher 的实例化过程中来,之前的文章中说过,当实例化渲染 Watcher 的时候,就会调用下面的 updateComponent
方法
updateComponent = function () {
// render生成虚拟DOM,update渲染真实DOM
vm._update(vm._render(), hydrating)
}
2
3
4
上一篇已经说到了 vm._render()
方法会生成组件的虚拟 DOM,也就是 VNode 并返回,所以接下来就看下 vm._update
方法,是如何将生成的虚拟 DOM 渲染成真实的 DOM 的。
# vm._update
updateComponent 方法的执行有两种情况,
- 组件初始化渲染的时候,此时
prevVnode
为空,走到代码的第 12 行 - 组件依赖的数据更新后重新渲染,此时
prevVnode
不为空,走到代码的第 15 行
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
// preVnode 是用于 diff 的老版本 vnode
// 初始化渲染的时候为空
const prevVnode = vm._vnode
const restoreActiveInstance = setActiveInstance(vm)
vm._vnode = vnode
// based on the rendering backend used.
if (!prevVnode) {
// 初始化渲染 render,因为初次渲染是没有 preVnode 的
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// 数据更新的 render
vm.$el = vm.__patch__(prevVnode, vnode)
}
restoreActiveInstance()
// update __vue__ reference
if (prevEl) {
prevEl.__vue__ = null
}
if (vm.$el) {
vm.$el.__vue__ = vm
}
// if parent is an HOC, update its $el as well
if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
vm.$parent.$el = vm.$el
}
// updated hook is called by the scheduler to ensure that children are
// updated in a parent's updated hook.
}
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
这两种情况下,都会调用 Vue 原型上的 __patch__
方法
# Vue.prototype.__patch__
const modules = platformModules.concat(baseModules)
const patch = createPatchFunction({ nodeOps, modules })
Vue.prototype.__patch__ = inBrowser ? patch : noop
2
3
4
- 这里的判断,如果不是浏览器环境(
ssr
场景)的话,该方法是一个空函数, patch
方法是由createPatchFunction
函数执行后返回的,这个函数接收一个对象作为参数,其中nodeOps
是针对不同平台(web/weex)封装的 DOM 节点的操作方法modules
则是定义了不同平台的模块的钩子函数platformModules
-style/class/events/transition
baseModules
-directives/ref
# createPatchFunction
export function createPatchFunction(backend) {
const { modules, nodeOps } = backend
// ...
return function patch(oldVnode, vnode, hydrating, removeOnly) {
// ...
return vnode.elm
}
}
2
3
4
5
6
7
8
patch 方法的核心是通过调用
createElm
创建节点,插入子节点,递归创建一个完整的 DOM 树并插入到 Body 中。并且在产生真实 DOM 阶段,会有 diff 算法来判断前后 Vnode 的差异,以求最小化改变。
有关 DOM Diff 相关的内容,回头另起一篇单独说。这里就先看下整个 DOM 树的递归构建过程。
# patch
这个方法一步步来看
# 首先看下入参
- oldVnode:旧的 VNode
- vnode:新的 VNode
- hydrating:是否是服务端渲染
- removeOnly:是给
TransitionGroup
用的
# patch 过程
这里以上一篇中结尾例子中的 App 组件的 vnode 为例,来看下 patch 过程
{
tag: "vue-component-1-App",
data: {
on: undefined,
hook: {
init: function init (vnode, hydrating) {
// ...
},
prepatch: function prepatch (oldVnode, vnode) {
// ...
},
insert: function insert (vnode) {
// ...
},
destroy: function destroy (vnode) {
// ...
},
},
},
componentOptions: {
Ctor: function VueComponent (options) {
this._init(options);
},
tag: "App",
},
componentInstance: undefined,
},
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
- 在首次渲染的过程中,因为 App 组件的 oldVnode 不存在,所以就会走到
处理组件首次渲染的场景
的部分 - 随后会调用
createElm(vnode, [])
function patch(oldVnode, vnode, hydrating, removeOnly) {
// =========== 如果vnode不存在,oldVnode存在,则调用 destroy 钩子 =========== //
if (isUndef(vnode)) {
if (isDef(oldVnode)) {
invokeDestroyHook(oldVnode)
}
return
}
// 标识是否为首次渲染
let isInitialPatch = false
// 待插入的所有Vnode集合
const insertedVnodeQueue = []
// =========== 处理组件首次渲染的场景 =========== //
if (isUndef(oldVnode)) {
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else {
/* ... */
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# createElm
function createElm(
vnode,
insertedVnodeQueue,
parentElm,
refElm,
nested,
ownerArray,
index
) {
if (isDef(vnode.elm) && isDef(ownerArray)) {
vnode = ownerArray[index] = cloneVNode(vnode)
}
vnode.isRootInsert = !nested
// ======= 如果 vnode 是一个组件,而不是一个标签 ======== //
if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) return
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
createElm 方法的内部又调用了 createComponent 方法,来处理组件 vnode 的场景
function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
var i = vnode.data
if (isDef(i)) {
// 获取 init hook,并调用
if (isDef((i = i.hook)) && isDef((i = i.init))) {
i(vnode, false /* hydrating */)
}
// ...
}
}
2
3
4
5
6
7
8
9
10
根据上面的 App 组件的 vnode 结构,可以看出 createComponent 会继续调用定义在 App vnode 对象上面的 init hook
,关于这个钩子之前一直没细说,现在看下
function createComponentInstanceForVnode (vnode, parent) {
var options = {
// ===== 这里的 _isComponent 属性 ======= //
_isComponent: true,
_parentVnode: vnode,
parent: parent
};
var inlineTemplate = vnode.data.inlineTemplate;
if (isDef(inlineTemplate)) {
options.render = inlineTemplate.render;
options.staticRenderFns = inlineTemplate.staticRenderFns;
}
// ======== 调用组件的构造器 ======= //
return new vnode.componentOptions.Ctor(options)
}
function init(vnode, hydrating) {
if (/**/) {
// ....
} else {
// ============= 构建子组件 vm 实例, 并挂在了组件 vnode 实例的 componentInstance 上 ============= //
var child = (vnode.componentInstance = createComponentInstanceForVnode(
vnode,
activeInstance
))
// ============= 执行组件的挂载操作 ============= //
child.$mount(hydrating ? vnode.elm : undefined, hydrating)
}
}
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
- 在 init 钩子里,调用了 App 组件的构造器创建出了 App 组件的 vm 实例,组件实例化后还调用了 $mount 方法完成组件的挂载
- 还记得这里的
_isComponent
属性吗,在选项合并的时候,对组件的选项合并进行特殊处理的地方就是通过这个属性进行判断的
componentOptions: {
Ctor: function VueComponent (options) {
this._init(options);
},
},
2
3
4
5
这样一来,App 组件的实例化过程也会跟 new Vue 时的流程一样,走到 _init
方法中去,完成一系列的初始化操作。由于组件不需要传递 el 选项指定挂载的元素,所以这里还会自动调用 $mount
完成组件的挂载。
然后挂载的过程中,App 组件也同样会经历:
- 调用 App 组件的
render
生成 App 组件内部的 vnode
说到这里,就回答了文章开始提到的那个问题,就是为什么在 App 组件内部的
HelloWorld
组件,在一开始的 vue 实例化过程中产生的 vnode 中并没有体现出来,就是因为 HelloWorld 组件的 vnode 的生成其实是在 App 组件实例化的过程中,也就是调用 App 组件对象的 render 方法后才会产生
- 调用
update
,开启 App 组件内部的 vnode 的patch
过程。
然后在 patch 过程中,又会调用
HelloWorld
组件上的 init 钩子, 然后又会是HelloWorld -> $mount -> render -> vnode -> patch
由此可见,这是一个深度递归的过程。
另外还有一个地方需要说明,就是对于 children 的处理,这里以 App.vue 这个组件的 Vnode 为例
# children 的处理
- App.vue
<template>
<div class="content-wrapper">
<HelloWorld />
{{ name }}
</div>
</template>
<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
name: 'App',
components: { HelloWorld },
data() {
return {
name: 'App',
}
},
created() {
console.log('this is App.vue')
},
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
对应的 vnode 如下:
{
tag: "div",
data: {
staticClass: "content-wrapper",
},
children: [
{
tag: "vue-component-2-HelloWorld",
data: {
hook: {
init() {
// ...
}
// ...
}
},
componentOptions: {
Ctor: function VueComponent (options) {
this._init(options);
},
tag: "HelloWorld",
},
},
{
tag: undefined,
data: undefined,
children: undefined,
text: " App ",
}
]
}
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
此时在 App 组件的 patch 过程中,传递给 createElm 方法的 vnode 就是上面的这种形式,可以看到该 vnode 是拥有一个 children 属性的。那么 createElm 内部是怎么处理的呢?
由于最外层的 div 节点的 data 属性中没有 hook 的定义,所以 createComponent 方法会返回 undefined,然后继续向下走就会执行到 createChildren
的逻辑
createChildren(vnode, children, insertedVnodeQueue)
function createChildren(vnode, children, insertedVnodeQueue) {
if (Array.isArray(children)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(children)
}
// 递归调用 createElm 来创建子组件
for (var i = 0; i < children.length; ++i) {
createElm(
children[i],
insertedVnodeQueue,
vnode.elm,
null,
true,
children,
i
)
}
} else if (isPrimitive(vnode.text)) {
nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
在 createChildren 方法内部,就会遍历 children 中的所有子 vnode,并递归调用 createElm 为子元素创建真实 DOM
到这里,组件初次渲染的场景下,由组件 vnode 生成真实 DOM 的过程就讲完了。数据更新的场景 DOM diff 的流程单独再说。
# 父子组件生命周期函数触发次序
最后再说一点,基于上面说的整个组件以及子组件实例化的顺序可以得出下面的结论:
父组件和子组件的生命周期钩子函数的触发顺序依次为:
父组件beforeCreate -> 父组件created -> 父组件beforeMount -> 子组件beforeCreate -> 子组件created -> 子组件beforeMount -> 子组件mounted -> 父组件mounted