浅层看懂DIFF算法

关于DIFF算法,Vue框架与React框架有不同做法。本文以Vue2.0版本的源码进行学习解析,主讲核心updateChildren部分。

1.先看看官方代码

updateChildren (parentElm, oldCh, newCh) {
    let oldStartIdx = 0, 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
    let idxInOld
    let elmToMove
    let before
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (oldStartVnode == null) {   // 对于vnode.key的比较,会把oldVnode = null
            oldStartVnode = oldCh[++oldStartIdx] 
        }else if (oldEndVnode == null) {
            oldEndVnode = oldCh[--oldEndIdx]
        }else if (newStartVnode == null) {
            newStartVnode = newCh[++newStartIdx]
        }else if (newEndVnode == null) {
            newEndVnode = newCh[--newEndIdx]
        }else if (sameVnode(oldStartVnode, newStartVnode)) {
            patchVnode(oldStartVnode, newStartVnode)
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
        }else if (sameVnode(oldEndVnode, newEndVnode)) {
            patchVnode(oldEndVnode, newEndVnode)
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
        }else if (sameVnode(oldStartVnode, newEndVnode)) {
            patchVnode(oldStartVnode, newEndVnode)
            api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]
        }else if (sameVnode(oldEndVnode, newStartVnode)) {
            patchVnode(oldEndVnode, newStartVnode)
            api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        }else {
           // 使用key时的比较
            if (oldKeyToIdx === undefined) {
                oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
            }
            idxInOld = oldKeyToIdx[newStartVnode.key]
            if (!idxInOld) {
                api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                newStartVnode = newCh[++newStartIdx]
            }
            else {
                elmToMove = oldCh[idxInOld]
                if (elmToMove.sel !== newStartVnode.sel) {
                    api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                }else {
                    patchVnode(elmToMove, newStartVnode)
                    oldCh[idxInOld] = null
                    api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
                }
                newStartVnode = newCh[++newStartIdx]
            }
        }
    }
    if (oldStartIdx > oldEndIdx) {
        before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
        addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
    }else if (newStartIdx > newEndIdx) {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }

你肯定会看得头晕目眩,所以我自己总结了个精华版的

2.精华版的updateChildren代码

updateChildren (parentElm, oldCh, newCh) {
    ..省略 (新节点的头尾的变量newStartIdxh和newEndIdx)
    ..省略 (旧节点的头尾的变量oldStartIdx和oldEndIdx)
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (oldStartVnode == null) {   // 对于vnode.key的比较,会把oldVnode = null
        ...省略 判断oldStartIdx/oldEndIdx/newStartIdxh/newEndIdx(按照这个顺序依次判断)是否为null,是的话,指针往中间移动
        }else if (sameVnode(oldStartVnode, newStartVnode)) {
         ...省略 依次以(oldStartVnode, newStartVnode)/(oldEndIdx, newEndIdx)为参数执行patchVnode方法,执行后,指针往中间移动
        }else if (sameVnode(oldStartVnode, newEndVnode)) {
            patchVnode(oldStartVnode, newEndVnode)
            ...省略 依次以(oldStartVnode, newEndVnode)/(oldEndIdx, newStartVnode)为参数执行patchVnode方法,执行后,指针往中间移动
		1.如果是oldStartVnode, newEndVnode匹配上了,那么真实dom中的第一个节点会移到最后
		  如果是oldEndIdx, newStartVnode匹配上了,那么真实dom中的最后一个节点会移到最前,匹配上的两个指针向中间移动
		2.指针往中间移动
        }else {
           // 使用key时的比较
		   // 设key后,除了头尾两端的比较外,还会从用key生成的对象 oldKeyToIdx 中查找匹配的节点,所以为节点设置key可以更高效的利用dom。
            if (oldKeyToIdx === undefined) {
                oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
            }
            idxInOld = oldKeyToIdx[newStartVnode.key]
            //没有key时执行
            if (!idxInOld) {
                api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                newStartVnode = newCh[++newStartIdx]
            }
            //有key时执行
            else {
                elmToMove = oldCh[idxInOld]
                if (elmToMove.sel !== newStartVnode.sel) {
                    api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                }else {
                    patchVnode(elmToMove, newStartVnode)
                    oldCh[idxInOld] = null
                    api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
                }
                newStartVnode = newCh[++newStartIdx]
            }
        }
    }
	// oldStartIdx > oldEndIdx表示oldCh先遍历完,那么就将多余的vCh根据index添加到dom中去
	// oldStartIdx  > oldEndIdx表示vCh先遍历完,那么就在真实dom中将区间为[oldStartIdx , oldEndIdx]的多余节点删掉
    if (oldStartIdx > oldEndIdx) {
        before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
        addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
    }else if (newStartIdx > newEndIdx) {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }

3.updateChildren中用到的sameVnode方法

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)
      )
    )
  )
}

 它是用来判断节点是否可用的关键函数,可以看到,判断是否是 sameVnode,传递给节点的 key 是关键。

4.updateChildren中用到的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)
        }
    }
}

const el = vnode.el = oldVnode.el 这是很重要的一步,让  vnode.el 引用到现在的真实dom,当 el 修改时,  vnode.el 会同步变化。

节点的比较有5种情况

  1. if (oldVnode === vnode) ,他们的引用一致,可以认为没有变化。

  2. if(oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text),文本节点的比较,需要修改,则会调用  Node.textContent = vnode.text 。

  3. if( oldCh && ch && oldCh !== ch ) , 两个节点都有子节点,而且它们不一样,这样我们会调用 updateChildren 函数比较子节点,这是diff的核心,后边会讲到。

  4. else if (ch) ,只有新的节点有子节点,调用  createEle(vnode) , vnode.el 已经引用了老的dom节点,  createEle 函数会在老dom节点上添加子节点。

  5. else if (oldCh) ,新节点没有子节点,老节点有子节点,直接删除老节点。

5.总体描述

diff过程整体遵循深度优先、同层比较的策略;先进行头尾节点可能相同做4次比对尝试。如果没有找到相同节点才按照通用方式遍历查找。这是如果设置了key,就会从用key生成的对象 oldKeyToIdx 中查找匹配的节点。如果没有key,则直接将newStartIdxh生成新的节点插入真实DOM。查找结束再按情况处理剩下的节点;

在指针相遇以后,还有两种比较特殊的情况:

  1. 有新节点需要加入。如果更新完以后,oldStartIdx > oldEndIdx ,说明旧节点都被patch 完了,但是有可能还有新的节点没有被处理到。接着会去判断是否要新增子节点。

  2. 有旧节点需要删除。如果新节点先patch完了,那么此时会走 newStartIdxh > newEndIdx 的逻辑,那么就会去删除多余的旧子节点。

6.为什么 Vue 中不要用 index 作为 key?

假设我们在data中有一个数组num , 值为[1, 2, 3]。我们渲染出来 1 2 3 三个数字。我们先以 index 作为key,来跟踪一下它的更新。假设我们用一个点击事件使数组做 reverse(翻转) 的操作。这时候会导致key的顺序没变,传入的值完全变了。

本来按照最合理的逻辑来说,旧的第一个vnode 是应该直接完全复用 新的第三个vnode的,因为它们本来就应该是同一个vnode,自然所有的属性都是相同的。但是在进行子节点的 diff 过程中,会在 旧首节点和新首节点用sameNode对比。 这一步命中逻辑,因为现在新旧两次首部节点 的 key 都是 0了,

然后把旧的节点中的第一个 vnode 和 新的节点中的第一个 vnode 进行 patchVnode 操作。

这会发生什么呢?我可以大致给你列一下:首先,正如我之前的文章props的更新如何触发重渲染?里所说,在进行 patchVnode 的时候,会去检查 属性有没有变更,如果有的话,会通过 属性值赋值 这样的逻辑去更新这个响应式的值,触发 dep.notify,触发子组件视图的重新渲染等一套很重的逻辑。

然后,还会额外的触发以下几个钩子,假设我们的组件上定义了一些dom的属性或者类名、样式、指令,那么都会被全量的更新。

  1. updateAttrs

  2. updateClass

  3. updateDOMListeners

  4. updateDOMProps

  5. updateStyle

  6. updateDirectives

而这些所有重量级的操作(虚拟dom发明的其中一个目的不就是为了减少真实dom的操作么?),都可以通过直接复用 第三个vnode 来避免,是因为我们偷懒写了 index 作为key,而导致所有的优化失效了。

7.总结key

  1. 用组件唯一的 id(一般由后端返回)作为它的 key,实在没有的情况下,可以在获取到列表的时候通过某种规则为它们创建一个 key,并保证这个 key 在组件整个生命周期中都保持稳定。

  2. 别用 index 作为 key,和没写基本上没区别,因为不管你数组的顺序怎么颠倒,index 都是 0, 1, 2 这样排列,导致 Vue 会复用错误的旧子节点,做很多额外的工作。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值