vue之虛擬DOM、diff算法


一、真實DOM和其解析流程?

    瀏覽器渲染引擎工作流程都差不多,大致分為5步,創建DOM樹——創建StyleRules——創建Render樹——布局Layout——繪制Painting

    第一步,用HTML分析器,分析HTML元素,構建一顆DOM樹(標記化和樹構建)。

    第二步,用CSS分析器,分析CSS文件和元素上的inline樣式,生成頁面的樣式表。

    第三步,將DOM樹和樣式表,關聯起來,構建一顆Render樹(這一過程又稱為Attachment)。每個DOM節點都有attach方法,接受樣式信息,返回一個render對象(又名renderer)。這些render對象最終會被構建成一顆Render樹。

    第四步,有了Render樹,瀏覽器開始布局,為每個Render樹上的節點確定一個在顯示屏上出現的精確坐標。

    第五步,Render樹和節點顯示坐標都有了,就調用每個節點paint方法,把它們繪制出來。 

    DOM樹的構建是文檔加載完成開始的?構建DOM數是一個漸進過程,為達到更好用戶體驗,渲染引擎會盡快將內容顯示在屏幕上。它不必等到整個HTML文檔解析完畢之后才開始構建render數和布局。

    Render樹是DOM樹和CSSOM樹構建完畢才開始構建的嗎?這三個過程在實際進行的時候又不是完全獨立,而是會有交叉。會造成一邊加載,一遍解析,一遍渲染的工作現象。

    CSS的解析是從右往左逆向解析的(從DOM樹的下-上解析比上-下解析效率高),嵌套標簽越多,解析越慢。

 
webkit渲染引擎工作流程

二、JS操作真實DOM的代價!

        用我們傳統的開發模式,原生JS或JQ操作DOM時,瀏覽器會從構建DOM樹開始從頭到尾執行一遍流程。在一次操作中,我需要更新10個DOM節點,瀏覽器收到第一個DOM請求后並不知道還有9次更新操作,因此會馬上執行流程,最終執行10次。例如,第一次計算完,緊接着下一個DOM更新請求,這個節點的坐標值就變了,前一次計算為無用功。計算DOM節點坐標值等都是白白浪費的性能。即使計算機硬件一直在迭代更新,操作DOM的代價仍舊是昂貴的,頻繁操作還是會出現頁面卡頓,影響用戶體驗。

三、為什么需要虛擬DOM,它有什么好處?

        Web界面由DOM樹(樹的意思是數據結構)來構建,當其中一部分發生變化時,其實就是對應某個DOM節點發生了變化,

        虛擬DOM就是為了解決瀏覽器性能問題而被設計出來的。如前,若一次操作中有10次更新DOM的動作,虛擬DOM不會立即操作DOM,而是將這10次更新的diff內容保存到本地一個JS對象中,最終將這個JS對象一次性attch到DOM樹上,再進行后續操作,避免大量無謂的計算量。所以,用JS對象模擬DOM節點的好處是,頁面的更新可以先全部反映在JS對象(虛擬DOM)上,操作內存中的JS對象的速度顯然要更快,等更新完成后,再將最終的JS對象映射成真實的DOM,交由瀏覽器去繪制。

四、實現虛擬DOM

        例如一個真實的DOM節點。

 
真實DOM

        我們用JS來模擬DOM節點實現虛擬DOM。

 
虛擬DOM

        其中的Element方法具體怎么實現的呢?

 
Element方法實現

        第一個參數是節點名(如div),第二個參數是節點的屬性(如class),第三個參數是子節點(如ul的li)。除了這三個參數會被保存在對象上外,還保存了key和count。其相當於形成了虛擬DOM樹。

 
虛擬DOM樹

        有了JS對象后,最終還需要將其映射成真實DOM

 
虛擬DOM對象映射成真實DOM

        我們已經完成了創建虛擬DOM並將其映射成真實DOM,這樣所有的更新都可以先反應到虛擬DOM上,如何反應?需要用到Diff算法

五、傳統diff

計算兩顆樹形結構差異並進行轉換,傳統diff算法是這樣做的:循環遞歸每一個節點


 

比如左側樹a節點依次進行如下對比,左側樹節點b、c、d、e亦是與右側樹每個節點對比
算法復雜度能達到O(n^2),n代表節點的個數

a->e、a->d、a->b、a->c、a->a

查找完差異后還需計算最小轉換方式,這其中的原理我沒仔細去看,最終達到的算法復雜度是O(n^3)

六、Vue優化的diff策略

既然傳統diff算法性能開銷如此之大,Vue做了什么優化呢?

  • 跟react一樣,只進行同層級比較,忽略跨級操作

react以及Vue在diff時,都是在對比虛擬dom節點,下文提到的節點都指虛擬節點。Vue是怎樣描述一個節點的呢?

Vue虛擬節點

// body下的 <div id="v" class="classA"><div> 對應的 oldVnode 就是

{
  el:  div  //對真實的節點的引用,本例中就是document.querySelector('#id.classA')
  tagName: 'DIV',   //節點的標簽
  sel: 'div#v.classA'  //節點的選擇器
  data: null,       // 一個存儲節點屬性的對象,對應節點的el[prop]屬性,例如onclick , style
  children: [], //存儲子節點的數組,每個子節點也是vnode結構
  text: null,    //如果是文本節點,對應文本節點的textContent,否則為null
}

patch

diff時調用patch函數,patch接收兩個參數vnode,oldVnode,分別代表新舊節點。

function patch (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
}

patch函數內第一個if判斷sameVnode(oldVnode, vnode)就是判斷這兩個節點是否為同一類型節點,以下是它的實現:

function sameVnode(oldVnode, vnode){
  //兩節點key值相同,並且sel屬性值相同,即認為兩節點屬同一類型,可進行下一步比較
    return vnode.key === oldVnode.key && vnode.sel === oldVnode.sel
}

也就是說,即便同一個節點元素比如div,他的className不同,Vue就認為是兩個不同類型的節點,執行刪除舊節點、插入新節點操作。這與react diff實現是不同的,react對於同一個節點元素認為是同一類型節點,只更新其節點上的屬性。

 

patchVnode

對於同類型節點調用patchVnode(oldVnode, vnode)進一步比較:

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

updateChildren

patchVnode中有一個重要的概念updateChildren,這是Vue diff實現的核心:

 function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    var oldStartIdx = 0;
    var newStartIdx = 0;
    var oldEndIdx = oldCh.length - 1;
    var oldStartVnode = oldCh[0];
    var oldEndVnode = oldCh[oldEndIdx];
    var newEndIdx = newCh.length - 1;
    var newStartVnode = newCh[0];
    var newEndVnode = newCh[newEndIdx];
    var 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
    var canMove = !removeOnly;

    {
      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);
        oldStartVnode = oldCh[++oldStartIdx];
        newStartVnode = newCh[++newStartIdx];
        // 如果舊的結束節點與新的結束節點相同,則開始更新該節點,然后進入下一個節點
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
        oldEndVnode = oldCh[--oldEndIdx];
        newEndVnode = newCh[--newEndIdx];
        // 如果舊的開始節點與新的結束節點相同,更新節點后把舊的開始節點移置節點末尾
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        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);
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      } else {
          // 如果舊的節點沒有定義key,則創建key
        if (isUndef(oldKeyToIdx)) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); }
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
        // 如果沒有定義index,則創建新的新的節點元素
        if (isUndef(idxInOld)) { // New element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm);
        } else {
          vnodeToMove = oldCh[idxInOld];
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue);
            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);
          }
        }
        newStartVnode = newCh[++newStartIdx];
      }
    }
    // 如果舊節點的開始index大於結束index,則創建新的節點  如果新的開始節點index大於新的結束節點則刪除舊的節點
    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(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }
過程可以概括為:oldCh和newCh各有兩個頭尾的變量StartIdx和EndIdx,它們的2個變量相互比較,一共有4種比較方式。如果4種比較都沒匹配,如果設置了key,就會用key進行比較,在比較的過程中,變量會往中間靠,一旦StartIdx>EndIdx表明oldCh和newCh至少有一個已經遍歷完了,就會結束比較。
詳細解析有大神的: 解析vue2.0的diff算法




免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM