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

文章結構:html

  • 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執行速度都很快了。java

   在 https://julialang.org/benchmarks/ 這個網站上,咱們能夠看到,JavaScript語言已經很是快了,和C就是幾倍的關係,和java在同一個量級。因此說,單純的JavaScript仍是仍是很快的。node

  

1.2 Dom很慢react

  當建立一個元素好比div,有如下幾項內容須要實現: HTML elementElementGlobalEventHandler。簡單的說,就是插入一個Dom元素的時候,這個元素上自己或者繼承不少屬性如 width、height、offsetHeight、style、title,另外還須要註冊這個元素的諸多方法,好比onfucos、onclick等等。 這還只是一個元素,若是元素比較多的時候,還涉及到嵌套,那麼元素的屬性和方法等等就會不少,效率很低。git

  好比,咱們在一個空白網頁的body中添加一個div元素,以下所示:github

       

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

   尤爲是在js操做DOM的過程當中,不只有dom自己的繁重,js的操做也須要浪費時間,咱們認爲js和DOM之間有一座橋,若是你頻繁的在橋兩邊走動,顯然效率是很低的,若是你的JavaScript操做DOM的方式還很是不合理,那麼顯然就會更糟糕了。 算法

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

  

  好比說,如今你的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此操做,這樣效率的提高是很是可觀的。

  

二、 關於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);

 

  

 能夠看到,這些屬性仍是很是驚人的,包括樣式的修飾特性、通常的特性、方法等等, 若是咱們打印出其長度,能夠獲得驚人的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)。 

 

  

算法實現

一、 用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>

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

 

二、比較兩顆虛擬DOM樹的差別

  比較兩顆DOM數的差別是Virtual DOM算法中最爲核心的部分,這也就是所謂的Virtual DOM的diff算法。 兩個樹的徹底的diff算法是一個時間複雜度爲 O(n3) 的問題。 可是在前端中,你會不多跨層地移動DOM元素,因此真實的DOM算法會對同一個層級的元素進行對比。 

 

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

  

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

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

 上面的這個遍歷過程就是深度優先,即深度徹底完成以後,再轉移位置。 在深度優先遍歷的時候,每遍歷到一個節點就把該節點和新的樹進行對比,若是有差別的話就記錄到一個對象裏面

    // 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

 
 

三、把差別引用到真正的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)
    }
  })
}

 

 
 

五、結語

  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了。

 
 
 
 
  
源碼地址: https://github.com/livoras/simple-virtual-dom
參考文章:https://github.com/livoras/blog/issues/13 
相關文章
相關標籤/搜索