返回

专栏第七篇-初次渲染流程

上篇我们介绍了Vue渲染的基础-虚拟DOM。

在渲染阶段,Vue通过虚拟 DOM来创建元素。

一、编译时的挂载操作

在专栏第四篇中,我们提到了在Vue实例化时,会执行init函数来初始化一些后续需要的选项。

通常在实例化后我们会执行挂载操作。

而搜索源码可以发现,$mount方法被定义了 2 次。

根据是否需要模版编译,存在 2 个$mount方法。

在模板编译版本的 Vue中,重写了运行时的$mount方法,其中添加了一些模板编译的方法。

模板编译意为将 template转化成 render函数。

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
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && query(el)

/* istanbul ignore if */
if (el === document.body || el === document.documentElement) {
__DEV__ &&
warn(
`Do not mount Vue to <html> or <body> - mount to normal elements instead.`
)
return this
}

const options = this.$options
// resolve template/el and convert to render function
if (!options.render) {
let template = options.template
if (template) {
if (typeof template === 'string') {
if (template.charAt(0) === '#') {
template = idToTemplate(template)
/* istanbul ignore if */
if (__DEV__ && !template) {
warn(
`Template element not found or is empty: ${options.template}`,
this
)
}
}
} else if (template.nodeType) {
template = template.innerHTML
} else {
if (__DEV__) {
warn('invalid template option:' + template, this)
}
return this
}
} else if (el) {
// @ts-expect-error
template = getOuterHTML(el)
}
if (template) {
/* istanbul ignore if */
if (__DEV__ && config.performance && mark) {
mark('compile')
}

const { render, staticRenderFns } = compileToFunctions(
template,
{
outputSourceRange: __DEV__,
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
},
this
)
options.render = render
options.staticRenderFns = staticRenderFns

/* istanbul ignore if */
if (__DEV__ && config.performance && mark) {
mark('compile end')
measure(`vue ${this._name} compile`, 'compile', 'compile end')
}
}
}
return mount.call(this, el, hydrating)
}
  1. 首先获取传入el参数,也就是挂载的节点。
  2. 然后判断挂载节点是否是body节点或者html节点,如果是则提示无法挂载并返回。
  3. 如果没有render方法,获取template选项。
    1. 如果存在 template选项:
      • template是一个选择节点字符串(#app),将template变量设置为节点字符串对应的HTML代码。
      • template是一个真实DOM节点,将template变量设置为节点对应的HTML代码。
      • 如果都不是,则会提示无效的template选项。
    2. 如果不存在 template选项,但是有 el节点,则将template变量设置为el节点对应的 HTML代码。
    3. 获取到对应的模板,然后将这个将这个模板转换成 render函数
  4. 如果有render方法,直接执行 mount方法,也就是运行时定义的$mount方法。

二、运行时的挂载操作

上面我们说到编译时的$mount方法将模板转换成了render方法。

然后调用了运行时的$mount方法。

让我们来看看$mount方法都做了什么操作。

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

我们可以看到就是获取节点,然后调用了mountComponent方法。

三、mountComponent

可以看出来最终执行了mountComponent方法。

我们把目光移到mountComponent方法中。

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
export function mountComponent(
vm: Component,
el: Element | null | undefined,
hydrating?: boolean
): Component {
vm.$el = el
if (!vm.$options.render) {
// @ts-expect-error invalid type
vm.$options.render = createEmptyVNode
if (__DEV__) {
/* istanbul ignore if */
if (
(vm.$options.template && vm.$options.template.charAt(0) !== '#') ||
vm.$options.el ||
el
) {
warn(
'You are using the runtime-only build of Vue where the template ' +
'compiler is not available. Either pre-compile the templates into ' +
'render functions, or use the compiler-included build.',
vm
)
} else {
warn(
'Failed to mount component: template or render function not defined.',
vm
)
}
}
}
callHook(vm, 'beforeMount')

let updateComponent
/* istanbul ignore if */
if (__DEV__ && config.performance && mark) {
updateComponent = () => {
const name = vm._name
const id = vm._uid
const startTag = `vue-perf-start:${id}`
const endTag = `vue-perf-end:${id}`

mark(startTag)
const vnode = vm._render()
mark(endTag)
measure(`vue ${name} render`, startTag, endTag)

mark(startTag)
vm._update(vnode, hydrating)
mark(endTag)
measure(`vue ${name} patch`, startTag, endTag)
}
} else {
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
}

const watcherOptions: WatcherOptions = {
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}

if (__DEV__) {
watcherOptions.onTrack = e => callHook(vm, 'renderTracked', [e])
watcherOptions.onTrigger = e => callHook(vm, 'renderTriggered', [e])
}

// 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,
watcherOptions,
true /* isRenderWatcher */
)
hydrating = false

// flush buffer for flush: "pre" watchers queued in setup()
const preWatchers = vm._preWatchers
if (preWatchers) {
for (let i = 0; i < preWatchers.length; i++) {
preWatchers[i].run()
}
}

// 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
}
  1. 将传入的节点挂载 vm.$el上。
  2. 判断是否有 render方法。如果没有render方法则会赋予render方法一个创建空 VNode的方法以及提示否则。有则继续向下执行代码。
  3. 调用 beforeMount钩子
  4. 定义updateComponent方法,其中根据是否配置性能配置在该方法中打了一个性能 tag。
  5. 传入updateComponent方法并实例化 Watcher,在实例化时会执行传入的updateComponent方法。
  6. 判断$vnode是否有值,有值则调用 mounted方法。

至此我们已经完成了挂载阶段的源码阅读。

实际上 updateComponent 就是真正渲染和挂载节点的地方。

1
updateComponent  = vm._update(vm._render())

我们来画个流程图来加深巩固一下整个渲染流程。

所以,我们现在可以很清楚的看出来。

Vue渲染大体上分为 2 步:

  1. 调用vm._render方法获取 vnode。
  2. 调用vm._update将获取到的 vnode渲染到页面上。

四、调用_render方法来获取 vnode

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

if (_parentVnode && vm._isMounted) {
vm.$scopedSlots = normalizeScopedSlots(
vm.$parent!,
_parentVnode.data!.scopedSlots,
vm.$slots,
vm.$scopedSlots
)
if (vm._slotsProxy) {
syncSetupSlots(vm._slotsProxy, vm.$scopedSlots)
}
}

// set parent vnode. this allows render functions to have access
// to the data on the placeholder node.
vm.$vnode = _parentVnode!
// render self
const prevInst = currentInstance
const prevRenderInst = currentRenderingInstance
let vnode
try {
setCurrentInstance(vm)
currentRenderingInstance = vm
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e: any) {
handleError(e, vm, `render`)
// return error render result,
// or previous vnode to prevent render error causing blank component
/* istanbul ignore else */
if (__DEV__ && vm.$options.renderError) {
try {
vnode = vm.$options.renderError.call(
vm._renderProxy,
vm.$createElement,
e
)
} catch (e: any) {
handleError(e, vm, `renderError`)
vnode = vm._vnode
}
} else {
vnode = vm._vnode
}
} finally {
currentRenderingInstance = prevRenderInst
setCurrentInstance(prevInst)
}
// if the returned array contains only a single node, allow it
if (isArray(vnode) && vnode.length === 1) {
vnode = vnode[0]
}
// return empty vnode in case the render function errored out
if (!(vnode instanceof VNode)) {
if (__DEV__ && isArray(vnode)) {
warn(
'Multiple root nodes returned from render function. Render function ' +
'should return a single root node.',
vm
)
}
vnode = createEmptyVNode()
}
// set parent
vnode.parent = _parentVnode
return vnode
}

可以看出来_render方法就是获取选项上的render函数并执行,就可以获取vnode了。

render函数的具体逻辑在上一章中我们也了解的差不多了。

就是通过内部的 createElement 函数来创建VNode。

五、调用_update方法来进行渲染

_update方法被定义在lifecycleMixin方法中。

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
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode
const restoreActiveInstance = setActiveInstance(vm)
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)
}
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
let wrapper: Component | undefined = vm
while (
wrapper &&
wrapper.$vnode &&
wrapper.$parent &&
wrapper.$vnode === wrapper.$parent._vnode
) {
wrapper.$parent.$el = wrapper.$el
wrapper = wrapper.$parent
}
// updated hook is called by the scheduler to ensure that children are
// updated in a parent's updated hook.
}

5.1 通过vm._vnode来判断是否是初次渲染

_update方法的执行直接有 2 个:1.初次渲染、2.更新渲染。color:orange

在每次渲染时,会将当前传入的 vnode赋值给 vm._vnode。

而在做此操作前,会获取vm._vnode意味上一次渲染的 vnode。

那么在初次渲染时,prevVnode这个值是空的。

所以就可以根据这个值来判断是否是初次渲染。

如果是初次渲染,调用__patch__函数传入的第一个参数和第二个参数分别为 vm.$el、vnode。

如果是更新渲染,调用__patch__函数传入的第一个参数和第二个参数分别为 prevVnode、vnode。

5.2 vm.$el是什么

vm.$el是在 mountComponent函数中赋值的。

也就是执行$mount方法中传入的挂载的节点。

1
vm.$mount('#app')

所以初次渲染时,vm.$el就是真实的 DOM节点。

5.3 patch

该方法是通过createPatchFunction函数来创建的。

所以 createPatchFunction函数返回了一个函数。

1
export const patch: Function = createPatchFunction({ nodeOps, modules })
  1. nodeOps是浏览器操作DOM的相关方法。如删除 DOM、添加 DOM等。
  2. modules中存在了一些处理 DOM 属性的方法。如设置样式等。

5.4 createPatchFunction

5.4.1 第一个参数oldVNode和第二个参数vnode

1
2
3
4
5
function createPatchFunction(){
return function patch(oldVnode,vnode){
// xxx
}
}

可以看到该函数的第一个参数是oldVnode,第二个参数是 vnode。

oldVnode表示旧的 vnode。

Vue在更新渲染时,会进行双端 diff对比,这个时候需要获取旧的 vnode进行对比。

因为这个函数时初次渲染和更新渲染的通用函数。

而前面我们说到在处理渲染时传递的是 vm.$el,这代表挂载的节点。

所以对于初次渲染(将虚拟节点渲染到真实节点)来说,旧节点表示真实的节点。

实际上挂载节点相当于一个索引节点,会根据他的位置进行挂载节点,挂载节点在初次渲染后就会被删除。

5.4.2 处理旧节点的销毁

如果旧的虚拟节点存在,但是新的虚拟节点不存在。

则表示该节点可能由于条件渲染(v-if)被移除,这时候调用invokeDestroyHook函数,确保旧节点被正确的销毁和清理。

如果新节点不存在,则终止当前的 patch操作。

这是因为没有新的节点需要渲染,所以没有必要继续执行更新 DOM的操作。

1
2
3
4
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}

5.4.3 判断旧的vnode是否是真实节点

因为在初次渲染时,旧的 vnode是挂载元素,也就是一个真实 DOM。

而 patch内部的处理基本上都是基于虚拟 DOM来处理的。

所以需要将真实 DOM通过emptyNodeAt函数包装成虚拟 DOM。

1
2
3
function emptyNodeAt(elm) {
return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
}
将虚拟节点的elm属性指向传入的dom节点。

5.4.4 createElm创建子节点

初次渲染时,我们无需关心更新的逻辑。

只需要将最新的 vnode渲染到页面上即可。

直接调用 createElm方法。

5.4.4.1 创建DOM元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export function createElm(
vnode,
parentElm,
refElm
){
const tag = vnode.tag;
const children = vnode.children;
if(isDef(tag)){
vnode.elm = nodeOps.createElement(tag);

createChildren(vnode, children);
}
}

export function createChildren(vnode, children){
if(isArray(children)){
createElm(
children[i],
vnode.elm,
null
)
}
}

可以看到createElm方法中调用了document.createElement创建了 DOM元素并将创建的元素赋值给虚拟节点的 elm属性中。

5.4.4.2 执行 inset方法将创建的 DOM挂载到父节点上

1
2
3
4
5
6
7
8
9
10
11
12
13
insert(parentElm, vnode.elm, refElm)

function insert(parent, elm, ref) {
if (isDef(parent)) {
if (isDef(ref)) {
if (nodeOps.parentNode(ref) === parent) {
nodeOps.insertBefore(parent, elm, ref)
}
} else {
nodeOps.appendChild(parent, elm)
}
}
}

如果有 ref节点,则调用 insetBefore插入 ref节点之前。

否则调用 appendChild 直接插入数组最后面。

5.4.4.3 初次渲染时需要删除旧节点

节点初次挂载后,需要将旧的节点给删除掉。

1
2
3
4
5
6
// destroy old node
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}

六、总结

我们这篇中简单介绍了初次挂载时的大概流程。

实际上就是通过浏览器的一些 API如createElement、appendChild来创建真实 DOM。

其中涉及到的一些细节并没有做详细阐述,比如说组件的处理,样式事件属性的注册等。

在后面的文章中我们会一一给大家解答。


本站总访问量