VirtualDOM與DomDiff淺談

前端開發大本營發表於2019-03-04

什麼是VirtualDOM

virtual dom(虛擬DOM),也就是虛擬節點。它通過JS的Object物件模擬DOM中的節點,然後再通過特定的render方法將其渲染成真實的DOM節點。

我們可以通過creatElement的API來建立虛擬DOM。我們模擬的虛擬DOM主要包括三部分:元素型別、元素屬性、虛擬子節點,其中虛擬子節點又是通過creatElement模擬出來的具有那三個主要部分的虛擬節點。

VirtualDOM與DomDiff淺談


為什麼引入虛擬DOM

JQuery橫霸天下的時候,我們都知道頻繁操作大量DOM會產生巨大的效能損耗,因為操作DOM會引起頁面的迴流或者重繪,操作大量dom也很費時,請看下面的程式碼,操作很簡單,建立一個空的div標籤,迴圈遍歷其中的屬性並將其拼列印出來

var div = document.createElement('div')
    var item ,result = ''
    for (item in div) {
      result += ' | ' + item
    }
    console.log(result)
複製程式碼

VirtualDOM與DomDiff淺談

這麼多的屬性,更何況這還只是一級屬性,可見直接操作大量dom屬性所帶來的損耗也是很不樂觀的,而且JavaScript操作DOM進行重繪整個檢視層的時候也是相當消耗效能的,如何規避這些問題呢?SO,虛擬DOM就可以幫助我們解決上面的問題。通過虛擬DOM我們不需要操作真實DOM,只需要操作JavaScript物件模擬出來的節點,通過對這些虛擬節點進行修改,修改以後經過diff演算法得出一些需要修改的最小單位,再將這些最小單位的檢視進行更新。這樣做減少了很多不必要的DOM操作,大大提高了效能。“使用虛擬DOM會更快”這句話並不一定適用於所有場景。例如:一個頁面就有一個按鈕,點選一下,數字加一,那肯定是直接操作DOM更快。使用虛擬DOM無非白白增加了計算量和程式碼量。即使是複雜情況,瀏覽器也會對我們的DOM操作進行優化,大部分瀏覽器會根據我們操作的時間和次數進行批量處理,所以直接操作DOM也未必很慢。大家應該根據具體場景對症下藥。

什麼是DomDiff

dom diff則是通過JS層面的計算,返回一個patch物件,即補丁物件,在通過特定的操作解析patch物件,完成頁面的重新渲染。

DomDiff是進行虛擬節點Element的對比,並返回一個pathchs物件,即補丁物件,用來儲存兩個節點改變的地方,最後用pathchs記錄的內容去區域性更新Dom。

VirtualDOM與DomDiff淺談

DomDiff的三種優化策略

VirtualDOM與DomDiff淺談

第一種更新的時候只比較平級虛擬節點,依次進行比較並不會跨級比較,比較差異部分,生成對應補丁包,根據補丁包改變的內容更新差異的dom

第二種更新的時候平級比較兩個虛擬DOM,當發現節點已經不存在,則該節點及其子節點會被完全刪除掉,不會用於進一步的比較。如果該刪除的節點之下有子節點,那麼這些子節點也會被完全刪除,它們也不會用於後面的比較。這樣只需要對樹進行一次遍歷,便能完成整個DOM樹的比較。新增的節點,會對應的建立一個新的節點

第三種更新的時候平級比較兩個虛擬DOM,如果只是一層變了,互換了位置,那麼它會複用此虛擬節點,把對應的位置互換一下即可,這個是通過給對應元素新增的key不同來實現的

兩個樹如果完全比較的話需要時間複雜度為O(n^3),如果對O(n^3)不太清楚的話建議去網上搜尋資料。而在Diff演算法中因為考慮效率的問題,只會對同層級元素比較,時間複雜度則為O(n),也就是深度遍歷,並比較同層級的節點。

實現DomDiff的思路

domDiff的差異演算法採用先序深度優先遍歷,其主要分為以下四步:

  1. 用JS物件模擬真實DOM
  2. 把此虛擬DOM轉換成真實DOM插入頁面中
  3. 如果有事件發生修改了虛擬DOM,會比較兩棵虛擬DOM樹的差異,得到差異物件,即補丁包
  4. 把差異物件應用到真正的DOM樹上
以平級刪除節點為例,流程如下圖所示:

VirtualDOM與DomDiff淺談

先把真實DOM對映為虛擬DOM,這個虛擬DOM代表著真實DOM,當虛擬DOM變化時,例如上圖,它的第三個p和第二個p中的son2被刪除了,這個時候我們會根據前後的變化計算出一個差異物件patches。根據索引我們可以找到那個節點生成對應索引元素的補丁包patches。patches記錄了改變的內容,這裡type是remove,代表刪除。根據patches中每一項的索引去對應的位置修改老的DOM節點,完成差異部分的更新。

程式碼實現

首先,我們建立element.js,建立虛擬Dom元素類Element,createElement用於虛擬節點的建立,setAttr用於屬性的掛載,render方法可以把虛擬Dom轉換成真實Dom,renderDom將元素插入頁面內

程式碼如下:

// 虛擬DOM元素的類
class Element {
  constructor(type, props, children) {
    this.type = type;
    this.props = props;
    this.children = children;
  }
}
// 設定屬性
function setAttr(node,key,value){
  switch (key) {
    case 'value': // node是一個input或者textarea
      if(node.tagName.toUpperCase() === 'INPUT'|| node.tagName.toUpperCase()=== 'TEXTAREA'){
        node.value = value;
      }else{
        node.setAttribute(key,value);
      }
      break;
    case 'style':
      node.style.cssText = value;
      break;
    default:
      node.setAttribute(key, value);
      break;
  }
}
// 返回虛擬節點的 返回object的
function createElement(type, props, children) {
  return new Element(type, props, children);
};
// render方法可以將vnode轉化成真實dom
function render(eleObj) {
  let el = document.createElement(eleObj.type);
  for(let key in eleObj.props){
     // 設定屬性的方法
    setAttr(el, key, eleObj.props[key]);
  }
  // 遍歷兒子 如果是虛擬dom繼續渲染,不是就代表的是文字節點
  eleObj.children.forEach(child=>{
    child = (child instanceof Element)?render(child):document.createTextNode(child);
    el.appendChild(child);
  })
  return el;
}
// 將元素插入到頁面內
function renderDom(el,target) {
  target.appendChild(el);
}
export { createElement, render, Element, renderDom}複製程式碼

其次,我們建立一個index.js檔案,建立舊虛擬Dom節點vertualDom1,並把它渲染到頁面上,建立新虛擬Dom節點vertualDom2,代表更新後的vertualDom1,匯入diff,根據diff演算法計算出補丁物件,匯入patch,給變化的元素打補丁,更新檢視

程式碼如下:

import { createElement, render,renderDom} from './element';
import diff from './diff';
import patch from './patch';
//舊虛擬Dom樹
let vertualDom1 = createElement('ul', { class: 'list' }, [
  createElement('li', { class: 'item' }, ['1']),
  createElement('li', { class: 'item' }, ['2'])
]);
//新虛擬Dom樹
let vertualDom2 = createElement('ul', { class: 'list-group' }, [
  createElement('li', { class: 'item' }, ['2']),
  createElement('li', { class: 'item' }, ['1']),
  createElement('li', { class: 'item' }, ['1']),
]);

// 如果平級元素有互換 那會導致沖洗渲染
// 新增節點也不會被更新
// index

//將虛擬dom轉化成真實dom渲染到頁面上
let el = render(vertualDom1);
renderDom(el, window.root);

//diff 入口,比較新舊兩棵樹的差異,構建出差異的補丁包
let patches = diff(vertualDom1, vertualDom2)

//找到對應的真實dom,給它打補丁,進行部分渲染更新檢視
patch(el, patches);
複製程式碼

再其次,就是核心部分diff演算法了,採用先序深度優先遍歷演算法在兩棵虛擬Dom樹平級位置做比較,同時用補丁的形式記錄需要更新的內容。

type不一致直接替換當前節點以及當前節點下的子節點; 如果兩個父節點一致,則從左往後遍歷子節點,若子節點一致,遍歷子節點下的子節點,依次遞迴。

VirtualDOM與DomDiff淺談

補丁包定義規則:
1. 節點型別相同,屬性不同(type: 'ATTRS', attrs)
2. 新的節點不存在,被刪除了 (type: 'REMOVE', index: xxxx)
3. 節點型別不同/新增 (type: 'REPLACE', newNode)
4. 僅僅是文字變化(type: 'TEXT', text)
複製程式碼

我們建立diff.js ,程式碼如下:

//oldTree:舊虛擬Dom樹,newTree:新虛擬Dom樹,diff 入口,比較新舊兩棵樹的差異
function diff(oldTree,newTree) {
  let patches = {}//補丁包,記錄節點差異
  let index = 0;//當前所在樹的第幾層
  // 遞迴樹 比較後的結果放到補丁包種
  walk(oldTree,newTree,index,patches);
  return patches;//將差異物件返回
}
//判斷兩個節點的屬性差異
function diffAttr(oldAttrs,newAttrs) {
    let patch = {};//記錄差異
    // 判斷老的屬性種和新的屬性的關係
    for(let key in oldAttrs){
      if(oldAttrs[key] !== newAttrs[key]){//二者不相等代表屬性更新了
        patch[key] = newAttrs[key]; // 有可能時undefined,將更新的新屬性存入patch
      }
    }
    for(let key in newAttrs){
      // 老節點沒有新節點的屬性,進行新增到patch中
      if(!oldAttrs.hasOwnProperty(key)){
        patch[key] = newAttrs[key];
      }
    }
    return patch;//將差異返回
}
//常量代表差異的型別
const ATTRS = 'ATTRS';//屬性
const TEXT = 'TEXT';//文字
const REMOVE = 'REMOVE';//刪除
const REPLACE = 'REPLACE'//替換
let Index = 0;//基於原有的一個遞增序號來遍歷
//如果有子節點,判斷孩子節點,呼叫walk
function diffChildren(oldChildren,newChildren,patches) {
  // 比較老的第一個和新的第一個
  oldChildren.forEach((child,idx) => {
    // 索引不應該是index了 ------------------
    // index 每次傳遞給waklk時 index是遞增的,所有的人都基於一個序號來實現
    walk(child, newChildren[idx], ++Index,patches);
  });
}
//判斷內容是不是字串
function isString(node) {
  return Object.prototype.toString.call(node) === '[object String]';
}
// 遞迴樹,記錄舊虛擬節點、新虛擬節點的差異,把差異放到補丁包currentPatch中,根據索引標識了哪些節點被替換、刪除、新增、文字更新了、還//是屬性更新了,inde被私有化到了walk作用域內。
function walk(oldNode,newNode,index,patches) {
  let currentPatch = []; // 每個元素都有一個補丁物件,存放當前層的差異對比
  if(!newNode){//如果節點不存在,徹底刪除此節點包括其下的所有子節點
    currentPatch.push({ type: REMOVE, index })
  }else if(isString(oldNode)&&isString(newNode)){ // 判斷文字是否一致
    if(oldNode !== newNode){//如果發現文字不同,currentPatch會記錄一個差異
      currentPatch.push({ type: TEXT,text:newNode});
    }
  }else if(oldNode.type === newNode.type){//如果發現兩個節點一樣 則去判斷節點是屬性是否一樣,並記錄下來
    // 比較屬性是否有更改
    let attrs = diffAttr(oldNode.props,newNode.props);
    if(Object.keys(attrs).length>0){//有屬性差異則把差異記錄下來
      currentPatch.push({ type: ATTRS,attrs})
    }
    // 如果有兒子節點 遍歷兒子
    diffChildren(oldNode.children,newNode.children,patches);
  }else{
    // 說明節點不一樣,比如型別不同等,直接進行替換
    currentPatch.push({ type: REPLACE, newNode });
  }
  if(currentPatch.length>0){ // 當前元素確實有補丁
    // 將元素和補丁對應起來 放到大補丁包中
    patches[index] = currentPatch;
  }
}
export default diff;複製程式碼

最後,建立patch.js, 遍歷補丁物件,將補丁的差異物件與真實DOM節點對應起來,並將差異更新到真實DOM節點中,更新頁面的DOM節點,更新檢視。

程式碼如下:

import {Element,render} from './element';
let allPathes;//不用傳來傳去,直接公用這個大補丁包
let index = 0; // 預設哪個需要打補丁
function patch(node,patches) {
  allPathes = patches;//把補丁包賦給allPathes
  //遞迴樹,給元素打補丁
  walk(node);
  // 給某個元素打補丁
}
//遞迴樹,
function walk(node) {
  //根據索引一層層取對應元素的補丁差異物件
  let currentPatch = allPathes[index++];
  let childNodes = node.childNodes;//拿出子節點
  childNodes.forEach(child =>walk(child));//深度先序遍歷,如果有子節點,遞迴呼叫
  if(currentPatch){//補丁存在,給對應元素打對應差異補丁,加補丁是後序的,先給子節點打再給父節點打補丁
    doPatch(node, currentPatch);
  }
}
//掛載屬性
function setAttr(node, key, value) {
  switch (key) {
    case 'value': // node是一個input或者textarea
      if (node.tagName.toUpperCase() === 'INPUT' || node.tagName.toUpperCase() === 'TEXTAREA') {
        node.value = value;
      } else {
        node.setAttribute(key, value);
      }
      break;
    case 'style':
      node.style.cssText = value;
      break;
    default:
      node.setAttribute(key, value);
      break;
  }
}
//給對應元素打對應差異補丁,加補丁是後序的,先給子節點打再給父節點打補丁,根據patch.type型別不同,進行不同的操作function doPatch(node,patches) {
  patches.forEach(patch=>{
    switch (patch.type) {
      case 'ATTRS'://型別相同,屬性不同,更新屬性
        for(let key in patch.attrs){
          let value = patch.attrs[key];
          if(value){
            setAttr(node, key, value);
          }else{
            node.removeAttribute(key);
          }
        }
        break;
      case 'TEXT'://僅僅是文字不同,更新文字
        node.textContent = patch.text;
        break;
      case 'REPLACE'://型別不同/新增進行替換
        let newNode = (patch.newNode instanceof Element) ? render(patch.newNode) : document.createTextNode(patch.newNode);//newNode是元素,render渲染真實元素,否則是文字,建立文字
        node.parentNode.replaceChild(newNode,node);//替換
        break;
      case 'REMOVE'://刪除節點
        node.parentNode.removeChild(node);
        break;
      default:
        break;
    }
  });
}
export default patch;複製程式碼

上面模擬的VirtualDOM與DomDiff沒有解決1)平級元素如果有呼喚會導致重新渲染2)新增節點也不會被更新的問題等等,這只是diff演算法的一個簡易實現,還存在一些複雜情況處理的情況以及還有很多演算法上面優化的方案,這裡讓大家大概瞭解了diff演算法的原理。

總結,Virture Dom與DOM Diff演算法簡化了UI開發的複雜度,也優化了大量操作DOM所帶來的效能損耗。通過虛擬DOM,我們不需要操作真實DOM,只需要操作JavaScript物件模擬出來的節點,通過對這些虛擬節點進行修改,修改以後經過diff演算法得出一些需要修改的最小單位,再將這些最小單位的檢視進行更新。它的出現無疑具有重要的意義,值得學習研究!

如有筆誤或者其他實現不對的地方,還望大家指出,謝謝!