探索Virtual DOM的前世此生

做者:百度外賣  程亞傑  李顯  盧培鵬
轉載請標明出處
複製代碼

緣起

在前端開發過程當中,對性能產生最大影響的因素莫過於DOM的重排重繪了,React做爲前端框架領跑者,爲了有效解決DOM更新開銷的問題,採用了Virtual DOM的思路,不只提高了DOM操做的效率,更推進了數據驅動式組件開發的造成與完善。一旦習慣了數據驅動式開發,再要求咱們使用顯式DOM操做開發的話,虐心程度無異於春節返鄉的車票賣完了,只能坐長途展轉煎熬了。前端

而VirtualDOM的主要思想就是模擬DOM的樹狀結構,在內存中建立保存映射DOM信息的節點數據,在因爲交互等因素須要視圖更新時,先經過對節點數據進行diff後獲得差別結果後,再一次性對DOM進行批量更新操做,這就比如在內存中建立了一個平行世界,瀏覽器中DOM樹的每個節點與屬性數據都在這個平行世界中存在着另外一個版本的虛擬DOM樹,全部複雜曲折的更新邏輯都在平行世界中的VirtualDOM處理完成,只將最終的更新結果發送給瀏覽器中的DOM樹執行,這樣就避免了冗餘瑣碎的DOM樹操做負擔,進而有效提升了性能。vue

若是你已是熟練使用vue或者react的項目老手,本文將助你一探這些前端框架進行視圖更新背後的工做原理,而且能夠必定程度掌握VirtualDOM的核心算法,即使你還未享受過這些數據驅動的工具帶來的便利,經過閱讀本文,你也將瞭解到一些當下的前端框架是如何對開發模式產生鉅變影響的。同時本文也是咱們對相關知識學習的一個總結,不免有誤,歡迎多多指正,並期待大大們的指點。node


Diff效率之爭

VirtualDOM是react在組件化開發場景下,針對DOM重排重繪性能瓶頸做出的重要優化方案,而他最具價值的核心功能是如何識別並保存新舊節點數據結構之間差別的方法,也便是diff算法。毫無疑問的是diff算法的複雜度與效率是決定VirtualDOM可以帶來性能提高效果的關鍵因素。所以,在VirtualDOM方案被提出以後,社區中不斷涌現出對diff的改進算法,引用司徒正美的經典介紹:react

最開始經典的深度優先遍歷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方式基本相同。git

Diff主要策略

  • 按tree層級diff(level by level)

因爲diff的數據結構是以DOM渲染爲目標的模擬樹狀層級結構的節點數據,而在WebUI中不多出現DOM的層級結構由於交互而產生更新,所以VirtualDOM的diff策略是在新舊節點樹之間按層級進行diff獲得差別,而非傳統的按深度遍歷搜索,這種經過大膽假設獲得的改進方案,不只符合實際場景的須要,並且大幅下降了算法實現複雜度,從O(n^3)提高至O(n)。github


  • 按類型進行diff

不管VirtualDOM中的節點數據對應的是一個原生的DOM節點仍是vue或者react中的一個組件,不一樣類型的節點所具備的子樹節點之間結構每每差別明顯,所以對不一樣類型的節點的子樹進行diff的投入成本與產出比將會很高昂,爲了提高diff效率,VirtualDOM只對相同類型的同一個節點進行diff,當新舊節點發生了類型的改變時,則並不進行子樹的比較,直接建立新類型的VirtualDOM,替換舊節點。web


  • 列表diff

當被diff節點處於同一層級時,經過三種節點操做新舊節點進行更新:插入,移動和刪除,同時提供給用戶設置key屬性的方式調整diff更新中默認的排序方式,在沒有key值的列表diff中,只能經過按順序進行每一個元素的對比,更新,插入與刪除,在數據量較大的狀況下,diff效率低下,若是可以基於設置key標識盡心diff,就可以快速識別新舊列表之間的變化內容,提高diff效率。算法


Virtual DOM不一樣的實現方式

基於以上的三條diff原則,咱們就能夠自由選擇Virtual DOM的具體方案,甚至本身動手進行diff實踐,在那以前,讓咱們先以Vue中的snabbdom與React中的Reconcile這兩個Virtual DOM的實現方案爲對象進行學習。segmentfault

snabbdom的vnode

在衆多VirtuaDOM實現方案中,snabbdom以其高效的實現,小巧的體積與靈活的可擴展性脫穎而出,它的核心代碼只有300行+,卻已被適用於vue等輕量級前端框架中做爲VirtualDOM的主要功能實現。api

一個使用snabbdom建立的demo是這樣的:

import snabbdom from 'snabbdom';
import h from 'snabbdom/h';
const patch = snabbdom.init([
  require('snabbdom/modules/class'),          // makes it easy to toggle classes
  require('snabbdom/modules/props'),          // for setting properties on DOM elements
  require('snabbdom/modules/style'),          // handles styling on elements with support for animations
  require('snabbdom/modules/eventlisteners'), // attaches event listeners
]);

var vnode = h('div', {style: {fontWeight: 'bold'}}, 'Hello world');
patch(document.getElementById('placeholder'), vnode)
複製代碼

在snabbdom中提供了h函數作爲建立VirtualDOM的主要函數,h函數接受的三個參數同時揭示了diff算法中關注的三個核心:節點類型,屬性數據,子節點對象。而patch方法便是用來建立初始DOM節點與更新VirtualDOM的diff核心函數。

function view(name) { 
  return h('div', [
    h('input', {
      props: { type: 'text', placeholder: 'Type a your name' },
      on   : { input: update }
    }),
    h('hr'),
    h('div', 'Hello ' + name)
  ]); 
}

var oldVnode = document.getElementById('placeholder');

function update(event) {
  const newVnode = view(event.target.value);
  oldVnode = patch(oldVnode, newVnode);
}

oldVnode = patch(oldVnode, view(''));
複製代碼

以上是一個經過input事件觸發VirtualDOM更新的典型app。在h函數中,不光能夠爲VirtualDOM保存數據屬性,還能夠設置事件回調函數,並在其中獲取並處理相關的事件屬性,如update回調中的event對象。經過捕獲事件中建立新的vnode,與舊的vnode進行diff,最終對當前的oldVnode進行更新,並向用戶展現更新結果,他的工做流程以下:


在snabbdom源碼中的核心patch函數中很明顯的體現了VirtualDOM的按類型diff與列表diff的策略:若是patch的新舊節點通過sameVnode判斷不是同一個節點,則進行新節點的建立插入與舊節點的刪除,而sameVnode也便是判斷兩個節點是否有相同的key標識與傳入的帶有節點類型等信息的selector字符串是否相同爲依據的:

function sameVnode(vnode1, vnode2) {
    return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}
複製代碼

而對相同節點進行新舊diff的主函數patchVnode的實現流程以下,其中oldCh與ch爲保存舊的當前節點與將要更新的新節點:


//新的text不存在
        if (isUndef(vnode.text)) {
            if (isDef(oldCh) && isDef(ch)) {
                if (oldCh !== ch)
                    updateChildren(elm, oldCh, ch, insertedVnodeQueue);
            }
            //舊的子節點不存在,新的存在
            else if (isDef(ch)) {
                //舊的text存在
                if (isDef(oldVnode.text))
                    api.setTextContent(elm, '');
                //把新的插入到elm底下
                addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
            }
            //新的子節點不存在,舊的存在
            else if (isDef(oldCh)) {
                removeVnodes(elm, oldCh, 0, oldCh.length - 1);
            }
            //新的子節點不存在,舊的text存在
            else if (isDef(oldVnode.text)) {
                api.setTextContent(elm, '');
            }
       
複製代碼
  1. 若是新節點多是複雜節點而非text節點,則對節點的children進一步diff:先判斷是否存在新節點的children總體新增或刪除的狀況,如果則進行批量更新, 而新舊節點都包含children列表的狀況進行updateChildren處理
  2. 若是新舊節點都是text節點,且二者不一樣則只進行text更新便可

如下介紹updateChildren的核心diff方式,以舊節點oldCh爲當前VirtualDOM狀態,將新節點newCh的變化對oldCh進行更新獲得新的VirtualDOM狀態,並記錄新舊節點的startIndex與endIndex兩端同時比較,這樣會比從單向按順序對比的方式更快獲得diff結果:

  • 當新舊節點的startVnode與endVnode 各自對應相同時,繼續對比,startVnode與endVnode位置各自向中間移動一位。
  • 發現oldStartVnode,newEndVnode相同時,也就是oldStartVnode成爲了新的末端節點,就將oldStartVnode插到oldEndVnode的後一個位置



  • 當oldEndVnode,newStartVnode相同時,也就是oldEndVnode成爲了新的頭部節點,就將oldEndVnode插入到oldStartVnode前一個位置



  • 當發現oldCh裏沒有當前newCh中的節點,將新節點插入到oldStartVnode的前邊,同時這裏會藉助節點中的key值進行map查找是否在其餘位置中有匹配的舊節點,若是有匹配,就對舊節點進行更新,再將其插入到當前的oldStartVnode的前面。


  • 在這一輪對比結束時後,有兩種狀況,當oldStartIdx > oldEndIdx,說明舊節點oldCh已經遍歷完。那麼剩下newStartIdx和newEndIdx之間的vnode的新節點就調用addVnodes,批量插入父節點的before節點位置,before不少時候是爲null的。addVnodes調用的是insertBefore操做dom節點,咱們看看insertBefore的文檔:parentElement.insertBefore(newElement, referenceElement)若是referenceElement爲null則newElement將被插入到子節點的末尾。若是newElement已經在DOM樹中,newElement首先會從DOM樹中移除。因此before爲null,newElement將被插入到子節點的末尾。
  • 若是newStartIdx > newEndIdx,就是newCh先在第一輪對比中遍歷完。此時oldCh中的oldStartIdx和oldEndIdx之間的vnode是須要被刪除的,調用removeVnodes將它們從dom裏刪除。


React的reconcilation

在react的歷史版本中,完成數據節點diff的過程是reconcilation,,當你在一個組件中調用setState時,react會將該組件節點標記爲dirty,進行reconcile並獲得從新構建的子樹virtual-dom,在工做流結束時從新render帶有dirty標記的節點, 若是你是在組件的根節點上進行setState,那麼整個組件樹Virtual DOM都會從新建立,但因爲這並非直接操做真實的DOM,因此實際上產生的影響仍然有限。

在React16的重寫中,最重要的改變時將核心架構改成了代號爲Fiber的異步渲染架構。從本質上看,一個Fiber就是一個POJO對象,一個React Element能夠對應一個或多個Fiber節點,Fiber包含着DOM節點與React組件中的全部工做須要的屬性數據。所以雖然React的代碼中其實沒有明確的Virtual DOM概念,但經過對Fiber的設計充分完成了Virtual DOM的功能與機制。

Fiber除了承擔Virtual DOM的工做以外,它真正設計目的是實現一種在前端執行的輕量執行線程,同普通線程同樣共享定址空間,但卻可以受React自身的Fiber系統調度,實現渲染任務細分,可計時,可打斷,可重啓,可調度的協做式多任務處理的強大渲染任務控制機制。

言歸正傳,儘管Fiber異步渲染的機制幾乎重寫了整個reconcile的過程,但經過源碼分析能夠看到對節點reconcile的思路與16以前版本基本一致:

在react的16.3.1版本中,會在頁面初始化render運行過程當中,對應頁面結構建立FiberNode,經過child屬性與siblings屬性分別存放子節點與兄弟節點,同時使用return屬性標記父節點,便於遍歷與修改。Fiber在update的時候,會從原來的Fiber(咱們稱爲current)clone出一個新的Fiber(稱爲alternate)。兩個Fiber diff出的變化(side effect)記錄在alternate上。因此一個組件在更新時最多會有兩個Fiber與其對應,在更新結束後alternate會取代以前的current的成爲新的current節點。



這裏略過Fiber複雜的構建過程,咱們直接來看在某個組件須要更新時的內部機制,也就是組件中setState方法被調用後,首先會在該組件對應的Fiber節點中設置updateQueue屬性以隊列的形式存儲更新內容,而後從頂端開始對整個Fiber樹開始進行深度遍歷,查找到須要進行更新的Fiber節點,判斷的依據就是該節點是否有updateQueue中的更新內容,若是存在更新,就運行咱們熟知的shouldUpdateComponent函數來判斷,shouldUpdateComponent返回爲真,就執行componentWillUpdate函數,並根據其節點類型決定按哪一種方式進行更新,也就是運行reconcile機制進行diff,若是diff的是component節點,待diff完成以後再運行lifeCycle中的componentDidUpdate函數。

const shouldUpdate = checkShouldComponentUpdate(
      workInProgress,
      oldProps,
      newProps,
      oldState,
      newState,
      newContext,
    );

    if (shouldUpdate) {
      // 【譯】這是爲了支持react-lifecycles-compat的兼容組件
      // 使用新的API的時候不能調用非安全的生命週期鉤子
      if (
        !hasNewLifecycles &&
        (typeof instance.UNSAFE_componentWillUpdate === 'function' ||
          typeof instance.componentWillUpdate === 'function')
      ) {
        //開始計時componentWillUpdate階段
        startPhaseTimer(workInProgress, 'componentWillUpdate');
        //執行組件實例上的componentWillUpdate鉤子
        if (typeof instance.componentWillUpdate === 'function') {
          instance.componentWillUpdate(newProps, newState, newContext);
        }
        if (typeof instance.UNSAFE_componentWillUpdate === 'function') {
          instance.UNSAFE_componentWillUpdate(newProps, newState, newContext);
        }
        //結束計時componentWillUpdate階段
        stopPhaseTimer();
      }
      // 在當前工做中的Fiber打上標籤,後續執行componentDidUpdate鉤子
      if (typeof instance.componentDidUpdate === 'function') {
        workInProgress.effectTag |= Update;
      }
      if (typeof instance.getSnapshotBeforeUpdate === 'function') {
        workInProgress.effectTag |= Snapshot;
      }
    } else {
      // 【譯】若是當前節點已經在更新中,即便咱們終止了更新,仍然應該執行componentDidUpdate鉤子
      if (typeof instance.componentDidUpdate === 'function') {
        if (
          oldProps !== current.memoizedProps ||
          oldState !== current.memoizedState
        ) {
          workInProgress.effectTag |= Update;
        }
      }
      if (typeof instance.getSnapshotBeforeUpdate === 'function') {
        if (
          oldProps !== current.memoizedProps ||
          oldState !== current.memoizedState
        ) {
          workInProgress.effectTag |= Snapshot;
        }
      }
複製代碼


這裏提到,咱們在組件中setState以後,React會將其視爲dirty節點,在事件流結束後,找出dirty的組件節點並進行diff,值得注意的是,雖然從新render構建一顆新的Virtual DOM樹不會觸碰真正的DOM,這裏也並無從新建立新的Fiber樹,取而代之的是在每一個Fiber節點中都設置了alternate屬性與current屬性來分別存放用於更新替代與當前的節點版本,只是在從新遍歷整顆樹後找到dirty的節點生成新的Fiber節點用於更新:



正如react官方文檔中描述的同樣,當一個節點須要被更新時(shouldComponentUpdate),下一步則須要對它及其子節點進行shouldComponentUpdate判斷與Reconcile的過程來對節點進行更新,這裏咱們能夠經過在組件中寫入覆蓋的shouldComponentUpdate函數來決定是否進行更新的邏輯:



Reconcile過程的核心源代碼起始於reconcileChildFiber函數,主要實現方式是:根據傳入組件的類型進行不一樣的reconcile過程,其中最爲複雜的是傳入子組件數組調用reconcileChildrenArray處理的狀況。reconcileChildrenArray函數在開始進行新舊子節點數組reconcile時,默認先按index順序進行對比,因爲Fiber節點自己沒有設置向後指針,所以React目前沒有采起兩端同時對比的算法,也就是說每個同層級別的兄弟Fiber節點只能指向下一個節點。所以在一般狀況下,對比過程當中react只會調用updateSlot將獲得的新Fiber數據按其不一樣類型直接更新到舊Fiber的位置中。

在按順序對比中,若是使用updateSlot未發現key值不相等的狀況,則進行將老節點替換成爲新節點,第一輪遍歷完成後,則判斷若是是新節點已遍歷完成,就將剩餘的老節點批量刪除,若是是老節點遍歷完成仍有新節點剩餘,則將新節點批量插入老節點末端,若是在第一輪遍歷中發現key值不相等的狀況,則直接跳出以上步驟,按照key值進行遍歷更新,最後再刪除沒有被上述狀況涉及的元素,因而可知在列表結構的組件中,添加key值是有助於提高diff算法效率的。

如下是reconcileChildrenArray函數源代碼:

// react使用flow進行類型檢查
function reconcileChildrenArray(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChildren: Array<*>,
    expirationTime: ExpirationTime,
  ): Fiber | null {
    let resultingFirstChild: Fiber | null = null;
    let previousNewFiber: Fiber | null = null;

    let oldFiber = currentFirstChild;
    let lastPlacedIndex = 0;
    let newIdx = 0;
    let nextOldFiber = null;

    for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
      // 沒有采用兩端同時對比,受限於Fiber列表的單向結構
      if (oldFiber.index > newIdx) {
        nextOldFiber = oldFiber;
        oldFiber = null;
      } else {
      // 指向下一個舊的兄弟節點
        nextOldFiber = oldFiber.sibling;
      }
      // 嘗試使用新的Fiber更新舊節點
      const newFiber = updateSlot(
        returnFiber,
        oldFiber,
        newChildren[newIdx],
        expirationTime,
      );
、    //若是在遍歷中發現key值不相等的狀況,則直接跳出第一輪遍歷
      if (newFiber === null) {
        if (oldFiber === null) {
          oldFiber = nextOldFiber;
        }
        break;
      }
      if (shouldTrackSideEffects) {
        if (oldFiber && newFiber.alternate === null) {
         // 【譯】咱們找到了匹配的節點,但咱們並不保留當前的Fiber,因此咱們須要刪除當前的子節點
          deleteChild(returnFiber, oldFiber);
        }
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      // 記錄上一個更新的子節點
      if (previousNewFiber === null) {  
        resultingFirstChild = newFiber;
      } else { 
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
      oldFiber = nextOldFiber;
    }

    if (newIdx === newChildren.length) {
      // 【譯】咱們已經遍歷完了全部的新節點,直接刪除剩餘舊節點
      deleteRemainingChildren(returnFiber, oldFiber);
      return resultingFirstChild;
    }

    if (oldFiber === null) {
      // 若是舊節點先遍歷完,則按順序插入剩餘的新節點,這裏受限於Fiber的結構比較繁瑣
      for (; newIdx < newChildren.length; newIdx++) {
        const newFiber = createChild(
          returnFiber,
          newChildren[newIdx],
          expirationTime,
        );
        if (!newFiber) {
          continue;
        }
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        if (previousNewFiber === null) {
          // TODO: Move out of the loop. This only happens for the first run.
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
      return resultingFirstChild;
    }

    // 【譯】把子節點都設置快速查找的map映射集
    const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

    // 【譯】使用map查找須要保存或刪除的節點
    for (; newIdx < newChildren.length; newIdx++) {
      // 按map查找並建立新的Fiber
      const newFiber = updateFromMap(
        existingChildren,
        returnFiber,
        newIdx,
        newChildren[newIdx],
        expirationTime,
      );
      if (newFiber) {
        if (shouldTrackSideEffects) {
          if (newFiber.alternate !== null) {
            // 【譯】新的Fiber也是一個工做線程,可是若是已有當前的實例,那咱們就能夠複用這個Fiber,
            // 咱們要從列表中刪除這個新的,避免準備複用的Fiber被刪除
            existingChildren.delete(
              newFiber.key === null ? newIdx : newFiber.key,
            );
          }
        }
        // 插入當前更新位置
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        if (previousNewFiber === null) {
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
    }

    if (shouldTrackSideEffects) 
      // 【譯】到此全部剩餘的子節點都將被刪除,加入刪除隊列
      existingChildren.forEach(child => deleteChild(returnFiber, child));
    }
    //最終返回Fiber子節點列表的第一個節點
    return resultingFirstChild;
  }
複製代碼


結束語:

VirtualDOM的設計是提高前端渲染性能的有效方案,也所以提供了以數據爲驅動的前端框架工具的基礎,將咱們從DOM的繁瑣操做中解放出來,不一樣的VirtualDOM方案在diff方面基本基於三條diff原則,具體diff過程則考慮自身運行上下文中的數據結構,算法效率,組件生命週期與設計來選擇diff實現。例如上文snabbdom的updateChildren執行中使用了兩端同時對比以及根據位置順序進行移動的更新策略,而React則受限於Fiber的單向結構採用按順序直接替換的方式更新,但React優化的組件設計與Fiber的工做線程機制在總體渲染性能方面帶來了效率提高,同時二者都提供了基於key值進行diff的策略改善方式。

VirtualDOM的設計影響深遠,本文僅對VirtualDOM中的思想與diff實現進行了詳細介紹,此外,如何建立一個VirtualDOM樹,如何將diff結果進行patch更新等內容仍有許多不一樣的具體實現方式能夠進行探索,以及React16的Fiber機制更是在異步渲染方面上又進了一步,值得咱們持續關注與學習。


參考閱讀

diff算法類:

snabbdom源碼

React-less Virtual DOM with Snabbdom :functions everywhere!

解析 snabbdom 源碼,教你實現精簡的 Virtual DOM 庫

React’s diff algorithm

Snabbdom - a Virtual DOM Focusing on Simplicity - Interview with Simon Friis Vindum

去哪兒網迷你React的研發心得


Fiber介紹類

React Fiber Architecture

如何理解 React Fiber 架構?

React 16 Fiber源碼速覽

How React Fiber can make your web and mobile apps smoother and more responsive

React的新引擎—React Fiber是什麼?

相關文章
相關標籤/搜索