• home > webfront > ECMAS > react >

    diff算法:react与vuediff算法对比,snabbdom.js

    Author:zhoulujun Date:

    vue和react使用虚拟dom(virtual DOM),只会在同一层级去比较,如果有变化直接删除当前虚拟dom节点,插入新的节点 vue2 0加入了virtual dom,和react拥有相同的 diff 优化原则,差异就在于, diff的过程就是调用patch函数,就像打补丁一样

    diff算法

    传统diff算法循环新旧dom树的所有节点去比较,更新修改的dom

    将两颗树中所有的节点一一对比需要O(n²)的复杂度,在对比过程中发现旧节点在新的树中未找到,那么就需要把旧节点删除,删除或者删除一棵树的一个节点(找到一个合适的节点放到被删除的位置)的时间复杂度为O(n)),合起来diff两个树的复杂度就是O(n³)

    最开始经典的深度优先遍历DFS算法,其复杂度为O(n^3),存在高昂的diff成本,然后是cito.js的横空出世,它对今后所有虚拟DOM的算法都有重大影响。它采用两端同时进行比较的算法,将diff速度拉高到几个层次。紧随其后的是kivi.js,在cito.js的基出提出两项优化方案,使用key实现移动追踪及基于key的编辑长度距离算法应用(算法复杂度 为O(n^2))。但这样的diff算法太过复杂了,于是后来者snabbdom将kivi.js进行简化,去掉编辑长度距离算法,调整两端比较算法。速度略有损失,但可读性大大提高。再之后,就是著名的vue2.0 把snabbdom整个库整合掉了

    因此目前VirtualDOM的主流diff算法趋向一致,在主要diff思路上,snabbdom与react的reconilation方式基本相同。

    vue和react使用虚拟dom(virtual DOM),只会在同一层级去比较,如果有变化直接删除当前虚拟dom节点,插入新的节点

    vue和react的虚拟DOM的diff算法大致相同,其核心是基于两个简单的假设:

    1. 两个相同的组件产生类似的DOM结构,不同的组件产生不同的DOM结构

    2. 同一层级的一组节点,他们可以通过唯一的id进行区分

    (优化的)diff三点策略:

    1. web UI中DOM节点跨层级的移动操作特别少,可以忽略不计。

    2. 拥有相同类型的两个组件将会生成相似的树形结构,拥有不同类型的两个组件将会生成不同树形结构。

    3. 对于同一层级的一组自节点,他们可以通过唯一id进行区分。

    所以,比较只会在同层级进行, 不会跨层级比较

    react:

    分三个策略:

    1. component diff(组件间的比较/同级比较): 拥有相同类的两个组件 生成相似的树形结构,拥有不同类的两个组件 生成不同的树形结构。

    2. tree diff (虚拟DOM树分层比较/子节点比较): Web UI中DOM节点跨层级的移动操作特别少,可以忽略不计

    3. element diff(元素间比较/key比较): 对于同一层级的一组子节点,通过唯一id区分。

    策略的优先级是:1. 同级比较,2. Key 的使用,3. 子节点更新。

    tree diff

    DOM节点出现了跨层级操作:只有创建节点和删除节点的操作

    tree diff 总结:

    1. React通过updateDepth对Virtual DOM树进行层级控制。

    2. 对树分层比较,两棵树 只对同一层次节点 进行比较。如果该节点不存在时,则该节点及其子节点会被完全删除,不会再进一步比较。

    3. 只需遍历一次,就能完成整棵DOM树的比较。

    tree diff  概述:

    基于策略一,React 对树的算法进行了简洁明了的优化,即对树进行分层比较,两棵树只会对同一层次的节点进行比较。

    既然 DOM 节点跨层级的移动操作少到可以忽略不计,针对这一现象,React 通过 updateDepth 对 Virtual DOM 树进行层级控制,只会对相同颜色方框内的 DOM 节点进行比较,即同一个父节点下的所有子节点。当发现节点已经不存在,则该节点及其子节点会被完全删除掉,不会用于进一步的比较。这样只需要对树进行一次遍历,便能完成整个 DOM 树的比较。

    0c08dbb6b1e0745780de4d208ad51d34_hd.jpg

    updateChildren: function(nextNestedChildrenElements, transaction, context) {
      updateDepth++;
      var errorThrown = true;
      try {
        this._updateChildren(nextNestedChildrenElements, transaction, context);
        errorThrown = false;
      } finally {
        updateDepth--;
        if (!updateDepth) {
          if (errorThrown) {
            clearQueue();
          } else {
            processQueue();
          }
        }
      }
    }

    分析至此,大部分人可能都存在这样的疑问:如果出现了 DOM 节点跨层级的移动操作,React diff 会有怎样的表现呢?是的,对此我也好奇不已,不如试验一番。

    如下图,A 节点(包括其子节点)整个被移动到 D 节点下,由于 React 只会简单的考虑同层级节点的位置变换,而对于不同层级的节点,只有创建和删除操作。当根节点发现子节点中 A 消失了,就会直接销毁 A;当 D 发现多了一个子节点 A,则会创建新的 A(包括子节点)作为其子节点。此时,React diff 的执行情况:create A -> create B -> create C -> delete A。

    d712a73769688afe1ef1a055391d99ed_hd (1).jpg

    由此可发现,当出现节点跨层级移动时,并不会出现想象中的移动操作,而是以 A 为根节点的树被整个重新创建,所以:

    当进行跨层级的移动操作,react并不是简单的进行移动,而是进行了删除和创建的操作

    这是一种影响 React 性能的操作,因此 React 官方建议不要进行 DOM 节点跨层级的操作。

    提示:在开发组件时,保持稳定的 DOM 结构会有助于性能的提升。例如,可以通过 CSS 隐藏或显示节点,而不是真的移除或添加 DOM 节点。

    component diff React 

    对不同的组件间的比较,有三种策略

    1. 同一类型的两个组件,按原策略(层级比较)继续比较Virtual DOM树即可。

    2. 同一类型的两个组件,组件A变化为组件B时,可能Virtual DOM没有任何变化,如果知道这点(变换的过程中,Virtual DOM没有改变),可节省大量计算时间,所以 用户 可以通过 shouldComponentUpdate() 来判断是否需要 判断计算。

    3. 不同类型的组件,将一个(将被改变的)组件判断为dirty component(脏组件),从而替换整个组件的所有节点。

    注意:如果组件A和组件B的结构相似,但是 React判断是不同类型的组件,则不会比较其结构,而是删除组件A及其子节点,创建组件B及其子节点

    如下图,当 component D 改变为 component G 时,即使这两个 component 结构相似,一旦 React 判断 D 和 G 是不同类型的组件,就不会比较二者的结构,而是直接删除 component D,重新创建 component G 以及其子节点。虽然当两个 component 是不同类型但结构相似时,React diff 会影响性能,但正如 React 官方博客所言:不同类型的 component 是很少存在相似 DOM tree 的机会,因此这种极端因素很难在实现开发过程中造成重大影响的。

    52654992aba15fc90e2dac8b2387d0c4_hd.jpg

    element diff

    当节点处于同一层级时,diff提供三种节点操作:INSERT_MARKUP(插入)、MOVE_EXISTING(移动)和 REMOVE_NODE(删除)。

    • 插入:组件 C 不在集合(A,B)中,需要插入

    • 删除

      • 组件D在集合(A,B,D)中,但D的节点已经更改,不能复用和更新,所以需要删除旧的D ,再创建新的。

      • 组件 D 之前在 集合(A,B,D)中,但集合变成新的集合(A,B)了,D 就需要被删除。

    • 移动:组件D已经在集合(A,B,C,D)里了,且集合更新时,D没有发生更新,只是位置改变,如新集合(A,D,B,C),D在第二个,无须像传统diff,让旧集合的第二个B和新集合的第二个D 比较,并且删除第二个位置的B,再在第二个位置插入D,而是(对同一层级的同组子节点) 添加唯一key进行区分,移动即可。


      • 看着上图的 B,React先从新中取得B,然后判断旧中是否存在相同节点B,当发现存在节点B后,就去判断是否移动B。

      • B在旧 中的index=1,它的lastIndex=0,不满足 index < lastIndex 的条件,因此 B 不做移动操作。此时,一个操作是,lastIndex=(index,lastIndex)中的较大数=1.

      • 看着 A,A在旧的index=0,此时的lastIndex=1,满足index<lastIndex,对A进行移动操作,此时lastIndex=max(index,lastIndex)=1。

      • 看着D,同,不移动,由于D在旧的index=3,比较时,lastIndex=1,所以改变lastIndex=max(index,lastIndex)=3

      • 看着C,同,移动,C在旧的index=2,满足index<lastIndex(lastIndex=3),所以移动。

    React 提出优化策略:允许开发者对同一层级的同组子节点,添加唯一 key 进行区分,虽然只是小小的改动,性能上却发生了翻天覆地的变化!

    7b9beae0cf0a5bc8c2e82d00c43d1c90_hd.jpg

    element diff 为diff算法核心,还是的阅读 snabbdom.js 源码分析,如:《Virtual DOM和snabbdom.js》、《解析 snabbdom 源码

    vue:

    vue2.0加入了virtual dom,和react拥有相同的 diff 优化原则,差异就在于, diff的过程就是调用patch函数,就像打补丁一样修改真实dom。


    Vue 并未完全自主开发一套 Virtual DOM,而是借鉴了另一个开源库snabbdom,其核心算法逻辑代码请参考https://github.com/snabbdom/snabbdom/blob/v0.7.3/src/snabbdom.ts#L179

    从数据改变的set方法开始的diff算法如下图所示:

    vue2 diff算法


    如果新旧两个节点完全不一样了isSameVnode返回false,则整个节点更新,如果节点本身是一样的,就比较他们的子节点

    patch 

    当数据发生改变时,set方法会让调用Dep.notify通知所有订阅者Watcher,订阅者就会调用patch给真实的DOM打补丁


    function patch (oldVnode, vnode) {
      // 对于同类型节点调用patchVnode(oldVnode, vnode)进一步比较:
      if (sameVnode(oldVnode, vnode)) {
        patchVnode(oldVnode, vnode);
      } else {
        const oEl = oldVnode.el;
        let parentEle = api.parentNode(oEl);
        createEle(vnode);
        if (parentEle !== null) {
          api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl));
          api.removeChild(parentEle, oldVnode.el);
          oldVnode = null;
        }
      }
      return vnode;
    }
    
    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)
      );
    }
    patchVnode (oldVnode, vnode) {
        const el = vnode.el = oldVnode.el  //让vnode.el引用到现在的真实dom,当el修改时,vnode.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)
            //对于拥有子节点(两者的子节点不同)的两个节点,调用updateChildren
            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)
            }
        }
    }



    sameVnode 函数可以解析为什么需要加key优化

    updateChildren

    updateChildren是vue diff的核心

    过程可以概括为:oldCh和newCh各有两个头尾的变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldCh和newCh至少有一个已经遍历完了,就会结束比较


    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.png



    Vue 2.x vs Vue 3.x

    Vue2的核心Diff算法采用了双端比较的算法,同时从新旧children的两端开始进行比较,借助key值找到可复用的节点,再进行相关操作。相比React的Diff算法,同样情况下可以减少移动节点次数,减少不必要的性能损耗,更加的优雅

    Vue3.x借鉴了 ivi算法和 inferno算法。在创建VNode时就确定其类型,以及在mount/patch的过程中采用位运算来判断一个VNode的类型,在这个基础之上再配合核心的Diff算法,使得性能上较Vue2.x有了提升。(实际的实现可以结合Vue3.x源码看

    该算法中还运用了动态规划的思想求解最长递归子序列

    具体推荐阅读:vue3为何向Inferno与ivi靠拢




    参看文章:

    传统diff、react优化diff、vue优化diff  https://www.jianshu.com/p/398e63dc1969

    react,vue diff算法 https://zhuanlan.zhihu.com/p/128623934

    聊一聊Diff算法(React、Vue2.x、Vue3.x) https://juejin.im/post/5eabb8776fb9a0437d2b1ecb

    https://dennisgo.cn/Articles/Vue/reactive.html

    浅谈react diff实现 https://github.com/yinguangyao/blog/issues/27



    转载本站文章《diff算法:react与vuediff算法对比,snabbdom.js 》,
    请注明出处:https://www.zhoulujun.cn/html/webfront/ECMAScript/jsBase/2020_0628_8489.html