虛擬Dom詳解 - (二)

第一篇文章中主要講解了虛擬DOM基本實現,簡單的回顧一下,虛擬DOM是使用json數據描述的一段虛擬Node節點樹,通過render函數生成其真實DOM節點。並添加到其對應的元素容器中。在創建真實DOM節點的同時併爲其註冊事件並添加一些附屬屬性。

虛擬Dom詳解 - (一)

在上篇文章中也曾經提到過,當狀態變更的時候用修改後的新渲染的的JavaScript對象和舊的虛擬DOMJavaScript對象作對比,記錄着兩棵樹的差異,把差別反映到真實的DOM結構上最後操作真正的DOM的時候只操作有差異的部分的更改。然而上篇文章中也只是簡簡單單的提到過一句卻沒有進行實質性的實現,這篇文章主要講述一下虛擬DOM是如何做出更新的。那就開始吧...O(∩_∩)O

在虛擬DOM中實現更新的話是使用DIFF算法進行更新的,我想大多數小夥伴都應該聽說過這個詞,DIFF是整個虛擬DOM部分最核心的部分,因爲當虛擬DOM節點狀態發生改變以後不可能去替換整個DOM節點樹,若是這樣的話會出現打兩個DOM操作,無非是對性能的極大影響,真的如此的話還不如直接操作DOM來的實際一些。

第一篇文章中是通過render對虛擬DOM節點樹進行渲染的,但是在render函數中只做了一件事情,只是對虛擬DOM進行了新建也就是初始化工作,其實回過頭來想一下,無論是新建操作還是修改操作,都應該通過render函數來做,在react中所有的DOM渲染都是通過其中的render函數完成的,那麼也就得出了這個結論。

//  渲染虛擬DOM
//    虛擬DOM節點樹
//    承載DOM節點的容器,父元素
function render(vnode,container) {
  //  首次渲染
  mount(vnode,container);
};

既然更新和創建操作都是通過render函數來做的,在方法中又應該如何區分當前的操作到底是新建還是更新呢?畢竟在react我們並沒有給出明確的標識來告訴其方法,當前是進行的哪個操作。在執行render函數的時候有兩個參數,一個是傳入的vnode節點樹,還有一個就是承載真實DOM節點的容器,其實我們可以把其虛擬DOM節點樹掛載在其容器中,若容器中存在其節點樹則是更新操作,反之則是新建操作。

//  渲染虛擬DOM
//    虛擬DOM節點樹
//    承載DOM節點的容器,父元素
function render(vnode, container) {

  if (!container.vnode) {
    //  首次渲染
    mount(vnode, container);
  } else {
    //  舊的虛擬DOM節點
    //  新的DOM節點
    //  承載DOM節點的容器
    patch(container.vnode, vnode, container);
  }
  container.vnode = vnode;
};

既然已經確定了現在的render函數所需要進行的操作了,那麼接下來就應該進行下一步操作了,如果想要做更新的話必須要知道如下幾個參數,原有的虛擬DOM節點是什麼樣的,新的虛擬DOM又是什麼樣的,上一步操作中我們已經把原有的虛擬DOM節點已經保存在了父容器中,直接使用即可。

//  更新函數
//    舊的虛擬DOM節點
//    新的DOM節點
//    承載DOM節點的容器
function patch(oldVNode, newVNode, container) {
  //  新節點的VNode類型
  let newVNodeFlag = newVNode.flag;
  //  舊節點的VNode類型
  let oldVNodeFlag = oldVNode.flag;
  //  如果新節點與舊節點的類型不一致
  //  如果不一致的情況下,相當於其節點發生了變化
  //  直接進行替換操作即可
  //  這裏判斷的是如果一個是 TEXT 一個是 Element
  //  類型判斷
  if (newVNodeFlag !== oldVNodeFlag) {
    replaceVNode(oldVNode, newVNode, container);
  }
  //  由於在新建時創建Element和Text的時候使用的是兩個函數進行操作的
  //  在更新的時候也是同理的
  //  也應該針對不同的修改進行不同的操作
  //  如果新節點與舊節點的HTML相同
  else if (newVNodeFlag == vnodeTypes.HTML) {
    //  替換元素操作
    patchMethos.patchElement(oldVNode, newVNode, container);
  }
  //  如果新節點與舊節點的TEXT相同
  else if (newVNodeFlag == vnodeTypes.TEXT) {
    //  替換文本操作
    patchMethos.patchText(oldVNode, newVNode, container);
  }
}
//  更新VNode方法集
const patchMethos = {
    //  替換文本操作
    //    舊的虛擬DOM節點
    //    新的DOM節點
    //    承載DOM節點的容器
    patchText(oldVNode,newVNode,container){
        //  獲取到el,並將 oldVNode 賦值給 newVNode
        let el = (newVNode.el = oldVNode.el);
        //  如果 newVNode.children 不等於 oldVNode.children
        //  其他情況就是相等則沒有任何操作,不需要更新
        if(newVNode.children !== oldVNode.children){
            //  直接進行替換操作
            el.nodeValue = newVNode.children;
        }
    }
};
//  替換虛擬DOM
function replaceVNode(oldVNode, newVNode, container) {
  //  在原有節點中刪除舊節點
  container.removeChild(oldVNode.el);
  //  重新渲染新節點
  mount(newVNode, container);
}

上述方法簡單的實現了對Text更新的一個替換操作,由於Text替換操作比較簡單,所以這裏就先實現,僅僅完成了對Text的更新是遠遠不夠的,當Element進行操作的時也是需要更新的。相對來說Text的更新要比Element更新要簡單很多的,Element更新比較複雜所以放到了後面,因爲比較重要嘛,哈哈~

首先想要進行Element替換之前要確定哪些Data數據進行了變更,然後才能對其進行替換操作,這樣的話需要確定要更改的數據,然後替換掉原有數據,才能進行下一步更新操作。

//  更新VNode方法集
const patchMethos = {
    //  替換元素操作
    //    舊的虛擬DOM節點
    //    新的DOM節點
    //    承載DOM節點的容器
    patchElement(oldVNode,newVNode,container){
        //  如果 newVNode 的標籤名稱與 oldVNode 標籤名稱不一樣
        //  既然標籤都不一樣則直接替換就好了,不需要再進行其他多餘的操作
        if(newVNode.tag !== oldVNode.tag){
            replaceVNode(oldVNode,newVNode,container);
            return;
        }
        //  更新el
        let el = (newVNode.el = oldVNode.el);
        //  獲取舊的Data數據
        let oldData = oldVNode.data;
        //  獲取新的Data數據
        let newData = newVNode.data;
        //  如果新的Data數據存在
        //  進行更新和新增
        if(newData){
            for(let attr in newData){
                let oldVal = oldData[attr];
                let newVal = newData[attr];
                domAttributeMethod.patchData(el,attr,oldVal,newVal);
            }
        }
        //  如果舊的Data存在
        //  檢測更新
        if(oldData){
            for(let attr in oldData){
                let oldVal = oldData[attr];
                let newVal = newData[attr];
                //  如果舊數據存在,新數據中不存在
                //  則表示已刪除,需要進行更新操作
                if(oldVal && !newVal.hasOwnProperty(attr)){
                    //  既然新數據中不存在,則新數據則傳入Null
                    domAttributeMethod.patchData(el,attr,oldVal,null);
                }
            }
        }
    }
};
//  dom添加屬性方法
const domAttributeMethod = {
  //  修改Data數據方法
  patchData (el,key,prv,next){
    switch(key){
      case "style":
        this.setStyle(el,key,prv,next);
        //  添加了這裏,看我看我  (●'◡'●)
        //  添加遍歷循環
        //  循環舊的data
        this.setOldVal(el,key,prv,next);
        break;
      case "class":
        this.setClass(el,key,prv,next);
        break;
      default :
        this.defaultAttr(el,key,prv,next);
        break;
    }
  },
  //  遍歷舊數據
  setOldVal(el,key,prv,next){
    //  遍歷舊數據
    for(let attr in prv){
        //  如果舊數據存在,新數據中不存在
        if(!next.hasOwnProperty(attr)){
            //  直接賦值爲字符串
            el.style[attr] = "";
        }
    }
  },
  //  修改事件註冊方法
  addEvent(el,key,prev,next){
    //  添加了這裏,看我看我  (●'◡'●)
    //  prev 存在刪除原有事件,重新綁定新的事件
    if(prev){
      el.removeEventListener(key.slice(1),prev);
    }
    if(next){
      el.addEventListener(key.slice(1),next);
    }
  }
}

上面的操作其實只是替換Data部分,但是其子元素沒有進行替換,所以還需要對子元素進行替換處理。替換子元素有共分爲6種情況:

  1. 舊元素只有一個
  2. 舊元素爲空
  3. 舊元素爲多個
  4. 新元素只有一個
  5. 新元素爲空
  6. 新元素爲多個
//  更新VNode方法集
const patchMethos = {
    //  替換元素操作
    //    舊的虛擬DOM節點
    //    新的DOM節點
    //    承載DOM節點的容器
    patchElement(oldVNode,newVNode,container){
        //  如果 newVNode 的標籤名稱與 oldVNode 標籤名稱不一樣
        //  既然標籤都不一樣則直接替換就好了,不需要再進行其他多餘的操作
        if(newVNode.tag !== oldVNode.tag){
            replaceVNode(oldVNode,newVNode,container);
            return;
        }
        //  更新el
        let el = (newVNode.el = oldVNode.el);
        //  獲取舊的Data數據
        let oldData = oldVNode.data;
        //  獲取新的Data數據
        let newData = newVNode.data;
        //  如果新的Data數據存在
        //  進行更新和新增
        if(newData){
            for(let attr in newData){
                let oldVal = oldData[attr];
                let newVal = newData[attr];
                domAttributeMethod.patchData(el,attr,oldVal,newVal);
            }
        }
        //  如果舊的Data存在
        //  檢測更新
        if(oldData){
            for(let attr in oldData){
                let oldVal = oldData[attr];
                let newVal = newData[attr];
                //  如果舊數據存在,新數據中不存在
                //  則表示已刪除,需要進行更新操作
                if(oldVal && !newVal.hasOwnProperty(attr)){
                    //  既然新數據中不存在,則新數據則傳入Null
                    domAttributeMethod.patchData(el,attr,oldVal,null);
                }
            }
        }
        //  添加了這裏
        //  更新子元素
        //      舊子元素類型
        //      新子元素類型
        //      舊子元素的children
        //      新子元素的children
        //      el元素,容器
        this.patchChildren(
            oldVNode.childrenFlag,
            newVNode.childrenFlag,
            oldVNode.children,
            newVNode.children,
            el,
        );

    },
    //  更新子元素
    //      舊子元素類型
    //      新子元素類型
    //      舊子元素的children
    //      新子元素的children
    //      el元素,容器
    patchChildren(...arg){
        let [oldChildrenFlag,newChildrenFlag,oldChildren,newChildren,container] = arg;
        switch(oldChildrenFlag){
            //  如果舊元素的子元素爲一個
            case childTeyps.SINGLE:
                this.upChildSingle(...arg);
                break;
            //  如果舊元素的子元素爲空
            case childTeyps.EMPTY:
                this.upChildEmpty(...arg);
                break;
            //  如果舊元素的子元素爲多個
            case childTeyps.MULTIPLE:
                this.upChildMultiple(...arg);
                break;
        }
    },

    upChildSingle(...arg){
        let [oldChildrenFlag,newChildrenFlag,oldChildren,newChildren,container] = arg;
        //  循環新的子元素
        switch(newChildrenFlag){
            //  如果新元素的子元素爲一個
            case childTeyps.SINGLE:
                patch(oldChildren,newChildren,container);
                break;
            //  如果新元素的子元素爲空
            case childTeyps.EMPTY:
                container.removeChild(oldChildren.el);
                break;
            //  如果新元素的子元素多個
            case childTeyps.MULTIPLE:
                container.removeChild(oldChildren.el);
                for(let i = 0;i<newChildren.length;i++){
                    mount(newChildren[i],container);
                }
                break;
        }
    },

    upChildEmpty(...arg){
        let [oldChildrenFlag,newChildrenFlag,oldChildren,newChildren,container] = arg;
        //  循環新的子元素
        switch(newChildrenFlag){
            //  如果新元素的子元素爲一個
            case childTeyps.SINGLE:
                mount(newChildren,container);
                break;
            //  如果新元素的子元素爲空
            case childTeyps.EMPTY:
                break;
            //  如果新元素的子元素多個
            case childTeyps.MULTIPLE:
                container.removeChild(oldChildren.el);
                for(let i = 0;i<newChildren.length;i++){
                    mount(newChildren[i],container);
                }
                break;
        }
    },

    upChildMultiple(...arg){
        let [oldChildrenFlag,newChildrenFlag,oldChildren,newChildren,container] = arg;
        //  循環新的子元素
        switch(newChildrenFlag){
            //  如果新元素的子元素爲一個
            case childTeyps.SINGLE:
                for(let i = 0;i<oldChildren.length;i++){
                    container.removeChild(oldChildren[i].el);
                }
                mount(newChildren,container);
                break;
            //  如果新元素的子元素爲空
            case childTeyps.EMPTY:
                for(let i = 0;i<oldChildren.length;i++){
                    container.removeChild(oldChildren[i].el);
                }
                break;
            //  如果新元素的子元素多個
            case childTeyps.MULTIPLE:
                //  **
                //  暫時擱置 這裏是所有節點的對比
                //  **
                break;
        }
    }

};

上面代碼比較亂,因爲嵌套了多層循環,大致邏輯就是使用上述六種情況一一對接配對並且使用其對應的解決方案。

上述六中情況,switch匹配邏輯:

新數據 舊數據
舊元素只有一個 新元素只有一個
舊元素只有一個 新元素爲空
舊元素只有一個 新元素爲多個
舊元素爲空 新元素只有一個
舊元素爲空 新元素爲空
舊元素爲空 新元素爲多個
舊元素爲多個 新元素只有一個
舊元素爲多個 新元素爲空
舊元素爲多個 新元素爲多個

最爲複雜的就是最後一種情況,新舊元素各爲多個,然而對於這一部分reactvue的處理方式都是不一樣的。以下借鑑的是reactdiff算法。

在進行虛擬DOM替換時,當元素之間的順序沒有發生變化則原有元素是不需要進行任何改動的,也就是說,若原有順序是123456,新順序爲654321則他們之間的順序發生了變化這個時候需要對其進行變更處理,若其順序出現了插入情況192939495969在每個數字後面添加了一個9,其實這個時候也是不需要進行更新操作的,其實他們之間的順序還是和原來一致,只是添加了一些元素值而已,如果變成了213456,這是時候只需要改變12就好,其他的是不需要做任何改動的。 接下來需要添加最關鍵的邏輯了。

//  更新VNode方法集
//  添加 oldMoreAndNewMore 方法
const patchMethos = {
  upChildMultiple(...arg) {
    let [oldChildrenFlag, newChildrenFlag, oldChildren, newChildren, container] = arg;
    //  循環新的子元素
    switch (newChildrenFlag) {
      //  如果新元素的子元素爲一個
      case childTeyps.SINGLE:
        for (let i = 0; i < oldChildren.length; i++) {
          // 遍歷刪除舊元素
          container.removeChild(oldChildren[i].el);
        }
        // 添加新元素
        mount(newChildren, container);
        break;
      //  如果新元素的子元素爲空
      case childTeyps.EMPTY:
        for (let i = 0; i < oldChildren.length; i++) {
          // 刪除所有子元素  
          container.removeChild(oldChildren[i].el);
        }
        break;
      //  如果新元素的子元素多個
      case childTeyps.MULTIPLE:
        // 修改了這裏 (●'◡'●) 
        this.oldMoreAndNewMore(...arg);
        break;
  },
  oldMoreAndNewMore(...arg) {
    let [oldChildrenFlag, newChildrenFlag, oldChildren, newChildren, container] = arg;
    let lastIndex = 0;
    for (let i = 0; i < newChildren.length; i++) {
      let newVnode = newChildren[i];
      let j = 0;
      //  新的元素是否找到
      let find = false;
      for (; j < oldChildren.length; j++) {
        let oldVnode = oldChildren[j];
        //  key相同爲同一個元素
        if (oldVnode.key === newVnode.key) {
          find = true;
          patch(oldVnode, newVnode, container);
          if (j < lastIndex) {
            if(newChildren[i-1].el){
              //  需要移動
              let flagNode = newChildren[i-1].el.nextSibling;
              container.insertBefore(oldVnode.el, flagNode);
            }
            break;
          }
          else {
            lastIndex = j;
          }
        }
      }
      // 如果沒有找到舊元素,需要新增
      if (!find) {
        // 需要插入的標誌元素
        let flagNode = i === 0 ? oldChildren[0].el : newChildren[i-1].el;
        mount(newVnode, container, flagNode);
      }
      //  移除元素
      for (let i = 0; i < oldChildren.length; i++) {
        //  舊節點
        const oldVNode = oldChildren[i];
        //  新節點key是否在舊節點中存在
        const has = newChildren.find(next => next.key === oldVNode.key);
        if (!has) {
          // 如果不存在刪除
          container.removeChild(oldVNode.el)
        }
      }
    }
  }
};
//  修改mount函數
//      flagNode    標誌node 新元素需要插入到哪裏
function mount(vnode, container, flagNode) {
  //  所需渲染標籤類型
  let { flag } = vnode;
  //  如果是節點
  if (flag === vnodeTypes.HTML) {
    //  調用創建節點方法
    mountMethod.mountElement(vnode, container, flagNode);
  } //  如果是文本
  else if (flag === vnodeTypes.TEXT) {
    //  調用創建文本方法
    mountMethod.mountText(vnode, container);
  };
};
//  修改mountElement
const mountMethod = {
  //  創建HTML元素方法
  //    修改了這裏 (●'◡'●) 添加 flagNode 參數
  mountElement(vnode, container, flagNode) {
    //  屬性,標籤名,子元素,子元素類型
    let { data, tag, children, childrenFlag } = vnode;
    //  創建的真實節點
    let dom = document.createElement(tag);
    //  添加屬性
    data && domAttributeMethod.addData(dom, data);
    //  在VNode中保存真實DOM節點
    vnode.el = dom;
    //  如果不爲空,表示有子元素存在
    if (childrenFlag !== childTeyps.EMPTY) {
      //  如果爲單個元素
      if (childrenFlag === childTeyps.SINGLE) {
        //  把子元素傳入,並把當前創建的DOM節點以父元素傳入
        //  其實就是要把children掛載到 當前創建的元素中
        mount(children, dom);
      } //  如果爲多個元素
      else if (childrenFlag === childTeyps.MULTIPLE) {
        //  循環子節點,並創建
        children.forEach((el) => mount(el, dom));
      };
    };
    //  添加元素節點  修改了這裏 (●'◡'●)
    flagNode ? container.insertBefore(dom, flagNode) : container.appendChild(dom);
  }
}

最終使用:

const VNODEData = [
    "div",
    {id:"test",key:789},
    [
      createElement("p",{
        key:1,
        style:{
          color:"red",
          background:"pink"
        }
      },"節點一"),
      createElement("p",{
        key:2,
        "@click":() => console.log("click me!!!")
      },"節點二"),
      createElement("p",{
        key:3,
        class:"active"
      },"節點三"),
      createElement("p",{key:4},"節點四"),
      createElement("p",{key:5},"節點五")
    ]
];
let VNODE = createElement(...VNODEData);
render(VNODE,document.getElementById("app"));

const VNODEData1 = [
    "div",
    {id:"test",key:789},
    [
      createElement("p",{
        key:6
      },"節點六"),
      createElement("p",{
        key:1,
        style:{
          color:"red",
          background:"pink"
        }
      },"節點一"),
      createElement("p",{
        key:5
      },"節點五"),
      createElement("p",{
        key:2
      },"節點二"),
      createElement("p",{
        key:4
      },"節點四"),
      createElement("p",{
        key:3,
        class:"active"
      },"節點三")
    ]
];

setTimeout(() => {
  let VNODE = createElement(...VNODEData1);
  render(VNODE,document.getElementById("app"));
},1000)

上面代碼用了大量的邏輯來處理其中使用大量計算,會比較兩棵樹之間的同級節點。這樣就徹底的降低了複雜度,並且不會帶來什麼損失。因爲在web應用中不太可能把一個組件在DOM樹中跨層級地去移動。

1.png

在計算中會儘可能的引用之前的元素,進行位置替換,其實無論是React還是Vue在渲染列表的時候需要給其元素賦值一個key屬性,因爲在進行diff算法時,會優先使用其原有元素,進行位置調整,也是對性能優化的一大亮點。

結語

本文也只是對diff算法的簡單實現,也許不能滿足所有要求,React的基本實現原理則是如此,希望這篇文章能對大家理解diff算法有所幫助。

非常感謝大家用這麼長時間來閱讀本文章,文章中代碼篇幅過長,若有錯誤請在評論區指出,我會及時做出改正。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章