深入理解react中的虛擬DOM、diff算法

文章結構:

  • React中的虛擬DOM是什么?
  • 虛擬DOM的簡單實現(diff算法)
  • 虛擬DOM的內部工作原理
  • React中的虛擬DOM與Vue中的虛擬DOM比較

React中的虛擬DOM是什么?

雖然React中的虛擬DOM很好用,但是這是一個無心插柳的結果。

React的核心思想:一個Component拯救世界,忘掉煩惱,從此不再操心界面。

1. Virtual Dom快,有兩個前提

1.1 Javascript很快

Chrome剛出來的時候,在Chrome里跑Javascript非常快,給了其它瀏覽器很大壓力。而現在經過幾輪你追我趕,各主流瀏覽器的Javascript執行速度都很快了。

https://julialang.org/benchmarks/ 這個網站上,我們可以看到,JavaScript語言已經非常快了,和C就是幾倍的關系,和java在同一個量級。所以說,單純的JavaScript還是還是很快的。

1.2 Dom很慢

當創建一個元素比如div,有以下幾項內容需要實現: HTML elementElementGlobalEventHandler。簡單的說,就是插入一個Dom元素的時候,這個元素上本身或者繼承很多屬性如 width、height、offsetHeight、style、title,另外還需要注冊這個元素的諸多方法,比如onfucos、onclick等等。 這還只是一個元素,如果元素比較多的時候,還涉及到嵌套,那么元素的屬性和方法等等就會很多,效率很低。

比如,我們在一個空白網頁的body中添加一個div元素,如下所示:


image

這個元素會掛載默認的styles、得到這個元素的computed屬性、注冊相應的Event Listener、DOM Breakpoints以及大量的properties,這些屬性、方法的注冊肯定是需要h耗費大量時間的。

尤其是在js操作DOM的過程中,不僅有dom本身的繁重,js的操作也需要浪費時間,我們認為js和DOM之間有一座橋,如果你頻繁的在橋兩邊走動,顯然效率是很低的,**如果你的JavaScript操作DOM的方式還非常不合理,那么顯然就會更糟糕了。 **

而 React的虛擬DOM就是解決這個問題的! 雖然它解決不了DOM自身的繁重,但是虛擬DOM可以對JavaScript操作DOM這一部分內容進行優化

比如說,現在你的list是這樣:

<ul>
  <li>0</li>
  <li>1</li>
  <li>2</li>
  <li>3</li>
</ul>

你希望把它變成下面這樣:

<ul>
  <li>6</li>
  <li>7</li>
  <li>8</li>
  <li>9</li>
  <li>10</li>
</ul>

通常的操作是什么

先把0, 1,2,3這些Element刪掉,然后加幾個新的Element 6,7,8,9,10進去,這里面就有4次Element刪除,5次Element添加。共計9次DOM操作

那React的虛擬DOM可以怎么做呢

而React會把這兩個做一下Diff,然后發現其實不用刪除0,1,2,3,而是可以直接改innerHTML,然后只需要添加一個Element(10)就行了,這樣就是4次innerHTML操作加1個Element添加。共計5此操作,這樣效率的提升是非常可觀的。

2、 關于React

2.1 接口和設計

在React的設計中,是完全不需要你來操作DOM的。 我們也可以認為,在React中根本就沒有DOM這個概念,有的只是Component。

當你寫好一個Component以后,Component會完全負責UI,你不需要也不應該去也不能夠指揮Component怎么顯示,你只能告訴它你想要顯示一個香蕉還是兩個梨。

隔離DOM并不僅僅是因為DOM慢,而也是為了把界面和業務完全隔離,操作數據的只關心數據,操作界面的只關心界面。比如在websocket聊天室的創建房間時,我們可以首先Component寫好,然后當獲取到數據的時候,只要把數據放在redux中就好,然后Component就動把房間添加到頁面中去,而不是你先拿到數據,然后使用js操作DOM把數據顯示在頁面上。

我提供一個Component,然后你只管給我數據,界面的事情完全不用你操心,我保證會把界面變成你想要的樣子。所以說React的著力點就在于View層,即React專注于View層。你可以把一個React的Component想象成一個Pure Function,只要你給的數據是[1, 2, 3],我保證顯示的是[1, 2, 3]。沒有什么刪除一個Element,添加一個Element這樣的事情。NO。你要我顯示什么就給我一個完整的列表。

另外,Flux雖然說的是單向的Data Flow(redux也是),但是實際上就是單向的Observer,Store->View->Action->Store(箭頭是數據流向,實現上可以理解為View監聽Store,View直接trigger action,然后Store監聽Action)。

2.2 實現

那么react如何實現呢? 最簡單的方法就是當數據變化時,我直接把原先的DOM卸載,然后把最新數據的DOM替換上去。 但是,虛擬DOM哪去了? 這樣做的效率顯然是極低的。

所以虛擬DOM就來救場了。

那么虛擬DOM和DOM之間的關系是什么呢?

首先,Virtual DOM并沒有完全實現DOM,即虛擬DOM和真正地DOM是不一樣的Virtual DOM最主要的還是保留了Element之間的層次關系和一些基本屬性。因為真實DOM實在是太復雜,一個空的Element都復雜得能讓你崩潰,并且幾乎所有內容我根本不關心好嗎所以Virtual DOM里每一個Element實際上只有幾個屬性,即最重要的,最為有用的,并且沒有那么多亂七八糟的引用,比如一些注冊的屬性和函數啊,這些都是默認的,創建虛擬DOM進行diff的過程中大家都一致,是不需要進行比對的。所以哪怕是直接把Virtual DOM刪了根據新傳進來的數據重新創建一個新的Virtual DOM出來都非常非常非常快。(每一個component的render函數就是在做這個事情,給新的virtual dom提供input)。

所以,引入了Virtual DOM之后,React是這么干的:你給我一個數據,我根據這個數據生成一個全新的Virtual DOM,然后跟我上一次生成的Virtual DOM去 diff,得到一個Patch,然后把這個Patch打到瀏覽器的DOM上去。完事。并且這里的patch顯然不是完整的虛擬DOM,而是新的虛擬DOM和上一次的虛擬DOM經過diff后的差異化的部分。

假設在任意時候有,VirtualDom1 == DOM1 (組織結構相同, 顯然虛擬DOM和真實DOM是不可能完全相等的,這里的==是js中非完全相等)。當有新數據來的時候,我生成VirtualDom2,然后去和VirtualDom1做diff得到一個Patch(差異化的結果)。然后將這個Patch去應用到DOM1上,得到DOM2。如果一切正常,那么有VirtualDom2 == DOM2(同樣是結構上的相等)

這里你可以做一些小實驗,去破壞VirtualDom1 == DOM1這個假設(手動在DOM里刪除一些Element,這時候VirtualDom里的Element沒有被刪除,所以兩邊不一樣了)。
然后給新的數據,你會發現生成的界面就不是你想要的那個界面了。

最后,回到為什么Virtual Dom快這個問題上
其實是由于每次生成virtual dom很快,diff生成patch也比較快,而在對DOM進行patch的時候,雖然DOM的變更比較慢但是React能夠根據Patch的內容優化一部分DOM操作,比如之前的那個例子。

重點就在最后,哪怕是我生成了virtual dom(需要耗費時間)哪怕是我跑了diff(還需要花時間)但是我根據patch簡化了那些DOM操作省下來的時間依然很可觀(這個就是時間差的問題了,即節省下來的時間 > 生成 virtual dom的時間 + diff時間)。所以總體上來說,還是比較快。

簡單發散一下思路,如果哪一天,DOM本身的已經操作非常非常非常快了,并且我們手動對于DOM的操作都是精心設計優化過后的,那么加上了VirtualDom還會快嗎
當然不行了,畢竟你多做了這么多額外的工作

但是那一天會來到嗎?
誒,大不了到時候不用Virtual DOM。

注: 此部分內容整理自:https://www.zhihu.com/question/29504639/answer/44680878

虛擬DOM的簡單實現(diff算法)

目錄

  • 1 前言
  • 2 對前端應用狀態管理思考
  • 3 Virtual DOM 算法
  • 4 算法實現
    • 4.1 步驟一:用JS對象模擬DOM樹
    • 4.2 步驟二:比較兩棵虛擬DOM樹的差異
    • 4.3 步驟三:把差異應用到真正的DOM樹上
  • 5 結語

前言

在上面一部分中,我們已經簡單介紹了虛擬DOM的答題思路和好處,這里我們將通過自己寫一個虛擬DOM來加深對其的理解,有一些自己的思考。

對前端應用狀態管理思考

維護狀態,更新視圖。

虛擬DOM算法

DOM是很慢的,如果我們創建一個簡單的div,然后把他的所有的屬性都打印出來,你會看到:

var div = document.createElement('div'),
    str = ''; 
for (var key in div) {
    str = str + ' ' + key;
}
console.log(str);
image

可以看到,這些屬性還是非常驚人的,包括樣式的修飾特性、一般的特性、方法等等,如果我們打印出其長度,可以得到驚人的227個。

而這僅僅是一層,真正的DOM元素是非常龐大的,這是因為標準就是這么設計的,而且操作他們的時候你要小心翼翼,輕微的觸碰就有可能導致頁面發生重排,這是殺死性能的罪魁禍首。

而相對于DOM對象,原生的JavaScript對象處理起來更快,而且更簡單,DOM樹上的結構信息我們都可以使用JavaScript對象很容易的表示出來

var element = {
      tagName: 'ul',
      props: {
        id: 'list' },
      children: {
        {
          tagName: 'li',
          props: { class: 'item' },
          children: ['Item1']
        }, 
        {
          tagName: 'li',
          props: { class: 'item' },
          children: ['Item1']
        }, 
        {
          tagName: 'li',
          props: { class: 'item' },
          children: ['Item1']
        }
      }
    }

如上所示,對于一個元素,我們只需要一個JavaScript對象就可以很容易的表示出來,這個對象中有三個屬性:

  1. tagName: 用來表示這個元素的標簽名。
  2. props: 用來表示這元素所包含的屬性。
  3. children: 用來表示這元素的children。

而上面的這個對象使用HTML表示就是:

<ul id='list'>
  <li class='item'>Item 1</li>
  <li class='item'>Item 2</li>
  <li class='item'>Item 3</li>
</ul>

OK! 既然原來的DOM信息可以使用JavaScript來表示,那么反過來,我們就可以用這個JavaScript對象來構建一個真正的DOM樹

所以之前所說的狀態變更的時候會從新構建這個JavaScript對象,然后呢,用新渲染的對象和舊的對象去對比, 記錄兩棵樹的差異,記錄下來的就是我們需要改變的地方。 這就是所謂的虛擬DOM,包括下面的幾個步驟:

  1. JavaScript對象來表示DOM樹的結構; 然后用這個樹構建一個真正的DOM樹插入到文檔中
  2. 當狀態變更的時候,重新構造一個新的對象樹,然后用這個新的樹和舊的樹作對比,記錄兩個樹的差異
  3. 把2所記錄的差異應用在步驟一所構建的真正的DOM樹上,視圖就更新了。

Virtual DOM的本質就是在JS和DOM之間做一個緩存,可以類比CPU和硬盤,既然硬盤這么慢,我們就也在他們之間添加一個緩存; 既然DOM這么慢,我們就可以在JS和DOM之間添加一個緩存。 CPU(JS)只操作內存(虛擬DOM),最后的時候在把變更寫入硬盤(DOM)。

算法實現

1、 用JavaScript對象模擬DOM樹

用JavaScript對象來模擬一個DOM節點并不難,你只需要記錄他的節點類型(tagName)、屬性(props)、子節點(children)

element.js


function Element(tagName, props, children) { 
  this.tagName = tagName; 
  this.props = props; 
  this.children = children;
}
module.exports = function (tagName, props, children) {
  return new Element(tagName, props, children);
} 

通過這個構造函數,我們就可以傳入標簽名、屬性以及子節點了,tagName可以在我們render的時候直接根據它來創建真實的元素,這里的props使用一個對象傳入,可以方便我們遍歷

基本使用方法如下:

var el = require('./element'); 
var ul = el('ul', {id: 'list'}, [
  el('li', {class: 'item'}, ['item1']),
  el('li', {class: 'item'}, ['item2']),
  el('li', {class: 'item'}, ['item3'])
]);

然而,現在的ul只是JavaScript表示的一個DOM結構,頁面上并沒有這個結構,所有我們可以根據ul構建一個真正的<ul>:

Element.prototype.render = function () { 
  // 根據tagName創建一個真實的元素
  var el = document.createElement(this.tagName); 
  // 得到這個元素的屬性對象,方便我們遍歷。
  var props = this.props; for (var propName in props) { 
    // 獲取到這個元素值
    var propValue = props[propName]; 
    // 通過setAttribute設置元素屬性。 
    el.setAttribute(propName, propValue);
  } 
// 注意: 這里的children,傳入的是一個數組,所以,children不存在時我們用[]來替代。 
  var children = this.children || [];
  //遍歷children
  children.forEach(function (child) { 
    var childEl = (child instanceof Element) ? child.render()
                      : document.createTextNode(child);
   // 無論childEl是元素還是文字節點,都需要添加到這個元素中。
       el.appendChild(childEl);
  });
  return el;
}

所以,render方法會根據tagName構建一個真正的DOM節點,然后設置這個節點的屬性,最后遞歸的把自己的子節點也構建起來,所以只需要調用ul的render方法,通過document.body.appendChild就可以掛載到真實的頁面了。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>div</title>
</head>
<body>
  <script> 
    function Element(tagName, props, children) { 
      this.tagName = tagName; 
      this.props = props; 
      this.children = children;
    } 
    var ul = new Element(
                 'ul', 
                 {id: 'list'}, 
                 [ new Element(
                           'li', 
                           {class: 'item'}, 
                           ['item1']), 
                   new Element(
                           'li',
                           {class: 'item'}, 
                           ['item2']), 
                   new Element(
                           'li', 
                           {class: 'item'}, 
                           ['item3'])]
                        );

    Element.prototype.render = function () { 
      // 根據tagName創建一個真實的元素
      var el = document.createElement(this.tagName); 
      // 得到這個元素的屬性對象,方便我們遍歷。
      var props = this.props; for (var propName in props) { 
        // 獲取到這個元素值
        var propValue = props[propName]; 
        // 通過setAttribute設置元素屬性。 
        el.setAttribute(propName, propValue);
      } 
  // 注意:這里的children,傳入的是一個數組,所以,children不存在時我們用[]來替代。 
      var children = this.children || [];  //遍歷children
       children.forEach(function (child) { 
         var childEl = (child instanceof Element) 
                      ? child.render()
                      : document.createTextNode(child); 
                      // 無論childEl是元素還是文字節點,都需要添加到這個元素中。
         el.appendChild(childEl);
       }); 
       return el;
    } 
    var ulRoot = ul.render();
    document.body.appendChild(ulRoot); 
  </script>
</body>
</html>

上面的這段代碼,就可以渲染出下面的結果了:

image

2、比較兩顆虛擬DOM樹的差異

比較兩顆DOM數的差異是Virtual DOM算法中最為核心的部分,這也就是所謂的Virtual DOM的diff算法。 兩個樹的完全的diff算法是一個時間復雜度為 O(n3) 的問題。 但是在前端中,你會很少跨層地移動DOM元素,所以真實的DOM算法會對同一個層級的元素進行對比。

image

上圖中,div只會和同一層級的div對比,第二層級的只會和第二層級對比。 這樣算法復雜度就可以達到O(n)

(1)深度遍歷優先,記錄差異

在實際的代碼中,會對新舊兩棵樹進行一個深度優先的遍歷,這樣每一個節點就會有一個唯一的標記:

image

上面的這個遍歷過程就是深度優先,即深度完全完成之后,再轉移位置。 在深度優先遍歷的時候,每遍歷到一個節點就把該節點和新的樹進行對比,如果有差異的話就記錄到一個對象里面。

// diff函數,對比兩顆樹
function diff(oldTree, newTree) { 
// 當前的節點的標志。因為在深度優先遍歷的過程中,每個節點都有一個index。
  var index = 0;
// 在遍歷到每個節點的時候,都需要進行對比,找到差異,并記錄在下面的對象中。
  var pathches = {}; 
  // 開始進行深度優先遍歷
  dfsWalk(oldTree, newTree, index, pathches); 
  // 最終diff算法返回的是一個兩棵樹的差異。
  return pathches;
} 
// 對兩棵樹進行深度優先遍歷。
function dfsWalk(oldNode, newNode, index, pathches) { 
  // 對比oldNode和newNode的不同,記錄下來
  pathches[index] = [...];
  diffChildren(oldNode.children, newNode.children, index, pathches); 
} 
// 遍歷子節點
function diffChildren(oldChildren, newChildren, index, pathches) {
  var leftNode = null; 
  var currentNodeIndex = index;
  oldChildren.forEach(function (child, i) { 
      var newChild = newChildren[i];
      currentNodeIndex = (leftNode && leftNode.count) 
                                    ? currentNodeIndex + leftNode.count + 1 
                                    : currentNodeIndex + 1

      // 深度遍歷子節點
      dfsWalk(child, newChild, currentNodeIndex, pathches);
      leftNode = child;
  });
}

例如,上面的div和新的div有差異,當前的標記是0, 那么我們可以使用數組來存儲新舊節點的不同:

  patches[0] = [{difference}, {difference}, ...]

同理使用patches[1]來記錄p,使用patches[3]來記錄ul,以此類推。

(2)差異類型

上面說的節點的差異指的是什么呢? 對DOM操作可能會:

  1. 替換原來的節點,如把上面的div換成了section。
  2. 移動、刪除、新增子節點, 例如上面div的子節點,把p和ul順序互換。
  3. 修改了節點的屬性
  4. 對于文本節點,文本內容可能會改變。 例如修改上面的文本內容2內容為Virtual DOM2.

所以,我們可以定義下面的幾種類型:

var REPLACE = 0; 
var REORDER = 1; 
var PROPS = 2; 
var TEXT = 3;

對于節點替換,很簡單,判斷新舊節點的tagName是不是一樣的,如果不一樣的說明需要替換掉。 如div換成了section,就記錄下:

patches[0] = [{
  type: REPALCE,
  node: newNode // el('section', props, children)
}]

除此之外,如果給div新增了屬性id為container,就記錄下:

   pathches[0] = [
      {
        type: REPLACE,
        node: newNode 
      }, 
      { 
        type: PROPS,
        props: {
          id: 'container' }
      }
    ]

如果是文本節點發生了變化,那么就記錄下:

   pathches[2] = [
      {
        type:  TEXT,
        content: 'virtual DOM2' }
    ]

那么如果我們把div的子節點重新排序了呢? 比如p、ul、div的順序換成了div、p、ul,那么這個該怎么對比呢? 如果按照同級進行順序對比的話,他們就會被替換掉,如p和div的tagName不同,p就會被div所代替,最終,三個節點就都會被替換,這樣DOM開銷就會非常大,而實際上是不需要替換節點的,只需要移動就可以了, 我們只需要知道怎么去移動。這里牽扯到了兩個列表的對比算法,如下。

(3)列表對比算法

假設現在可以英文字母唯一地標識每一個子節點:

舊的節點順序:

a b c d e f g h i

現在對節點進行了刪除、插入、移動的操作。新增j節點,刪除e節點,移動h節點:

新的節點順序:

a b c h d f g i j

現在知道了新舊的順序,求最小的插入、刪除操作(移動可以看成是刪除和插入操作的結合)。這個問題抽象出來其實是字符串的最小編輯距離問題(Edition Distance),最常見的解決算法是 Levenshtein Distance,通過動態規劃求解,時間復雜度為 O(M * N)。但是我們并不需要真的達到最小的操作,我們只需要優化一些比較常見的移動情況,犧牲一定DOM操作,讓算法時間復雜度達到線性的(O(max(M, N))。具體算法細節比較多,這里不累述,有興趣可以參考代碼

我們能夠獲取到某個父節點的子節點的操作,就可以記錄下來:

patches[0] = [{
  type: REORDER,
  moves: [{remove or insert}, {remove or insert}, ...]
}]

但是要注意的是,因為tagName是可重復的,不能用這個來進行對比。所以需要給子節點加上唯一標識key,列表對比的時候,使用key進行對比,這樣才能復用老的 DOM 樹上的節點。

這樣,我們就可以通過深度優先遍歷兩棵樹,每層的節點進行對比,記錄下每個節點的差異了。完整 diff 算法代碼可見 diff.js

3、把差異引用到真正的DOM樹上

因為步驟一所構建的 JavaScript 對象樹和render出來真正的DOM樹的信息、結構是一樣的。所以我們可以對那棵DOM樹也進行深度優先的遍歷,遍歷的時候從步驟二生成的patches對象中找出當前遍歷的節點差異,然后進行 DOM 操作。

function patch (node, patches) { 
  var walker = {index: 0}
  dfsWalk(node, walker, patches)
}

function dfsWalk (node, walker, patches) { 
  var currentPatches = patches[walker.index] 
  // 從patches拿出當前節點的差異

  var len = node.childNodes ? node.childNodes.length
    : 0
  for (var i = 0; i < len; i++) { 
    // 深度遍歷子節點
    var child = node.childNodes[i]
    walker.index++ 
    dfsWalk(child, walker, patches)
  } 
  if (currentPatches) {
    applyPatches(node, currentPatches) 
    // 對當前節點進行DOM操作
 }
}

// applyPatches,根據不同類型的差異對當前節點進行 DOM 操作:
function applyPatches (node, currentPatches) {
  currentPatches.forEach(function (currentPatch) { 
    switch (currentPatch.type) { 
      case REPLACE:
        node.parentNode.replaceChild(currentPatch.node.render(), node) break
      case REORDER:
        reorderChildren(node, currentPatch.moves) break
      case PROPS:
        setProps(node, currentPatch.props) break
      case TEXT:
        node.textContent = currentPatch.content break
      default: throw new Error('Unknown patch type ' + currentPatch.type)
    }
  })
}

5、結語

virtual DOM算法主要實現上面步驟的三個函數: element、diff、patch,然后就可以實際的進行使用了。

// 1. 構建虛擬DOM
var tree = el('div', {'id': 'container'}, [
    el('h1', {style: 'color: blue'}, ['simple virtal dom']),
    el('p', ['Hello, virtual-dom']),
    el('ul', [el('li')])
]) 
// 2. 通過虛擬DOM構建真正的DOM
var root = tree.render()
document.body.appendChild(root) 
// 3. 生成新的虛擬DOM
var newTree = el('div', {'id': 'container'}, [
    el('h1', {style: 'color: red'}, ['simple virtal dom']),
    el('p', ['Hello, virtual-dom']),
    el('ul', [el('li'), el('li')])
]) 
// 4. 比較兩棵虛擬DOM樹的不同
var patches = diff(tree, newTree) 
// 5. 在真正的DOM元素上應用變更
patch(root, patches)

當然這是非常粗糙的實踐,實際中還需要處理事件監聽等;生成虛擬 DOM 的時候也可以加入 JSX 語法。這些事情都做了的話,就可以構造一個簡單的ReactJS了。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容