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将去往何处?的主要内容,如果未能解决你的问题,请参考以下文章

雨后春笋般涌现的VR外设,未来将去往何处?(上)

Vue 虚拟DOM和Diff算法源码解析

Vuejs571- Vue 虚拟DOM和Diff算法源码解析

Vue源码之虚拟DOM来自何方?

Vue源码之虚拟DOM来自何方?

Vue2总结虚拟DOM