patch过程

本次内容的三个目标:

1、从逻辑上理解patch过程

2、手动做一次复杂案例的diff过程

3、调试vue源码

一、虚拟DOM
虚拟DOM就是把真实DOM树的结构和信息抽象出来,以对象的形式模拟树形结构,如下:

真实DOM:

        <div>
            <p>Hello World</p>
        </div>
复制代码

对应的虚拟DOM就是:

    let vnode = {
        tag: 'div',
        children:[ {tag:'p', text:'Hello World'}]
    }
复制代码

渲染真实DOM会有一定的开销,如果每次修改数据都进行真实DOM渲染,都会引起DOM树的重绘和重排,性能开销很大。那么有没有可能只修改一小部分数据而不渲染整个DOM呢?虚拟DOM和Diff算法可以实现。

如何实现?
先根据真实DOM生成一颗虚拟DOM树
当某个DOM节点数据发生改变时,生成一个新的Vnode
新的Vnode和旧的oldVnode进行对比
通过patch函数一边比对一边给真实DOM打补丁或者创建Vnode、移除oldVnode等

二、 Diff算法

传统Diff算法
遍历两棵树中的每一个节点,每两个节点之间都要做一次比较。

image (1).png

Vue优化的Diff算法
Vue的diff算法只会比较同层级的元素,不进行跨层级比较

diff 即对比,这是一个广泛的概念,例如 linux diff、git diff 等,vdom 中的 diff 就是对两颗树做 diff。树 diff 的时间复杂度为 O(n^3),时间复杂度太高,算法不可用。但是后来创建框架的大佬们将 diff 算法的时间复杂度优化到 O(n),具体改变如下:
只比较同一层级,不跨级比较
tag 不相同,则直接删掉重建,不再深度比较
tag 和 key,两者都相同,则认为是相同节点,不再深度比较

三、 Vue中的Diff算法实现

Vnode分类
EmptyVNode: 没有内容的注释节点
TextVNode: 文本节点
ElementVNode: 普通元素节点
ComponentVNode: 组件节点
CloneVNode: 克隆节点,可以是以上任意类型的节点,唯一的区别在于isCloned属性为true

patch函数接收以下参数:
oldVnode:旧的虚拟节点
Vnode:新的虚拟节点
hydrating:是否要和真实DOM混合
removeOnly:特殊的flag,用于 transition-group

处理流程大致分为以下步骤:

vnode不存在,oldVnode存在时,移除oldVnode
vnode存在,oldVnode不存在时,创建vnode
vnode和oldVnode都存在时
如果vnode和oldVnode是同一个节点(通过sameVnode函数对比 后续详解),通过patchVnode进行后续比对工作
如果vnode和oldVnode不是同一个节点,那么根据vnode创建新的元素并挂载至oldVnode父元素下。如果组件根节点被替换,遍历更新父节点element。然后移除旧节点。如果oldVnode是服务端渲染元素节点,需要用hydrate函数将虚拟dom和真是dom进行映射

    return function patch(oldVnode, vnode, hydrating, removeOnly) {
        // 如果vnode不存在,但是oldVnode存在,移除oldVnode
        if (isUndef(vnode)) {
          if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
          return
     }
复制代码
        let isInitialPatch = false
    const insertedVnodeQueue = []

    // 如果oldVnode不存在,但是vnode存在时,创建vnode
    if (isUndef(oldVnode)) {
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
    } else {
      // 剩余情况为vnode和oldVnode都存在

      // 判断是否为真实DOM元素
      const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // 如果vnode和oldVnode是同一个(通过sameVnode函数进行比对  后续详解)
        // 受用patchVnode函数进行后续比对工作 (函数后续详解)
        patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
      } else {
        // vnode和oldVnode不是同一个的情况
        if (isRealElement) {
          // 如果存在真实的节点,存在data-server-render属性
          if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
            // 当旧的Vnode是服务端渲染元素,hydrating记为true
            oldVnode.removeAttribute(SSR_ATTR)
            hydrating = true
          }
          // 需要用hydrate函数将虚拟DOM和真实DOM进行映射
          if (isTrue(hydrating)) {
            // 需要合并到真实DOM上
            if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
              // 调用insert钩子
              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 ' +
                '<p>, or missing <tbody>. Bailing hydration and performing ' +
                'full client-side render.'
              )
            }
          }
          // 如果不是服务端渲染元素或者合并到真实DOM失败,则创建一个空的Vnode节点去替换它
          oldVnode = emptyNodeAt(oldVnode)
        }

        // 获取oldVnode父节点
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)

        // 根据vnode创建一个真实DOM节点并挂载至oldVnode的父节点下
        createElm(
          vnode,
          insertedVnodeQueue,
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )

        // 如果组件根节点被替换,遍历更新父节点Element
        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
          }
        }

        // 销毁旧节点
        if (isDef(parentElm)) {
          // 移除老节点
          removeVnodes(parentElm, [oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          // 调用destroy钩子
          invokeDestroyHook(oldVnode)
        }
      }
    }
    // 调用insert钩子并返回节点
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }
复制代码

VNode最大的用途就是在数据变化前后生成真实DOM对应的虚拟DOM节点,然后就可以对比新旧两份VNode,找出差异所在,然后更新有差异的DOM节点,最终达到以最少操作真实DOM更新视图的目的。

在Vue中,把 DOM-Diff过程叫做patch过程。patch,意为“补丁”,即指对旧的VNode修补,打补丁从而得到新的VNode,非常形象哈。

diff流程图
当数据发生改变时,set方法会让调用Dep.notify通知所有订阅者Watcher,订阅者就会调用patch给真实的DOM打补丁,更新相应的视图。
dsq7rmc5zp.png
以新的VNode为基准,改造旧的oldVNode使之成为跟新的VNode一样,这就是patch过程要干的事。

创建节点:新的VNode中有而旧的oldVNode中没有,就在旧的oldVNode中创建。
删除节点:新的VNode中没有而旧的oldVNode中有,就从旧的oldVNode中删除。
更新节点:新的VNode和旧的oldVNode中都有,就以新的VNode为准,更新旧的oldVNode。

    function patch (oldVnode, vnode) {
    // some code
    if (sameVnode(oldVnode, vnode)) {
    	patchVnode(oldVnode, vnode)
    } else {
    	const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
    	let parentEle = api.parentNode(oEl)  // 父元素
    	createEle(vnode)  // 根据Vnode生成新元素
    	if (parentEle !== null) {
            api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
            api.removeChild(parentEle, oldVnode.el)  // 移除以前的旧元素节点
            oldVnode = null
    	}
    }
    // some code 
    return vnode
}
复制代码

判断两节点是否值得比较,值得比较则执行patchVnode

    function sameVnode(a, b) {
      return (
        a.key === b.key && (
          (
            a.tag === b.tag &&
            a.isComment === b.isComment &&
            isDef(a.data) === isDef(b.data) &&
            sameInputType(a, b)
          ) || (
            isTrue(a.isAsyncPlaceholder) &&
            a.asyncFactory === b.asyncFactory &&
            isUndef(b.asyncFactory.error)
          )
        )
      )
    }
复制代码

Vue怎么判断是不是同一个节点?流程如下:
判断Key值是否一样
tag的值是否一样
isComment,这个不用太关注。
数据一样
sameInputType(),专门对表单输入项进行判断的:input一样但是里面的type不一样算不同的inputType
从这里可以看出key对diff算法的辅助作用,可以快速定位是否为同一个元素,必须保证唯一性。

如果你用的是index作为key,每次打乱顺序key都会改变,导致这种判断失效,降低了Diff的效率。

patchVnode函数
前置条件vnode和oldVnode是同一个节点

如果oldVnode和vnode引用一致,可以认为没有变化,return
如果oldVnode的isAsyncPlaceholder属性为true,跳过检查异步组件,return
如果oldVnode跟vnode都是静态节点,且具有相同的key,同时vnode是克隆节点或者v-once指令控制的节点时,只需要把oldVnode.elm和oldVnode.child都复制到vnode上,也不用再有其他操作,return
如果vnode不是文本节或注释节点
如果vnode和oldVnode都有子节点并且两者子节点不一致时,就调用updateChildren更新子节点
如果只有vnode有自子节点,则调用addVnodes创建子节点
如果只有oldVnode有子节点,则调用removeVnodes把这些子节点都删除
如果vnode文本为undefined,则清空vnode.elm文本
如果vnode是文本节点但是和oldVnode文本内容不同,只需更新文本。
复制代码

不值得比较则用Vnode替换oldVnode

当我们确定两个节点值得比较之后我们会对两个节点指定patchVnode方法。那么这个方法做了什么呢?

    patchVnode (oldVnode, vnode) {
        const el = vnode.el = oldVnode.el
        let i, oldCh = oldVnode.children, ch = vnode.children
        if (oldVnode === vnode) return
        if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
            api.setTextContent(el, vnode.text)
        }else {
            updateEle(el, vnode, oldVnode)
            if (oldCh && ch && oldCh !== ch) {
                updateChildren(el, oldCh, ch)
            }else if (ch){
                createEle(vnode) //create el's children dom
            }else if (oldCh){
                api.removeChildren(el)
            }
        }
    }
复制代码
function patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) {

    // 如果新老节点引用一致,直接返回。
    if (oldVnode === vnode) {
      return
    }

    const elm = vnode.elm = oldVnode.elm

    // 如果oldVnode的isAsyncPlaceholder属性为true,跳过检查异步组件
    if (isTrue(oldVnode.isAsyncPlaceholder)) {
      if (isDef(vnode.asyncFactory.resolved)) {
        hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
      } else {
        vnode.isAsyncPlaceholder = true
      }
      return
    }

    // 如果新旧都是静态节点,vnode的key也相同
    // 新vnode是克隆所得或新vnode有 v-once属性
    // 则进行赋值,然后返回。vnode的componentInstance 保持不变
    if (isTrue(vnode.isStatic) &&
      isTrue(oldVnode.isStatic) &&
      vnode.key === oldVnode.key &&
      (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
    ) {
      vnode.componentInstance = oldVnode.componentInstance
      return
    }

    let i
    const data = vnode.data
    // 执行data.hook.prepatch 钩子
    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 钩子函数,更新oldVnode所有属性
      // 包括attrs、class、domProps、events、style、ref、directives
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      // 执行data.hook.update 钩子
      if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
    }
    // Vnode 的 text选项为undefined
    if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {
        //新老节点的children不同,执行updateChildren方法
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        // oldVnode children不存在 执行 addVnodes方法
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        // vnode不存在执行removeVnodes方法
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        // 新旧节点都是undefined,且老节点存在text,清空文本。
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      // 新老节点文本内容不同,更新文本
      nodeOps.setTextContent(elm, vnode.text)
    }
    if (isDef(data)) {
      // 执行data.hook.postpatch钩子,至此 patch完成
      if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
    }
  }
复制代码

前置条件:vnode和oldVnode的children不相等

这个函数做了以下事情:
找到对应的真实dom,称为el
判断Vnode和oldVnode是否指向同一个对象,如果是,那么直接return
如果他们都有文本节点并且不相等,那么将el的文本节点设置为Vnode的文本节点。
如果oldVnode有子节点而Vnode没有,则删除el的子节点
如果oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el
如果两者都有子节点,则执行updateChildren函数比较子节点,这一步很重要

image (2).png

其他几个点都很好理解,我们详细来讲一下updateChildren
vnode头对比oldVnode头

vnode尾对比oldVnode尾

vnode头对比oldVnode尾

vnode尾对比oldVnode头

只要符合一种情况就进行patch,移动节点,移动下标等操作都不对再在oldChild中找一个key和newStart相同的节点找不到,新建一个。

找到,获取这个节点,判断它和newStartVnode是不是同一个节点

如果是相同节点,进行patch 然后将这个节点插入到oldStart之前,newStart下标继续移动
如果不是相同节点,需要执行createElm创建新元素

将Vnode的子节点Vch和oldVnode的子节点oldCh提取出来
oldCh和vCh各有两个头尾的变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。
如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,
一旦StartIdx>EndIdx表明oldCh和vCh至少有一个已经遍历完了,就会结束比较。

image (3).png

function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {

   // 定义变量
   let oldStartIdx = 0  // 老节点Child头下标
   let newStartIdx = 0  // 新节点Child头下标
   let oldEndIdx = oldCh.length - 1  // 老节点Child尾下标
   let oldStartVnode = oldCh[0]      // 老节点Child头结点
   let oldEndVnode = oldCh[oldEndIdx] // 老节点Child尾结点
   let newEndIdx = newCh.length - 1   // 新节点Child尾下标
   let newStartVnode = newCh[0]       // 新节点Child头结点
   let newEndVnode = newCh[newEndIdx]  // 新节点Child尾结点
   let oldKeyToIdx, idxInOld, vnodeToMove, refElm  

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

   if (process.env.NODE_ENV !== 'production') {
     checkDuplicateKeys(newCh)
   }

   // 定义循环
   while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
     // 存在检测
     if (isUndef(oldStartVnode)) {
       oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
     } else if (isUndef(oldEndVnode)) {
       oldEndVnode = oldCh[--oldEndIdx]

     // 如果老结点Child头和新节点Child头是同一个节点
     } else if (sameVnode(oldStartVnode, newStartVnode)) {
       // patch差异
       patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
       // patch完成  移动节点位置  继续比对下一个节点
       oldStartVnode = oldCh[++oldStartIdx]
       newStartVnode = newCh[++newStartIdx]

     // 如果老结点Child尾和新节点Child尾是同一个节点
     } else if (sameVnode(oldEndVnode, newEndVnode)) {
       // patch差异
       patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
       // patch完成  移动节点位置 继续比对下一个节点
       oldEndVnode = oldCh[--oldEndIdx]
       newEndVnode = newCh[--newEndIdx]

     // 如果老结点Child头和新节点Child尾是同一个节点
     } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        // patch差异
       patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
       // 把oldStart节点放到oldEnd节点后面
       canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
       // patch完成  移动节点位置 继续比对下一个节点
       oldStartVnode = oldCh[++oldStartIdx]
       newEndVnode = newCh[--newEndIdx]
     // 如果老结点Child尾和新节点Child头是同一个节点
     } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        // patch差异
       patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
       // 把oldEnd节点放到oldStart节点前面
       canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
       // patch完成  移动节点位置 继续比对下一个节点
       oldEndVnode = oldCh[--oldEndIdx]
       newStartVnode = newCh[++newStartIdx]
     } else {
       // 如果没有相同的Key,执行createElm方法创建元素
       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 {
         // 有相同的Key,判断这两个节点是否为sameNode
         vnodeToMove = oldCh[idxInOld]
         if (sameVnode(vnodeToMove, newStartVnode)) {
           // 如果是相同节点,进行patch  然后举将oldStart插入到oldStart之前,newStart下标继续移动
           patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
           oldCh[idxInOld] = undefined
           canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
         } else {
           // 如果不是相同节点,需要执行createElm创建新元素
           createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
         }
       }
       newStartVnode = newCh[++newStartIdx]
     }
   }

   // oldStartIdx > oldEndIdx说明oldChild先遍历完,使用addVnode方法添加newStartIdx指向的节点到newEndIdx的节点
   if (oldStartIdx > oldEndIdx) {
     refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
     addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
   } else if (newStartIdx > newEndIdx) {
     // 如果newStartIdx > newEndIdx说明newChild先遍历完,remove掉oldChild未遍历完的节点
     removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
   }
 }

复制代码

四、实际调试案例

<html>
    <header>
        <script src="../../dist/vue.js"></script>
    </header>
    <body>
        <div id="demo">
          <p v-for="item in items" :key="item">{{ item }}</p>
        </div>
        <div>
          ["a", "b", "c", "d", "e", "f", "g"] => ["f", "d", "a", "h", "e", "c", "b", "g"]
        </div>
        <script>
          const app = new Vue({
            el: "#demo",
            data: {
              items: ["a", "b", "c", "d", "e", "f", "g"]
            },
            mounted() {
              setTimeout(() => {
                this.items = ["f", "d", "a", "h", "e", "c", "b", "g"]
              }, 2000)
            }
          })
        </script>
      </body>
</html>
复制代码

vue中debug观察代码走向及流程

五、总结
正确使用key,可以快速执行sameVnode比对,加速Diff效率,可以作为性能优化的一个点。
DIff只做同级比较,使用sameVnode函数比对,文本节点直接替换文本内容。
子元素列表的Diff,进行头对头、尾对尾、头对尾等系列比较,直到遍历完两个元素的子元素列表。
或一个列表先遍历完了,直接addVnode / removeVnode。

参考:
Vue 源码解读(12)—— patch juejin.cn/post/696414…

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享