Vue源码虚拟DOM将去往何处?
Posted 天地会珠海分舵
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Vue源码虚拟DOM将去往何处?相关的知识,希望对你有一定的参考价值。
通过上一篇文章《Vue源码之虚拟DOM来自何方?》,我们看到了vue组件的模板最终被编译成了render函数,然后了解到了该render函数是怎么的在组件初始化或者数据状态更新时被渲染watcher的回调updateComponent所触发的,最后还一步步分析出编译后的vue组件模板是怎么变成虚拟DOM的。
有了虚拟DOM之后,下一步我这里想学习下的就是这些虚拟DOM将去往何处?当然,答案这里很明显,当然是要变成真实DOM在页面上显示了。但问题的关键是how?这就是我接下来要学习的重点。
先回顾下updateComponent那几行代码
updateComponent = () =>
const vnode = vm._render();
vm._update(vnode, hydrating);
1. patch之前先准备好老的虚拟节点
在获取到组件的虚拟DOM后,下一行就是调用_update这个vue原型方法
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean)
const vm: Component = this;
const prevEl = vm.$el;
const prevVnode = vm._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);
...
我们通过最新生成的虚拟DOM去更新页面时,有可能这是第一次更新,也就是说此前还没有老的prevVnode,这时将把vm.$el作为老的虚拟节点,这个vm.$el有可能是我们在new vue是指定的一个挂载点
<div id="app"></div>
<template id="demo">
<div>
<div >HelloWorld</div>
</div>
</template>
<script>
const vm = new Vue(
template: '#demo',
).$mount("#app");
</script>
在调用$mount的时候会去真实DOM里面query这个#app为id的挂载点。
// platforms/web/entry-runtime-with-compiler.js文件
const mount = Vue.prototype.$mount;
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component
el = el && query(el);
...
return mount.call(this, el, hydrating);
// platforms/web/runtime/index.js文件
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
// core/instance/lifecycle.js文件
export function mountComponent(
vm: Component,
el: ?Element,
hydrating?: boolean
): Component
vm.$el = el;
...
query到这个挂载点的真实DOM后,会将其放到组件或者vue实例的$el属性下面。
还有一种情况针对的是组件实例的初始化而不是上面的vue实例初始化,初始化时也不会有老的组件虚拟DOM,且组件初始化是不会像vue实例初始化那样指定 e l 选项或者明确调用 el选项或者明确调用 el选项或者明确调用mount(‘#app’)的,这时vm.$el就直接是undefined了。
回到_update这个方法,在上面这种情况下,既然挂载点都还是真实DOM,所以示例的_vnode很显然也不存在了,即prevVnode不存在,所以就会把$el作为老虚拟DOM来进一步调用__patch__方法
以上是第一次创建vue实例的情况,但也有可能组件中老的虚拟节点已经存在了,这次只是因为数据的改变引起的更新,这时就直接提供之前保存的老的_vnode
2. patch要做的事情概括
那么有了新旧虚拟DOM后,vue要做的事情就是对他们做diff,然后看怎么才能最小化的更新到最新的页面。这里无非是三种情况:
- 新虚拟DOM有,老虚拟DOM空:直接根据新的虚拟DOM来创建对应的真实DOM。
- 新虚拟DOM没有,老虚拟DOM有:那代表这部分节点被删除掉了,直接在真实DOM中移除对应的节点就好了。
- 新虚拟DOM有,老虚拟DOM也有:这代表发生了数据的更新,这时就需要去diff两个dom有哪些差别,然后针对性的做真实DOM的更新了
以上patch要做的事情前两者实现起来相对比较简单,而第三种需要diff的更新的情况比较复杂,个人当前没有那么多精力去详细分析,所以往下会挑个中间难度的『新虚拟DOM有,老虚拟DOM没有』的情况来进行分析。
3. 根据虚拟DOM创建真实DOM的patch代码分析
先让我们看下patch的关键代码
return function patch (oldVnode, vnode, hydrating, removeOnly)
...
if (isUndef(oldVnode))
// empty mount (likely as component), create new root element
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
else
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode))
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
else
if (isRealElement)
...
oldVnode = emptyNodeAt(oldVnode);
// replacing existing element
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// create new node
createElm(
vnode,
insertedVnodeQueue,
// extremely rare edge case: do not insert if old element is in a
// leaving transition. Only happens when combining transition +
// keep-alive + HOCs. (#4590)
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
...
// destroy old node
if (isDef(parentElm))
removeVnodes([oldVnode], 0, 0)
else if (isDef(oldVnode.tag))
invokeDestroyHook(oldVnode)
...
return vnode.elm
根据上面的分析,当组件实例对象初始化的时候,老的虚拟节点会设置成undefined,这就是代码开始的时候为什么判断odlVnode为undefined时就直接调用createElm进行组件创建的原因。
跟着判断如果存在老的vnode和新的vnode,且老的vnode和新的vnode是同一种vnode的情况下,则会走patchVnode这个需要diff等需要相对复杂计算的过程,而这相信足够开一篇新的文章来阐述,所以这里先不花时间在这上面了。
这里什么才叫同一种vnode呢,给个代码大家自己体会吧
function sameVnode(a, b)
return (
a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error)))
);
仅跟着我们要看的情况就是将新的虚拟节点挂载到真实dom的某个位置的情况。也就是前面说的如下代码这种情况。
<div id="app"></div>
<template id="demo">
<div>
<div >HelloWorld</div>
</div>
</template>
const vm = new Vue(
template: '#demo',
).$mount("#app")
这个模板对应的vnode大概如下:
tag: 'div',
text: undefined,
data: undefined,
children: [
tag: 'div',
text: undefined,
data: undefined,
children: [
text: 'Hello world',
....
]
]
...
这里需要注意的是’Hello world’这个文本是自成一个虚拟节点的,也就是最内层的那个虚拟节点。通常我们叫这种只有文本的节点叫做textNode,即文本节点。
下面我们就看下createElm这个方法如何根据这个虚拟DOM来创建出真实DOM的。
function createElm(
vnode,
insertedVnodeQueue,
parentElm,
refElm,
nested,
ownerArray,
index
)
...
const data = vnode.data;
const children = vnode.children;
const tag = vnode.tag;
if (isDef(tag))
...
vnode.elm = nodeOps.createElement(tag, vnode);
...
createChildren(vnode, children, insertedVnodeQueue);
if (isDef(data))
invokeCreateHooks(vnode, insertedVnodeQueue);
insert(parentElm, vnode.elm, refElm);
else if (isTrue(vnode.isComment))
vnode.elm = nodeOps.createComment(vnode.text);
insert(parentElm, vnode.elm, refElm);
else
vnode.elm = nodeOps.createTextNode(vnode.text);
insert(parentElm, vnode.elm, refElm);
这里我们先说下函数里面判断的后两种情况,如果这个虚拟节点是个注释节点的话,那么直接调用createComment来创建一个真实的注释节点;如果是不是注释节点,也么有提供tag标签的话,那么应该就是如我们例子中的’Hello world’那样的一个文本节点,直接调用createTextNode来创建文本节点。无论是评论节点还是文本节点,都需要通过insert方法来将新创建的真实DOM插入到父节点之下,最终通过往下将要分析到的递归,完成一个完整的虚拟DOM,而不是单个节点。
继续之前,下面我们先瞄两眼操作真实DOM的几个函数的封装
export function createTextNode (text: string): Text
return document.createTextNode(text)
export function createComment (text: string): Comment
return document.createComment(text)
export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node)
parentNode.insertBefore(newNode, referenceNode)
export function appendChild (node: Node, child: Node)
node.appendChild(child)
export function createElement (tagName: string, vnode: VNode): Element
const elm = document.createElement(tagName)
if (tagName !== 'select')
return elm
// false or null will remove the attribute but undefined will not
if (vnode.data && vnode.data.attrs && vnode.data.attrs.multiple !== undefined)
elm.setAttribute('multiple', 'multiple')
return elm
可见这些方法都是通过操作如document和某个节点之类的真实DOM来实打实的对真实DOM进行增删改的。
回到我们前面的示例代码,我们的虚拟DOM的第一层是个div,也就是说是有tag属性的,所以会进入到createElm的第一个条件判断中。
if (isDef(tag))
...
vnode.elm = nodeOps.createElement(tag, vnode);
...
createChildren(vnode, children, insertedVnodeQueue);
if (isDef(data))
invokeCreateHooks(vnode, insertedVnodeQueue);
insert(parentElm, vnode.elm, refElm);
这时会先对这个div执行一次createElement来创建一个真实的DOM节点,然后将其设置成该虚拟DOM对应在真实DOM的挂载点elm。
往下会调用createChildren来继续创建子节点,代码如下
function createChildren(vnode, children, insertedVnodeQueue)
if (Array.isArray(children))
...
for (let i = 0; i < children.length; ++i)
createElm(
children[i],
insertedVnodeQueue,
vnode.elm,
null,
true,
children,
i
);
...
函数的作用就是循环虚拟节点的children数组,然后为每一个子节点又调用父函数createElm来创建真实DOM子节点,期间该子节点可能又有children数组,所以又会调用createChildren的为其创建子节点,如此往复,形成递归循环,直接碰到子节点为文本节点或者注释节点才返回。
递归的写法一向都不是很容易然人理解的。所以这里我们最好是参考我们的’Hello world‘那个虚拟DOM的示例来一层层对应着分析,因为层数不多,所以应该还是有帮助于理解的。
好了,以上就是今天要学习的虚拟DOM是怎么变成真实DOM的相关知识点。
这里稍微总结下,和render方法一样,update/patch方法也是在渲染watcher回调updateComponent中得到调用的。即每次初始化一个组件实例或者组件实例的数据状态发生更新,都会被渲染watcher检测到,并调用updateComponent来调用render来根据最新的数据状态生成新的虚拟DOM,然后拿这个新的虚拟DOM和老的虚拟DOM来做比较,通过patch来更新最新的真实DOM页面。
我是@天地会珠海分舵,「青葱日历」和「三日清单」 作者。能力一般,水平有限,觉得我说的还有那么点道理的不妨点个赞关注下!
以上是关于Vue源码虚拟DOM将去往何处?的主要内容,如果未能解决你的问题,请参考以下文章