【探究Vue原理】Diff算法

目录

    • 开篇
    • 源码版本
    • 算法概述
      • patch
      • patchVnode 与 updateChildren
    • 算法细节
      • patch
      • patchVnode
        • 节点属性更新
          • updateAttrs(oldVnode, vnode)
        • 节点内容更新
      • updateChildren
    • 阅读源码(含注释)
      • patch
      • patchVnode
      • updateChildren
    • 参考

 
 

开篇

   本文目的是分享我对Vue2.x中的diff算法的一些浅薄理解。如有错误还望指出。
   之前的博文都是清一色的大量源码加注解堆砌的,非常不便于读者阅读和理解,此篇博文我会换一种写法并尽最大努力将我的想法表达清楚。

 
 

源码版本

本文用到Vue源码版本为 v2.6.10,此版本是本文发布日能下载到的最新版。

 
 

算法概述

   Diff算法主要使用了3个函数,分别是patchpatchVnodeupdateChildren

patch

   先说patch (oldVnode, vnode, hydrating, removeOnly),patch的oldVnode与vnode参数指的一定是组件的根节点变化前后的虚拟节点。

   为什么?

   因为,在Vue中每个Dep实例对应一个变量(vm.$options.data.xxx.xxx....),每个Dep实例内保存了与此实例相关的全部watcher实例的引用(或者说是监听了此变量的watcher实例的引用),这些watcher分两类,一类是用户使用$watch或其它方式定义的watcher,另一类则是Vue在创建组件实例的同时,随组件实例一起创建的renderWatcher(它本质上也是个watcher实例,只是被存到了vm._watcher中用做特殊用途),每个renderWatcher与组件是一对一的关系。renderWatcher监听的对象是一个函数,函数内部只调用了一个patch方法,patch方法的参数是_render方法的返回值,而这个返回值就是当前组件的虚拟DOM。也就是说,当某个组件的虚拟DOM被修改的时候,dep会通知其对应的renderWatcher,此时renderWatcher就会被添加到待更新队列中,等待某个时间点会调用patch方法。并将当前组件的根节点的新旧vnode传入patch方法,以便让patch可以计算出真实DOM需要作出的最小变化,最后更新组件。因此我认为patch就是组件更新的入口。

   那这里有个问题,既然每个组件实例都有_vnode属性用来描述页面的真实DOM,为什么Vue不直接根据_vnode生成真实DOM然后替换页面上的已有DOM呢?

   在我的理解中,Vue希望在每次DOM需要被修改的时候(虚拟DOM发生改变之后),可以尽量多的复用之前已经渲染在页面上的DOM节点,而不是直接清空原有DOM节点之后创建新的节点。而对于组件来说,它能掌握的信息只是变化前后的虚拟DOM,它是无法直接得知哪些节点发生了变化的。Diff算法的目的就是通过对比新旧虚拟DOM找出节点的变化,找出哪些节点是可以复用的并复用它们,哪些节点又是需要增删改的并处理它们。patch方法就是Diff算法的入口(我认为组件更新实际就是在执行Diff算法)。

 
 

patchVnode 与 updateChildren

  再说patchVnodeupdateChildren。这两个函数构成了一组递归操作,目的是从组件根节点开始,对新旧虚拟DOM层层递归进行比较(如下图)。拿红色层为例,patchVnode用于比较红色层内的1号节点在新旧VNode中的异同(并更新),而updateChildren用于比较1号节点的子节点是否有增删或移动(并更新),如果像图中这样变化前后的2号3号节点是同一个节点(调用sameVnode方法来判断),则再调用patchVnode去处理2和3。处理2的时候又会updateChildren处理其子节点。
  总结下来就是:
  patchVnode对相同节点进行更新并调用updateChildren处理子节点,updateChildren对子节点进行判断,可能会进行增删和移动,如遇相同节点则再调用patchVnode进行更新。
  只是在这个过程中有一些约定好的规则。首先,Vue不会跨层比较,:
【探究Vue原理】Diff算法_第1张图片
oldVNode的红色层只会和newVNode的红色层做比对,不会和其他层作比对。oldVNode的黑色层和newVNode的绿色层属于同一层级的节点,但它们父节点不同,因此也不会进行比较。
   做这些限制的目的是为了优化算法效率。Diff本就是想用cpu和内存资源换渲染时间,如果Diff效率低,计算过程占用了大量时间的话,也就失去了它的价值了。
 
 

算法细节

   在对比过程中,Vue是以vnode为主,与oldVnode进行比对的。并根据比对结果对vnode.elm进行处理。elm存放的是从oldVnode.elm取得的组件对应的真实DOM节点(大家可以通过vm._vnode.elm或vm.$el拿到它,对它做修改不会触发patch,而是直接改变页面显示)。

   下面我根据自己的理解描述一下整个比较过程。
 
 

patch

   首先patch方法被调用,并接收到两个参数oldVnodevnode,这里的oldVnode与vnode是且只能是需要进行更新的组件根节点变化前后的虚拟DOM。oldVnode为更新前的虚拟DOM,vnode为更新后的虚拟DOM。接下来patch需要通过对比两者来异同来决定组件对应的真实DOM需要做哪些改动。
patch方法规则如下:

  1. 如果 没有 vnode,但 oldVnode。则表示用户将当前组件的根节点删除了,此时执行删除组件的操作。(删除)
  2. 如果 vnode,但 没有 oldVnode。表示当前组件根节点是用户新增的节点。执行新增组件的操作。(新增)
  3. 如果 vnode,也 oldVnode。它们是相同虚拟节点,则表示用户有可能对当前组件进行了修改。此时调用patchVnode更新根节点。(更新)
  4. 如果 vnode,也 oldVnode。它们是不同虚拟节点,则表示用户用一个新组件替换掉了当前页面上的这个组件,此时不管 oldVnode是不是真实DOM(因为它已经没有价值了),就直接以vnode为参照创建一个真实DOM,然后用新真实DOM替换掉之前的老真实DOM。顺便一提,刷新页面的时候走的就是这个条件。(替换)
     
     

patchVnode

   patchVnode大体做了两件事,第一件事是对当前节点的属性进行更新,第二件事是对节点包含的内容进行更新。另外,很重要的一点是,patchVnode的参数oldVnode与vnode一定是同一节点(sameNode)

节点属性更新

   如果当前节点是一个标签,并且它包含属性则对它进行属性更新。
   属性更新用到了7个函数,如下所示:
   1: updateAttrs(oldVnode, vnode) //更新用户在标签上自定义的属性,如

中的a和b
   2: updateClass(oldVnode, vnode)
   3: updateDOMListeners(oldVnode, vnode)
   4: updateDOMProps(oldVnode, vnode)
   5: updateStyle(oldVnode, vnode)
   6: update(oldVnode, vnode)
   7: updateDirectives(oldVnode, vnode)

   我没有对它们一一做研究,只是看了下updateAttrs(oldVnode, vnode) 的源码,方法内部逻辑如下:

updateAttrs(oldVnode, vnode)

   方法首先遍历vnode的用户自定义属性vnode.data.attrs),用每个属性去和老节点中的中同名自定义属性作对比。如果前后两个属性的值不相同,则代表用户新增或者修改或删除了此属性。
   例如节点修改前是这样:


   修改后是这样:

那么vnode.data.attrs.aoldVnode.data.attrs.a不相等。此时会使用 setAttr(elm, key, cur) 更新真实DOM(vnode.elm)中相应的值。至于具体到底是新增、修改还是删除,是交给baseSetAttr (el, key, value)方法来分辨的。el表示真实DOM节点(也就是vnode.elm),key表示属性名,value表示属性值。如果value为 null 或者 false,则表示删除el中名称为key的属性,否则表示在el中添加或修改名为key的属性且值设置为value。
   之后遍历oldVnode.data.attrs,如果发现oldVnode.data.attrs里面有某些属性在vnode.data.attrs里面不存在(undefined或null),则表示节点变化后这些属性被删除了。此时只需要删除真实DOM中的对应属性即可。

节点内容更新

规则如下:

  1. 如果vnode与oldVnode均有子节点,则调用updateChildren对子节点数组进行下一步操作。
  2. 如果vnode有子节点,而oldVnode无子节点,则说明用户在当前节点下添加了子节点。此时对应的是这种情况:
    改为

    孩子1

    。不过如果oldVnode包含文本节点则应先删除文本,再添加子节点,此处对应的是这样情况:
    abc
    改为

    孩子1

  3. 如果vnode是空节点(无子节点无文本),而oldVnode有子节点,说明用户删除了当前节点下的子节点。对应的情况是这样:

    孩子1

    改为
  4. 如果vnode是空节点(无子节点无文本),oldVnode包含文本节点,则将当前节点的文本设为空字符串。对应情况为:
    abc
    改为
  5. 如果vnode包含文本且与oldVnode的文本不一致则更新DOM节点的文本,对应情况:
    abc
    改为
    123

    改为
    123

    孩子1

    改为
    123

 
 

updateChildren

  之前提到过,Vue在比对子节点数组的时候只对同级节点作比较,所以问题就可以简化为如何对“两个数组进行比较”,只不过数组中存放的都是虚拟节点而已。另外,此函数接收的参数oldCh指的是oldVnode的子节点构成的数组,newCh指的是vnode的子节点构成的数组。
  两个数组作比较只需要一个双层循环就搞定了。举个例子,如果内层循环为newCh而外层循环为oldCh。我现在对oldCh数组的第一个元素做判断,我要拿着这个元素去和newCh里面的元素一个个比过去,假设在对比到newCh中第三个元素的时候发现它和自己一模一样,则表示oldVNode数组的第一个元素的位置发生了变化,在新数组中它变到了第三的位置。此时对oldCh数组的第一个元素的判断完毕,判断结果为:它的位置发生了变化。至此第一次外层循环结束。(改)这对应的情况可能是

<div>
	<p>1p>
	<p>2p>
	<p>3p>
div>

改为

<div>
	<p>2p>
	<p>3p>
	<p>1p>
div>

  下面进行第二次外循环,现在开始对oldCh中的第二个元素做判断,第二个元素直到对比完newCh数组的最后一个元素也没找到和和自己一样的元素。此时它恍然大悟,哦 原来自己被删掉了。(因为自己在新数组中不存在了嘛,必然是被删掉了)。(删)对应的情况可能是下面这样;

<div>
	<p>1p>
	<p>2p>
	<p>3p>
div>

改为

<div>
	<p>1p>
	<p>3p>
div>

  就这么比啊比,直到oldCh数组的元素已经都做过对比操作了,但是newCh里面还有没与oldCh数组元素配对成功的元素。结果很明显,这些newCh数组中剩余的元素是新添加的元素。(增)

  上面这种方式确实可以确定子节点数组需要做的操作,但效率太低。请想象一些极端情况。比如当我们在比较oldCh的第三个元素的时候,发现它和newCh中的最后一个元素相同,这其实浪费了很多的cpu资源(假设oldCh与newCh均有上百个元素)。
  因此我们可以做个优化,在每次循环开始之前,先拿当前元素和newCh中的最后一个元素作比较,如果不同无所谓,我们只浪费了微乎其微的cpu资源做了次判断而已,但很幸运,我们通过这次对比发现oldCh的第三个元素和newCh的最后一个元素是相同节点,因此之后本次内循环可以都不用做了,直接做后续处理即可,这样一来节省了非常多的cpu资源。
  Vue的Diff算法其实就是做了很多这样的优化。

优化1:
【探究Vue原理】Diff算法_第2张图片
Vue为oldCh和newCh分别添加了一对游标,默认指向数组的第一个和最后一个元素。

Vue列出了几种特殊情况,在双循环开始之前先判断是否属于这几种特殊情况之一,如果是,则直接进行处理而不需要再进行双层循环。如果不是,再使用双层循环来判断。

具体规则如下:

  1. 如果oldStartIdx指向的元素为undefined则oldStartIdx右移,同样的如果oldEndIdx指向的元素不存在则oldEndIdx左移。
    这个操作的目的是快速去掉oldCh左右两端的无效数据。为什么会出现元素值为undefined呢?往下看就知道了。

  2. 如果oldStartIdx和newStartIdx是相同元素则对其调用patchVNode。oldStartIdx和newStartIdx都向右移动。 同样的,如果newEndIdx和oldEndIdx是相同元素对其调用patchVNode。newEndIdx和oldEndIdx都向左移动。我对这个优化操作的理解是:可能Vue认为很多时候节点变化前后它的子节点数组的首尾元素仍是相同元素。

  3. 如果oldStartIdx和newEndIdx是相同元素则对其调用patchVNode,oldStartIdx右移,newEndIdx左移。如果oldEndIdx和newStartIdx是相同元素则对其调用patchVNode,oldEndIdx左移,newStartIdx右移。我对这个优化操作的理解是:可能Vue认为 用户对子元素做的操作中有很多都是把第一个子元素移动到最后一个的位置。或者是把最后一个子元素移动到第一个的位置。想象我们在做待办事件列表的时候,有可能允许用户对待办事项打勾然后待办事项变为已完成并加到列表末尾?又或者用户将已完成的事项再提到列表开头作为待办事项?为了证实猜想我写了个小Demo测试了一下,发现确实是这种情况,Demo很简单就不贴了。

其余情况只能通过双层循环来比对出来了。双层循环内的规则如下:

  1. 如果newStartIdx指向的元素在oldCh里面不存在,则创建新节点。
  2. 如果在oldCh里面找到某个元素和newStartIdx指向的元素相同了,则:
    • 先对这个元素做应用patchVnode方法来更新属性。
    • 而后将老数组中的vnode设置为undefind。(读到这里就知道为什么while循环一开始会跳过老数组的首尾的undefind元素的了吧!)
    • 最后将老数组中的vode对应的真实DOM节点移动到上一个已经排好的DOM节点的下一个兄弟节点之前的位置。 (其实就是移动到上一个已经拍好的DOM节点之后的位置。只不过因为源码中用的是insertBefore,所以必须插入到某个节点之前。)

最后,如果其中一个数组遍历完毕(头部游标跑到了尾部游标之后)之后发现,另一个数组还有未匹配到的元素,此时还需要做判断。规则如下:
7. 如果oldStartIdx跑到oldEndIdx之后了,说明oldCh先于newCh遍历完毕了,此时newCh还有没遍历到的元素, 那么,这些元素必然是新增元素,此时执行新增操作。
8. 如果newStartIdx跑到newEndIdx之后了,说明newCh先于oldCh遍历完毕,此时oldCh还有剩余的没遍历到的元素,那么,这些元素必然是已经被删除的元素,此时执行删除操作。

阅读源码(含注释)

这部分只是简单的将我做过注释的源码贴出来,比较乱,而且大部分注释都在正文中出现过了,大家请酌情阅读。

patch

/* 对某个组件的根节点执行patch操作 */
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
     
    /* 参数说明
      oldVnode指的是组件的旧的虚拟根节点,
      vnode指的是组件的新的虚拟根节点,
    */

    /* 
      如果没有新节点,但有老节点。则表示用户将当前组件的根节点删除了
      执行删除组件的操作。
    */
    if (isUndef(vnode)) {
     
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    
    const insertedVnodeQueue = []

    /* 
      如果有新节点,但是没有老节点。表示当前根节点是用户新增的节点。
      执行新增组件的操作。
    */
    if (isUndef(oldVnode)) {
     
      // empty mount (likely as component), create new root element
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
    } 
    /* 
      如果有新节点,也有老节点。表示用户有可能对当前节点进行了修改
      这里的修改一般是指用户修改了组件的模板中引用的变量,导致模板需要重新渲染
    */
    else {
     
      /* 
        如果老节点有nodeType则表示它是一个真实DOM节点
      */
      const isRealElement = isDef(oldVnode.nodeType)
      /* 
        如果老节点也是虚拟节点,且新老节点是同一个节点, 
        则对节点调用patchVnode进一步做对比。
      */
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
     
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } 
      else {
     
        /* 如果是真实DOM节点 */
        if (isRealElement) {
     
          // mounting to a real element
          // check if this is server-rendered content and if we can perform
          // a successful hydration.
          /* 如果此DOM是个元素节点 */
          if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
     
            oldVnode.removeAttribute(SSR_ATTR)
            hydrating = true
          }
          if (isTrue(hydrating)) {
     
            if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
     
              invokeInsertHook(vnode, insertedVnodeQueue, true)
              return oldVnode
            } else if (process.env.NODE_ENV !== 'production') {
     
              warn(
                'The client-side rendered virtual DOM tree is not matching ' +
                'server-rendered content. This is likely caused by incorrect ' +
                'HTML markup, for example nesting block-level elements inside ' +
                '

, or missing . Bailing hydration and performing ' + 'full client-side render.' ) } } // either not server-rendered, or hydration failed. // create an empty node and replace it /* 页面第一次刷新,没有老虚拟节点,只有真实DOM,此时创建一个空虚拟节点作为老虚拟节点. */ oldVnode = emptyNodeAt(oldVnode) } /* 如果组件的新旧根节点不是相同虚拟节点, 说明用户创建了一个新的根节点并覆盖了之前的根节点。 在我自己的测试中,只能在重新刷新页面的时候才走进这个条件。 我认为是Vue在组件的根组件不同的情况下,选择根据新虚拟节点创建了真实DOM并用其替代了旧的组件根节点 */ // 替换已存在元素 //取出旧节点对应的真实DOM节点(elm),注意此时vnode.elm还是undefined const oldElm = oldVnode.elm //找出此真实节点的父真实节点 const parentElm = nodeOps.parentNode(oldElm) /* 根据vnode的数据在parentElm下创建一个新节点,替代之前的真实DOM */ 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) ) // update parent placeholder node element, recursively //如果新虚拟节点存在父节点 if (isDef(vnode.parent)) { let ancestor = vnode.parent const patchable = isPatchable(vnode) while (ancestor) { for (let i = 0; i < cbs.destroy.length; ++i) { cbs.destroy[i](ancestor) } ancestor.elm = vnode.elm if (patchable) { for (let i = 0; i < cbs.create.length; ++i) { cbs.create[i](emptyNode, ancestor) } // #6513 // invoke insert hooks that may have been merged by create hooks. // e.g. for directives that uses the "inserted" hook. const insert = ancestor.data.hook.insert if (insert.merged) { // start at index 1 to avoid re-invoking component mounted hook for (let i = 1; i < insert.fns.length; i++) { insert.fns[i]() } } } else { registerRef(ancestor) } ancestor = ancestor.parent } } // 销毁旧虚拟节点对应的真实DOM if (isDef(parentElm)) { removeVnodes([oldVnode], 0, 0) } else if (isDef(oldVnode.tag)) { invokeDestroyHook(oldVnode) } } } invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch) return vnode.elm }

patchVnode

function patchVnode (
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
  ) {
     
    /* 如果新旧虚拟node节点引用的是内存中同一段数据,则不做任何操作 */
    if (oldVnode === vnode) {
     
      return
    }

    if (isDef(vnode.elm) && isDef(ownerArray)) {
     
      // clone reused vnode
      vnode = ownerArray[index] = cloneVNode(vnode)
    }

    /* 注意!
    这里将老节点的真实DOM赋值给了新节点 
    其实对oldVnode.elm和对vnode.elm操作是一样的,
    都是对当前组件对应的真实DOM进行操作。
    */
    const elm = vnode.elm = oldVnode.elm

    /* 异步组件的特殊处理 */
    if (isTrue(oldVnode.isAsyncPlaceholder)) {
     
      if (isDef(vnode.asyncFactory.resolved)) {
     
        hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
      } else {
     
        vnode.isAsyncPlaceholder = true
      }
      return
    }

    // reuse element for static trees.
    // note we only do this if the vnode is cloned -
    // if the new node is not cloned it means the render functions have been
    // reset by the hot-reload-api and we need to do a proper re-render.
    /* 如果新旧节点都是静态节点并且新旧节点的key一致 */
    if (isTrue(vnode.isStatic) &&
      isTrue(oldVnode.isStatic) &&
      vnode.key === oldVnode.key &&
      /* 
      通过src\core\vdom\vnode.js下的cloneVNode方法产生的虚拟节点的isCloned会被标记为true
      cloneVNode方法使用的是浅复制
      */
      /* 如果节点被用户添加了v-onde属性则isOnce会被标记为true */
      (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
    ) {
     
      /* 
      componentInstance表示节点所在的组件实例的引用 
      这步操作没有看懂,感觉和slot有关,以后再研究。
      */
      vnode.componentInstance = oldVnode.componentInstance
      return
    }

    let i
    const data = vnode.data
    if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
     
      i(oldVnode, vnode)
    }

    
    const oldCh = oldVnode.children
    const ch = vnode.children
    /* 属性更新 */
    if (isDef(data) && isPatchable(vnode)) {
     
      /* 
        cbs.update是一个数组里面包含7个方法,分别是
        1: updateAttrs(oldVnode, vnode) 
        2: updateClass(oldVnode, vnode) 
        3: updateDOMListeners(oldVnode, vnode) 
        4: updateDOMProps(oldVnode, vnode) 
        5: updateStyle(oldVnode, vnode) 
        6: update(oldVnode, vnode)
        7: updateDirectives(oldVnode, vnode) 
        7个方法对应了DOM元素的全部属性更新操作,此处利用for循环将节点过一遍7个函数做一次彻底更新

        7个方法内部的逻辑不是研究重点,但是我想以updateAttrs作为例子研究下函数大概做了啥
        updateAttrs函数会取出oldVnode.data.attrs(老虚拟节点的属性)和vnode.data.attrs(新虚拟节点的属性)
        首先遍历vnode.data.attrs,
          拿vnode.data.attrs中的每个属性去和oldVnode.data.attrs中同名属性作比较
          如果发现两个属性的值不相同,则代表新的虚拟节点新增或者修改或删除了此属性,
          此时会使用 setAttr(elm, key, cur) 重新设置vnode.elm中的这个属性,

          而setAttr中还使用了另一个名为baseSetAttr (el, key, value)方法来设置属性
          el表示真实DOM节点也就是vnode.elm,key表示属性名,value表示属性值。
          如果value为 null 或者  false,则表示删除el中名称为key的属性,
          否则表示在el中添加名为key的属性并且值为value。

          因此本次遍历总结下来就是
            对新节点存在而旧节点不存在或值不同的属性以新节点的属性值为准进行增删改。
            这里增删改的对象是新节点的elm

        之后遍历oldVnode.data.attrs
          如果发现oldVnode.data.attrs里面有某些属性在vnode.data.attrs里面不存在(undefined或null)
          表示节点变化后这些属性被删除了。此时只需要删除新节点的elm中的对应属性即可
          这里删除的时候还根据是不是xlink属性做了两种删除操作,感兴趣的可以看看。
      */
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
    }
    /* 节点更新 */
    /* 如果新节点不包含文本节点,说明它可能有子节点(当然也有可能只是个空节点) */
    if (isUndef(vnode.text))
     {
     
       /* 如果新节点有子节点,并且旧节点也有子节点 */
      if (isDef(oldCh) && isDef(ch))
       {
     
         /* 
         如果新旧子节点的引用不同,
         */
        if (oldCh !== ch) {
     
          /* 就执行子节点的更新操作 */
          updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
        }
      } 
      /* 
      如果新节点有子节点,而旧节点无子节点
      此时应该添加新节点
      */
      else if (isDef(ch)) {
     
        /* 这里跳过 */
        if (process.env.NODE_ENV !== 'production') {
     
          checkDuplicateKeys(ch)
        }
        /* 
          老节点无子节点的情况下,老节点有可能包含文本,也有可能只是空节点
          如果是空节点的话,就可以在它里面直接加子节点。
          否则如果包含文本则应该先清空全部文本,再添加子节点。 
        */
        if (isDef(oldVnode.text)){
     
          nodeOps.setTextContent(elm, '')
        }
        /* 然后再添加子节点 */
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } 
      /* 
      如果老节点有子节点,而新节点是空节点(无子无text)
      则应该删除子节点
      */
      else if (isDef(oldCh)) {
     
        removeVnodes(oldCh, 0, oldCh.length - 1)
      } 
      /* 
      如果新老节点均无子节点,则再判断老节点有无文本,
      如果老节点是包含文本的,但此时新节点是无文本无子节点的,
      说明新节点就是个空节点而已,类似这样
那么直接将老节点的文本置为空即可。 */
else if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, '') } } /* 否则新节点是个包含文本的节点,如果新旧文本不一致 将用新文本替代老文本. 注意这里所谓的新旧文本不一致是包含新节点是文本而老节点有子节点这种情况的。 也就是说这里的比较都是以新节点为准的, 那为啥还要比来比去 直接用新节点替换老节点不得了? 这里比来比去是为了让新节点最大程度的利用老节点已有资源, 比如老节点有一些子节点和新节点的子节点相同,那就完全没必要用新节点直接替换老节点 只需要对比新旧节点的子节点有哪些是不同的,然后对不同的部分进行增删改即可。 */ else if (oldVnode.text !== vnode.text) { nodeOps.setTextContent(elm, vnode.text) } /* 调用钩子 */ if (isDef(data)) { if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode) } }

updateChildren

/* 子节点的更新(重要) */
  function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
     
    /* 参数解释
    oldCh为旧虚拟节点的子节点构成的数组 
    newCh为新虚拟节点的子节点构成的数组 
    */

    //游标指向oldCh的第一个元素(子节点)
    let oldStartIdx = 0
    
    //游标指向newCh的第一个元素(子节点)
    let newStartIdx = 0
    //游标指向oldCh的最后一个元素(子节点)
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    //游标指向newCh的最后一个元素(子节点)
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    // removeOnly is a special flag used only by 
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    const canMove = !removeOnly

    //暂时跳过
    if (process.env.NODE_ENV !== 'production') {
     
      checkDuplicateKeys(newCh)
    }

    /* 
    以下称老虚拟节点的子节点构成数组为老数组,
    新虚拟节点的子节点构成数组为新数组,
    oldStartVnode为老头,
    oldEndVnode为老尾
    newStartVnode为新头
    newEndVnode为新尾
    注意上述四个xxxVnode格式命名的变量本身存放的是当前指向节点的引用,称作游标
    可以将它们想象成"指针",和指针的区别不赘述。
    */
    /* 
    循环终止条件为 老头跑到老尾之后 或者 新头跑到新尾之后 
    说白了就是 老数组或者新数组任意一个遍历完毕之后循环结束,
    一般我们做双层循环操作数组时候也都是这个条件。
    */
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
     
      /* 
      循环开始之后四个游标是会向中间移动的,
      老头不一定是老虚拟节点的子节点构成的数组的第一个元素,
      它应该是老虚拟节点的子节点构成的数组中的第一个未被处理的元素。 
      不用太在意这句话的意思,读源码过程中自然会明白的。
      下面的注释只是针对第一次while循环做出的注释
      */
      /* 如果老头是undefined或null,则老头右移 指向下一个元素 */
      if (isUndef(oldStartVnode)) 
      {
     
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } 
      /* 如果老尾是undefined或null,则老尾左移 指向上一个元素(向中间靠拢) */
      else if (isUndef(oldEndVnode)) {
     
        oldEndVnode = oldCh[--oldEndIdx]
      } 
      /* 
      如果老头和新头是相同的节点,就表示节点变化前后它的第一个子节点自身无变化 
      因此直接对老头和新头做diff即可。
      此时老头和新头都要右移,指向下一个元素。
      */
      else if (sameVnode(oldStartVnode, newStartVnode)) {
     
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      }
      /* 
      如果老尾和新尾是相同的节点,就表示节点变化前后它的最后一个子节点自身无变化 
      因此直接对老尾和新尾做diff即可。
      此时老尾和新尾都要左移,指向上一个元素。
      */
       else if (sameVnode(oldEndVnode, newEndVnode)) {
     
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      }
      /* 
      如果老头和新尾是相同节点,就表示节点变化后它的第一个子节点被移动到了数组末尾
      因此直接对老头和新尾做diff算法即可
      此时老头右移,新尾左移 
      */
       else if (sameVnode(oldStartVnode, newEndVnode)) {
      // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      }
      /* 
      如果老尾和新头是相同节点,就表示节点变化后它的最后一个子节点被移动到了数组开头
      因此直接对老尾和新头做diff算法即可
      此时老尾左移,新头右移 
      */
       else if (sameVnode(oldEndVnode, newStartVnode)) {
      // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } 
      /* 
      如果不是上面那4种情况则只能按常规的双循环来判断了
      拿新开去老数组的老开与老尾之间(包括老开老尾)一个个对照看看相不相等 
      */
      else {
     
        if (isUndef(oldKeyToIdx)) 
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

         /*  如果新元素在老数组里面不存在,则创建新节点 */
        if (isUndef(idxInOld))
         {
      // New element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
     
          vnodeToMove = oldCh[idxInOld]
          /* 如果在老数组里面找到某个元素和新开相同了,则进行diff */
          if (sameVnode(vnodeToMove, newStartVnode))
           {
     
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
     
            // same key but different element. treat as new element
            /* 如果key相同但是节点类型不同,则当做新节点来处理 */
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    /* 
    等任意数组遍历完成之后发现,其中一个数组还有没遍历到的元素,此时还需要做判断。
    */
    /* 
    如果老头跑到老尾之后了,说明老数组先于新数组遍历完毕了,此时新数组还有没遍历到的元素
    那新数组里面没遍历到的元素必然是新增元素,此时执行新增操作。 
    */
    if (oldStartIdx > oldEndIdx) {
     
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } 
    /* 
    如果新头跑到新尾之后了,说明新数组先于老数组遍历完毕,此时老数组还有剩余的没遍历到的元素
    这些元素必然是已经被删除的元素,此时执行删除操作。 
    */
    else if (newStartIdx > newEndIdx) {
     
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }

全文完,感谢您的阅读!

参考

Vue如何界定静态节点
VNode类型

你可能感兴趣的:(vue探究,Vue,Diff)