彻底理解vue的patch流程和diff算法

前言

上一篇《vue异步更新流程梳理》 梳理了数据从赋值到更新到视图的整体流程。但是最后的步骤vm._update(vm._render()) 只是粗略的提了一嘴,现在就仔细的研究它内部的细节,搞清楚patch流程和diff原理是我们看源码的重中之重。

更新过程

  • 代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="app">{{value}}</div>
  <script src="../dist/vue.js"></script>
  <script>
    new Vue({
      el: '#app',
      data() {
        return {
          value: 1
        }
      },
      mounted () {
        this.value = 2
      }
    })
  </script>
</body>
</html>

  • 在vue实例调用$mount的时候,就已经把updateComponent 方法通过new Watcher(vm, updateComponent)传入到渲染watcher里面, 且挂在watcher.getter上,得到一个渲染watcher, 渲染watcher在以后每次响应式数据更新都会执行watcher.getter 即 updateComponent 方法。


    mount.png
watcher.getter.png

当更新数据的时候就会执行这个updateComponent方法,即方法里面的vm._update(vm._render()),vm.render() 得到一个vnode,那么vm._update到底干什么? 进去看看

image.png

其实就是判断是进行初始化流程(initial render) 或者是更新流程(updates),但是都是调用了 vm.__patch__ 函数;两者的区别是

  • 初始化的第一个参数是el节点,也就是例子中真实的dom节点#app
  • 而更新流程的第一个参数就是prevVnode, 这个prevVnode其实是一个vnode, 是更新前的vnode

至此,无论是初始化还是更新都是靠patch来完成的 ,我们只需要看update流程就可以了。进入patch内部

image.png

patch函数主要接收oldVnode 与 vnode两个参数,其实就是新旧两棵虚拟树。这里经过判断条件 !isRealElement && sameVnode(oldVnode, vnode),不是真实节点 且是相同的vnode,进入patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly); 我们只要关注oldVnode, vnode这两个参数即可。

按照我们的例子,此时的oldVnode 与 vnode分别是

oldVnode: {
  tag: 'div',
  key: undefined,
  elm: div#app, // 这是该vnode节点映射对应的真实dom节点
  children: [
    { 
      tag: undefined,
      key: undefined,
      elm: text // 文本节点
      text: '1'
    }
  ]
}

vnode: {
  tag: 'div',
  key: undefined,
  elm: div#app, // 这是该vnode节点映射对应的真实dom节点
  children: [
    { 
      tag: '',
      key: undefined,
      elm: undefined, // 注意这里是undefined,此时还没有diff
      text: '2'
    }
  ]
}

此处只列出关键属性tag, key, elm, children,elm,还有很多其他的属性没有列出。真实的虚拟树节点应该是如下图


image.png

我们能看出 两个vnode之间就是children[0]的不同:

oldVnode.children[0].text === '1',
vnode.children[0].text === '2'

追踪流程发现,我们进入oldVnode 与 vnode的children进行对比,在updateChildren函数中。


image.png

我们先不去看updateChildren的逻辑,继续看patchVnode这个函数其他的逻辑分支,得出oldVnode 与 vnode的对比流程

  1. 拿出两者的children: oldCh, ch
  2. 如果vnode是元素节点
    2.1,如果oldCh, ch两者都存在且不相同,进入updateChildren流程
    2.2,否则如果只是ch 存在,oldCh不存在,那么直接操作真实dom, 添加ch节点
    2.3,否则如果只是oldCh 存在,ch不存在,那么直接操作真实dom, 删除oldCh节点
    2.4,否则如果只是oldCh是文本, ch不存在,直接操作真实dom设置文本节点为空 ''
  3. 如果vnode是文本节点,直接操作真实dom, 设置文本节点为 vnode.text

总结:patchVnode这个方法的主要作用是对比两个虚拟节点过程中去更新真实dom

接下来我们进入updateChildren流程,这是两个children的对比,看一下这个函数的定义

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0
    let newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    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 <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]
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } 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]
      } 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]
      } 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]
          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
            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)
    }
  }

函数解读:

  • 参数 parentElm:真实的dom元素,做为父节点,供更新children时去插入
  • 参数 oldCh:老的虚拟树的children
  • 参数 newCh: 新的虚拟树的children
  • oldStartIdx:老数组的开始的下标index
  • newStartIdx: 新数组的开始的下标index
  • oldEndIdx: 老数组的结束的下标index
  • oldStartVnode : 老数组的开始的节点
  • oldEndVnode : 老数组的结束的节点
  • newEndIdx :新数组的结束的下标index
  • newStartVnode :新数组的开始的节点
  • newEndVnode :新数组的结束的节点
  • oldKeyToIdx:老数组的节点的key与index的一个映射map
  • idxInOld:老数组的某个节点的index
  • vnodeToMove:待移动的节点,也就是在oldKeyToIdx中找到key的可复用的节点
  • refElm: 一个真实的节点,用来做参考位置的,表示从哪里开始添加新的dom节点

下面是两个数组进行diff的流程,也就是diff算法

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } 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]
      } 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]
      } 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]
          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
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }

diff解读:
新旧两个数组,都有双端指针,两端指针向中间靠拢,直到某个数组的两端指针相交则退出循环。

在这个过程中,会先判断是否有以下四种情况

  • 旧首 - 新首 是同一个节点,互相比较,深度递归进行patchVnode流程,
  • 旧尾 - 新尾 是同一个节点,互相比较,深度递归进行patchVnode流程
  • 旧首 - 新尾 是同一个节点,互相比较,深度递归进行patchVnode流程
  • 旧尾 - 新首 是同一个节点,互相比较,深度递归进行patchVnode流程

如果不符合这4种情况,那就基于旧数组遍历一次,拿到每个节点的key和index,就是oldKeyToIdx: {key1: 0, key2: 1}这种情况。然后去新数组首个节点开始匹配,匹配到就进行递归patchVnode流程,没匹配到就进行创建新节点,插入到真实dom节点里面去。

当循环结束,此时要么是旧数组相交,要么是新数组相交,只有这两种情况:

  1. 旧数组相交,说明新数组还没有相交,那么要根据相交的位置插入新数组剩余的未遍历到节点
  2. 新数组相交,说明旧数组还没有相交,那么要删除旧数组剩余的未遍历到的节点

至此diff流程结束。

总结

两个虚拟树进行对比:
patch(oldVnode, vnode) -> patchVnode(oldVnode, vnode) -> updataChildren(oldCh, newCh)
在updataChildren(oldCh, newCh)的过程中也会进行 patchVnode(oldVnode, vnode) ,如此虚拟树深度优先递归diff完成。

更加详细直观的图看此链接
https://www.processon.com/view/5e809004e4b08e4e2447d02e

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,504评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,434评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,089评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,378评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,472评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,506评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,519评论 3 413
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,292评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,738评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,022评论 2 329
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,194评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,873评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,536评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,162评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,413评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,075评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,080评论 2 352

推荐阅读更多精彩内容