標籤:

如何理解虛擬DOM?

React的虛擬的DOM的原理是什麼?是怎麼實現的?誰能舉個簡單例子來更好的理解虛擬DOM是什麼


寫一個就知道了。剛好寫了一篇博客剛好可以回答這個問題,copy過來:

深度剖析:如何實現一個 Virtual DOM 演算法 · Issue #13 · livoras/blog · GitHub

目錄:

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

1 前言

本文會在教你怎麼用 300~400 行代碼實現一個基本的 Virtual DOM 演算法,並且嘗試盡量把 Virtual DOM 的演算法思路闡述清楚。希望在閱讀本文後,能讓你深入理解 Virtual DOM 演算法,給你現有前端的編程提供一些新的思考。

本文所實現的完整代碼存放在 Github。

2 對前端應用狀態管理的思考

假如現在你需要寫一個像下面一樣的表格的應用程序,這個表格可以根據不同的欄位進行升序或者降序的展示。

這個應用程序看起來很簡單,你可以想出好幾種不同的方式來寫。最容易想到的可能是,在你的 JavaScript 代碼裡面存儲這樣的數據:

var sortKey = "new" // 排序的欄位,新增(new)、取消(cancel)、凈關注(gain)、累積(cumulate)人數
var sortType = 1 // 升序還是逆序
var data = [{...}, {...}, {..}, ..] // 表格數據

用三個欄位分別存儲當前排序的欄位、排序方向、還有表格數據;然後給表格頭部加點擊事件:當用戶點擊特定的欄位的時候,根據上面幾個欄位存儲的內容來對內容進行排序,然後用 JS 或者 jQuery 操作 DOM,更新頁面的排序狀態(表頭的那幾個箭頭表示當前排序狀態,也需要更新)和表格內容。

這樣做會導致的後果就是,隨著應用程序越來越複雜,需要在JS裡面維護的欄位也越來越多,需要監聽事件和在事件回調用更新頁面的DOM操作也越來越多,應用程序會變得非常難維護。後來人們使用了 MVC、MVP 的架構模式,希望能從代碼組織方式來降低維護這種複雜應用程序的難度。但是 MVC 架構沒辦法減少你所維護的狀態,也沒有降低狀態更新你需要對頁面的更新操作(前端來說就是DOM操作),你需要操作的DOM還是需要操作,只是換了個地方。

既然狀態改變了要操作相應的DOM元素,為什麼不做一個東西可以讓視圖和狀態進行綁定,狀態變更了視圖自動變更,就不用手動更新頁面了。這就是後來人們想出了 MVVM 模式,只要在模版中聲明視圖組件是和什麼狀態進行綁定的,雙向綁定引擎就會在狀態更新的時候自動更新視圖(關於MV*模式的內容,可以看這篇介紹)。

MVVM 可以很好的降低我們維護狀態 -&> 視圖的複雜程度(大大減少代碼中的視圖更新邏輯)。但是這不是唯一的辦法,還有一個非常直觀的方法,可以大大降低視圖更新的操作:一旦狀態發生了變化,就用模版引擎重新渲染整個視圖,然後用新的視圖更換掉舊的視圖。就像上面的表格,當用戶點擊的時候,還是在JS裡面更新狀態,但是頁面更新就不用手動操作 DOM 了,直接把整個表格用模版引擎重新渲染一遍,然後設置一下innerHTML就完事了。

聽到這樣的做法,經驗豐富的你一定第一時間意識這樣的做法會導致很多的問題。最大的問題就是這樣做會很慢,因為即使一個小小的狀態變更都要重新構造整棵 DOM,性價比太低;而且這樣做的話,input和textarea的會失去原有的焦點。最後的結論會是:對於局部的小視圖的更新,沒有問題(Backbone就是這麼乾的);但是對於大型視圖,如全局應用狀態變更的時候,需要更新頁面較多局部視圖的時候,這樣的做法不可取。

但是這裡要明白和記住這種做法,因為後面你會發現,其實 Virtual DOM 就是這麼做的,只是加了一些特別的步驟來避免了整棵 DOM 樹變更

另外一點需要注意的就是,上面提供的幾種方法,其實都在解決同一個問題:維護狀態,更新視圖。在一般的應用當中,如果能夠很好方案來應對這個問題,那麼就幾乎降低了大部分複雜性。

3 Virtual DOM演算法

DOM是很慢的。如果我們把一個簡單的div元素的屬性都列印出來,你會看到:

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

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

var element = {
tagName: "ul", // 節點標籤名
props: { // DOM的屬性,用一個對象存儲鍵值對
id: "list"
},
children: [ // 該節點的子節點
{tagName: "li", props: {class: "item"}, children: ["Item 1"]},
{tagName: "li", props: {class: "item"}, children: ["Item 2"]},
{tagName: "li", props: {class: "item"}, children: ["Item 3"]},
]
}

上面對應的HTML寫法是:

&

    &

  • Item 1& &

  • Item 2& &

  • Item 3& &

    既然原來 DOM 樹的信息都可以用 JavaScript 對象來表示,反過來,你就可以根據這個用 JavaScript 對象表示的樹結構來構建一棵真正的DOM樹。

    之前的章節所說的,狀態變更-&>重新渲染整個視圖的方式可以稍微修改一下:用 JavaScript 對象表示 DOM 信息和結構,當狀態變更的時候,重新渲染這個 JavaScript 的對象結構。當然這樣做其實沒什麼卵用,因為真正的頁面其實沒有改變。

    但是可以用新渲染的對象樹去和舊的樹進行對比,記錄這兩棵樹差異。記錄下來的不同就是我們需要對頁面真正的 DOM 操作,然後把它們應用在真正的 DOM 樹上,頁面就變更了。這樣就可以做到:視圖的結構確實是整個全新渲染了,但是最後操作DOM的時候確實只變更有不同的地方。

    這就是所謂的 Virtual DOM 演算法。包括幾個步驟:

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

    Virtual DOM 本質上就是在 JS 和 DOM 之間做了一個緩存。可以類比 CPU 和硬碟,既然硬碟這麼慢,我們就在它們之間加個緩存:既然 DOM 這麼慢,我們就在它們 JS 和 DOM 之間加個緩存。CPU(JS)只操作內存(Virtual DOM),最後的時候再把變更寫入硬碟(DOM)。

    4 演算法實現

    4.1 步驟一:用JS對象模擬DOM樹

    用 JavaScript 來表示一個 DOM 節點是很簡單的事情,你只需要記錄它的節點類型、屬性,還有子節點:

    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)
    }

    例如上面的 DOM 結構就可以簡單的表示:

    var el = require("./element")

    var ul = el("ul", {id: "list"}, [
    el("li", {class: "item"}, ["Item 1"]),
    el("li", {class: "item"}, ["Item 2"]),
    el("li", {class: "item"}, ["Item 3"])
    ])

    現在ul只是一個 JavaScript 對象表示的 DOM 結構,頁面上並沒有這個結構。我們可以根據這個ul構建真正的&

    Element.prototype.render = function () {
    var el = document.createElement(this.tagName) // 根據tagName構建
    var props = this.props

    for (var propName in props) { // 設置節點的DOM屬性
    var propValue = props[propName]
    el.setAttribute(propName, propValue)
    }

    var children = this.children || []

    children.forEach(function (child) {
    var childEl = (child instanceof Element)
    ? child.render() // 如果子節點也是虛擬DOM,遞歸構建DOM節點
    : document.createTextNode(child) // 如果字元串,只構建文本節點
    el.appendChild(childEl)
    })

    return el
    }

    render方法會根據tagName構建一個真正的DOM節點,然後設置這個節點的屬性,最後遞歸地把自己的子節點也構建起來。所以只需要:

    var ulRoot = ul.render()
    document.body.appendChild(ulRoot)

    上面的ulRoot是真正的DOM節點,把它塞入文檔中,這樣body裡面就有了真正的&的DOM結構:

    &

      &

    • Item 1& &

    • Item 2& &

    • Item 3& &

      完整代碼可見 element.js。

      4.2 步驟二:比較兩棵虛擬DOM樹的差異

      正如你所預料的,比較兩棵DOM樹的差異是 Virtual DOM 演算法最核心的部分,這也是所謂的 Virtual DOM 的 diff 演算法。兩個樹的完全的 diff 演算法是一個時間複雜度為 O(n^3) 的問題。但是在前端當中,你很少會跨越層級地移動DOM元素。所以 Virtual DOM 只會對同一個層級的元素進行對比:

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

      4.2.1 深度優先遍歷,記錄差異

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

      在深度優先遍歷的時候,每遍歷到一個節點就把該節點和新的的樹進行對比。如果有差異的話就記錄到一個對象裡面。

      // diff 函數,對比兩棵樹
      function diff (oldTree, newTree) {
      var index = 0 // 當前節點的標誌
      var patches = {} // 用來記錄每個節點差異的對象
      dfsWalk(oldTree, newTree, index, patches)
      return patches
      }

      // 對兩棵樹進行深度優先遍歷
      function dfsWalk (oldNode, newNode, index, patches) {
      // 對比oldNode和newNode的不同,記錄下來
      patches[index] = [...]

      diffChildren(oldNode.children, newNode.children, index, patches)
      }

      // 遍歷子節點
      function diffChildren (oldChildren, newChildren, index, patches) {
      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, patches) // 深度遍歷子節點
      leftNode = child
      })
      }

      例如,上面的div和新的div有差異,當前的標記是0,那麼:

      patches[0] = [{difference}, {difference}, ...] // 用數組存儲新舊節點的不同

      同理p是patches[1],ul是patches[3],類推。

      4.2.2 差異類型

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

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

      所以我們定義了幾種差異類型:

      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,就記錄下:

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

      如果是文本節點,如上面的文本節點2,就記錄下:

      patches[2] = [{
      type: TEXT,
      content: "Virtual DOM2"
      }]

      那如果把我div的子節點重新排序呢?例如p, ul, div的順序換成了div, p, ul。這個該怎麼對比?如果按照同層級進行順序對比的話,它們都會被替換掉。如p和div的tagName不同,p會被div所替代。最終,三個節點都會被替換,這樣DOM開銷就非常大。而實際上是不需要替換節點,而只需要經過節點移動就可以達到,我們只需知道怎麼進行移動。

      這牽涉到兩個列表的對比演算法,需要另外起一個小節來討論。

      4.2.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。

      4.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)
      }
      })
      }

      完整代碼可見 patch.js。

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

      本文所實現的完整代碼存放在 Github,僅供學習。

      6 References

      virtual-dom/diff.js at master · Matt-Esch/virtual-dom · GitHub


      比如說,你有一個數組,被展示成了一個列表,之前有5個數組項:

      [0, 1, 2, 3, 4]

      展示成了:

      & &0& &1& &2& &3& &4& &

      現在,給數組新增了10個元素,變成了從0-14這樣的狀況,思考一下如果要創建li節點,是怎麼處理?

      最圡的方式,是不是這樣?

      for (var i=5; i&<15; i++) { var li = document.createElement("li"); li.innerHTML = arr[i]; ul.appendChild(li); }

      這裡其實就有問題,因為性能是不好的,批量操作應當被合併。

      東風吹,戰鼓擂,廠里來了新同事,車間主任召集大家開個會:今天我們車間新來了關羽同志,來自河東解良,大家歡迎,然後就是各種套話,一不小心15分鐘過去了,大家都聚在一起,抓了革命沒促生產。

      明天又來了張飛同志,後天來了趙雲,然後馬超黃忠魏延,每來一個人,有很多事情都得來這麼一遍,如果這群人是同一天來,一次也就30分鐘就都介紹完了,每天來一個,總的就花了一個多小時,所以,從總的時間成本來說,批量介面都是比多次調用單個介面的總和要節省的,因為可以有一些內部優化。

      所以,我們在剛才的例子里,一般會用一個合併好的html字元串,或者DocumentFragment先做好批量的創建合併,再一次追加到DOM樹去。

      這當然是手動的情形了,考慮在一個抽象度較高的框架/庫中,需要提供一些對細節的屏蔽,讓使用者不要自己去考慮這些東西。

      比如我們剛才創建li,如果給你搞成這樣:

      var li = new Li();
      li.html(i);

      ul.append(li);

      寫的時候讓你不考慮細節,由框架/庫自己處理內部的批量合併,那用起來就會方便很多。怎麼做到這樣呢?

      我們讓你new的這個Li,你以為我在創建li元素,其實不一定啊,說不定我的Li實現是這樣:

      function Li() {}

      Li.prototype = {
      html: function(str) {}
      }

      然後,ul.append(li),其實ul也是個類似的東西,到最後,你認為就創建DOM啦?其實都是JavaScript對象實例,我們知道,純JS的東西比DOM操作快不知多少倍了,所以這裡隨便你怎麼折騰,我就是只給你操作數據,讓你覺得在操作DOM,就達到虛擬的目的了。在最後,由框架/庫幫你一次把整個DOM的最終狀態映射出來,中間過程全部捨棄,性能就優化得比較好了。

      正因為如此,能夠實現服務端與瀏覽器端的同構,比如說有一段JS用於根據某種數據生成DOM,我可以選擇把這段JS放在瀏覽器中執行,也可以放在服務端執行,反正在中間過程操作的都是純數據,最終才批量生成HTML結構。


      雖然Virtual DOM確實是性能杠杠的,但是其實可以說它是無心插柳的一個結果。

      React的核心思想:

      一個Component拯救世界,忘掉煩惱,從此不再操心界面。

      1. Virtual Dom快,有兩個前提

      1.1 Javascript很快

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

      Julia有一個Benchmark,Julia Benchmarks, 可以看到Javascript跟C語言很接近了,也就幾倍的差距,跟Java基本也是一個量級。

      所以說,單純的Javascript其實速度是很快的。

      多說一句,這種benchmark並不是絕對的依據,因為用這個語言寫這個跑得快,並不代表一定是用這個語言寫那個也跑得快。

      1.2 DOM很慢

      關於什麼CSS,什麼layout那些我不懂,就不瞎說了,咱就說說DOM的結構。

      當你用document.createElement()創建一個空的Element的時候(比如創建一個空的div),有以下這幾頁的東西需要實現(當然,這不是標準,只是個大概的意思):

      HTMLElement - Web API Interfaces

      Element - Web API Interfaces

      GlobalEventHandlers

      非常非常多,並且還有不少嵌套引用。

      你可以在Chrome console裏手動調用document.createElement 然後插入DOM里看看效果。

      這還是一個空的Elemnt,啥內容也沒有,就這麼複雜。所以說DOM的操作非常慢是可以理解的。不是瀏覽器不想好好實現DOM,而是DOM設計得太複雜,沒辦法。

      而更糟糕的是,我們(以及很多框架)在調用DOM的API的時候做得不好,導致整個過程更加的慢。React的Virtual Dom解決的是這一部分問題,它並不能解決DOM本身慢的問題。

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

      &

      &0&

      &1&

      &2&

      &3&

      &

      你想把它變成這樣

      &

      &6&

      &7&

      &8&

      &9&

      &10&

      &

      通常的操作是什麼?

      先把0, 1,2,3這些Element刪掉,然後加幾個新的Element 6,7,8,9,10進去,這裡面就有4次Element刪除,5次Element添加。

      而React會把這兩個做一下Diff,然後發現其實不用刪除0,1,2,3,而是可以直接改innerHTML,然後只需要添加一個Element(10)就行了,這樣就是4次innerHTML操作加1個Element添加,比9次Element操作快多了吧?

      當然還有其它一些例子能夠優化我們對DOM的操作,就不舉例子了。(實際上是因為我舉不出例子。。。)

      2. 關於React

      2.1 介面和設計

      在React的設計里,是完全不需要你操作DOM的。在React里其實根本就沒有DOM這個概念的存在,只有Component。當你寫好一個Component以後,Component會完全負責UI,你不需要也不應該去也不能夠指揮Component怎麼顯示,你只能告訴它你想要顯示一個香蕉還是兩個梨。

      隔離DOM並不是因為DOM慢(當然DOM確實慢),而是把界面和業務完全隔離,操作數據的只關心數據,操作界面的只關心界面。可以想像成把MVC裡面的Controller分成兩個部分,一部分合併到M裡面去,一部分合併到V裡面去,就剩下MV,沒有C了。。。其實M也並不是Model了。推薦看一下Pete Hunt的這個Talk https://youtu.be/DgVS-zXgMTk

      重複一遍,React的意思是,我提供一個Component,然後你只管給我數據,界面的事情完全不用你操心,我保證會把界面變成你想要的樣子。

      你可以把一個React的Component想像成一個Pure Function,只要你給的數據是[1, 2, 3],我保證顯示的是[1, 2, 3]。沒有什麼刪除一個Element,添加一個Element這樣的事情。NO。你要我顯示什麼就給我一個完整的列表。

      說到這裡,插一句別的,我一開始看到這裡還以為這樣的處理方式比較適合一般的WEB應用,寫遊戲啊什麼的可能這個模式不太好用,然後我就看到Pete Hunt那個Talk,說DOOM 3就是這麼乾的。

      。。

      。。。

      眼淚都下來了,大神們的思路果然我是摸不著邊的,洗洗睡吧。

      睡醒了接著說。React其實需要從Imperative Programming轉換到Declarative Programming去理解。你不要一步一步告訴我這件事情怎麼做,什麼先和面再剁餡,NO,告訴我你想要煎餅還是月餅,我會想辦法去做的,不要來干擾我。你只需要告訴我有這麼一個列表[1, 3, 6]需要顯示就行了,不要告訴我怎麼顯示,我會想辦法的,我保證美得冒泡,各種神奇的效果,亮瞎你的鈦合金狗眼。

      行了行了,你真啰嗦。

      。。。

      再說幾句瞎扯的話,Flux雖然說的是單向的Data Flow,但是實際上就是單向的Observer。

      Store-&>View-&>Action-&>Store(箭頭是數據流向,實現上可以理解為View監聽Store,View直接trigger action,然後Store監聽Action)

      等等,不是說Component是pure function不跟誰綁定嗎,為啥View要監聽Store?你這個騙子。怪不得都沒有人給你點贊。

      。。。

      。。

      我們還是繼續說React把,Flux是什麼鬼,我反正沒聽過。

      2.2 實現

      OK,那麼,如何實現React呢?

      其實對於React來說,最容易實現的辦法是每次完全摧毀整個DOM,然後重新建立一個全新的DOM。因為一個Component是一個Pure function,根本就沒有State這個概念,我又不知道DOM現在是什麼樣子,那最簡單的辦法當然是只要你給新數據,我就把整個DOM刪了,然後根據你給的數據重新生成一個DOM咯。

      等等,Virtual DOM哪兒去了?

      事實是這樣的,最簡單實現React的方式雖然說非常簡單,但是效率實在是太低了,你居然要全部都刪了重建DOM,DOM本身已經很慢了,你還這麼去用,誰能忍啊?

      然後Virtual DOM就來救場了。

      Virtual DOM和DOM是啥關係呢?

      首先,Virtual DOM並沒有完全實現DOM,Virtual DOM最主要的還是保留了Element之間的層次關係和一些基本屬性。因為DOM實在是太複雜,一個空的Element都複雜得能讓你崩潰,並且幾乎所有內容我根本不關心好嗎。所以Virtual DOM里每一個Element實際上只有幾個屬性,並且沒有那麼多亂七八糟的引用。所以哪怕是直接把Virtual DOM刪了,根據新傳進來的數據重新創建一個新的Virtual DOM出來都非常非常非常快。(每一個component的render函數就是在做這個事情,給新的virtual dom提供input)

      所以,引入了Virtual DOM之後,React是這麼乾的:

      你給我一個數據,我根據這個數據生成一個全新的Virtual DOM,然後跟我上一次生成的Virtual DOM去 diff,得到一個Patch,然後把這個Patch打到瀏覽器的DOM上去。完事。

      有點像版本控制打patch的思路。

      假設在任意時候有,VirtualDom1 == DOM1 (組織結構相同)

      當有新數據來的時候,我生成VirtualDom2,然後去和VirtualDom1做diff,得到一個Patch。

      然後將這個Patch去應用到DOM1上,得到DOM2。

      如果一切正常,那麼有VirtualDom2 == DOM2。

      這裡你可以做一些小實驗,去破壞VirtualDom1 == DOM1這個假設(手動在DOM里刪除一些Element,這時候VirtualDom里的Element沒有被刪除,所以兩邊不一樣了)。

      然後給新的數據,你會發現生成的界面就不是你想要的那個界面了。

      最後,回到為什麼Virtual Dom快這個問題上。

      其實是由於每次生成virtual dom很快,diff生成patch也比較快,而在對DOM進行patch的時候,我能夠根據Patch的內容,優化一部分DOM操作,比如之前1.2里的那個例子。

      重點就在最後,哪怕是我生成了virtual dom,哪怕是我跑了diff,但是我根據patch簡化了那些DOM操作省下來的時間依然很可觀。所以總體上來說,還是比較快。

      簡單發散一下思路,如果哪一天,DOM本身的已經操作非常非常非常快了,並且我們手動對於DOM的操作都是精心設計優化過後的,那麼加上了VirtualDom還會快嗎?

      當然不行了,畢竟你多做了這麼多額外的工作。

      但是那一天會來到嗎?

      誒,大不了到時候不用Virtual DOM。


      這是普通的Html標籤寫法

      &
      &React&

      這是在js中手動生成相同dom的寫法

      //javascript dom
      var a = document.createElement("a")
      a.setAttribute("class", "link")
      a.setAttribute("href", "https://github.com/facebook/react")
      a.appendChild(document.createTextNode("React"))

      這是一種封裝,沿用的React.createElement的命名

      var a = React.createElement("a", {
      className: "link",
      href: "https://github.com/facebook/react"
      }, "React")

      所有html結構,都可以用js dom來構造,而且能將構造的步驟封裝起來,做到「數據-dom結構」的映射。

      緩存初始數據,新數據進來時,與舊數據對比,找到差異,根據差異本身的性質進行dom操作;無差異,則不作為。

      dom本身在js中就是一種數據結構,console.dir(document.body),在控制台可以看到body的數據結構。然而,dom相關的數據豐富而且複雜,我們其實只關心少數元素的少數屬性。

      建立一個javascript plain object,非常輕量,用它保存我們真正關心的與dom相關的少數數據;對它進行操作,然後對比操作前後的差異,再根據映射關係去操作真正的dom,無疑能提高性能。

      這就是虛擬DOM的理念。

      ==========

      說虛擬DOM的理念,沒人贊。只好 show code 了,說虛擬DOM的實踐。

      React.createElement 的方法名,看似創建了一個Element,實質只是一個輕量級的數據結構,其最簡形式如下:

      var a = {
      type: "a",
      props: {
      children: "React",
      className: "link",
      href: "facebook/react · GitHub"
      },
      _isReactElement: true
      }

      React.render(a, document.body)

      在線DEMO(進去點 run with JS): JS Bin - Collaborative JavaScript Debugging

      如上,React.render(ReactElement, DOM) 中所謂的 ReactElement,是指私有屬性_isReactElement 為 true 的一種數據結構,而非真正的Element。

      React.createClass 所謂的組件,實質是這樣的:

      var type = function (props, context) {
      this.props = props
      }

      type.prototype.render = function() {
      return {
      type: "a",
      props: this.props,
      _isReactElement: true
      }
      }

      var a = {
      type: type,
      props: {
      children: "React",
      className: "link",
      href: "facebook/react · GitHub"
      },
      _isReactElement: true
      }

      React.render(a, document.body)

      在線DEMO(進去點 run with JS):JS Bin - Collaborative JavaScript Debugging

      當對象的type屬性是字元串時,表示為普通html標籤,當type為函數時,它被當做構造函數,props屬性被傳入這個構造函數,生成實例,再調用實例的render方法,render方法返回的數據結構才用以渲染。

      虛擬DOM只是個名字。


      @徐飛 兄講述了虛擬DOM的概念,那麼我來結合React說一下在React中虛擬DOM究竟做了什麼。

      瀏覽器上呈現的html文檔,本質上來說是一種xml,那麼我們可以用一種樹狀結構把這個html文檔描述出來:

      html
      +----head
      + +----...
      +
      +----body
      +----div
      + +----...
      +
      +----div
      +----...

      React在呈現的過程中,會首先根據render的結果將這個樹狀結構在js里創建出來(注意,這個時候並沒有操作DOM),這個樹狀結構就是虛擬DOM層。用樓上的例子,根據一個array[1, 2, 3, 4, 5]去渲染一個列表,那麼虛擬DOM應該類似以下結構:

      ul
      +----li(1)
      +----li(2)
      +----li(3)
      +----li(4)
      +----li(5)

      這時再根據這個虛擬DOM渲染成實際DOM。然後重點來了,如果array內容發生變化了怎麼辦,比如我們刪除了3這個元素,重新render,虛擬DOM會發生相應變化:

      ul
      +----li(1)
      +----li(2)
      +----li(4)
      +----li(5)

      React會將這個新的虛擬DOM和正在呈現的虛擬DOM進行對比,並找出其中的差異,然後用最少的DOM操作完成這個更新。(參考React』s diff algorithm)

      這裡需要注意到,以上這些操作都是在js里完成的(生成虛擬DOM,比對),並沒有實際操作DOM元素,比對完畢後找出的差異才會實際操作DOM元素,比如移除掉一個節點,更新其他節點的屬性。

      對比angularjs,沒有比對的這個過程,直接移除掉所有模板元素的DOM,再重新添加。我們曾經結合angularjs和d3js,模板中有一個佔位符用d3js來繪製圖表,結果數據變化時,d3js繪製的圖表被移除了,替換回了佔位符,不得不重新繪製圖表,而不是更新圖表數據,結果就是圖表會有一瞬間的空白狀態。


      要知道為什麼要用Virtual DOM,就要知道Virtual DOM到底好在哪

      我來說一個最簡單的例子吧

      假設這裡有三個套娃

      儘管我們不知道套娃裡面有n層,但是我們可以確定的是,n肯定是一個確切的數字,n就代表了裡面的第n層的套娃

      常規上,如果我們要把灰套娃中的第三層(g3)和紫(p3)套娃中的第三層做一個互換,會經過一下手續:

      打開灰色套娃*2

      取出g3

      打開紫色套娃*2

      取出p3

      p3+灰色套娃裝配

      g3+紫色套娃裝配

      那如果要交換第n個

      只需

      打開灰色套娃*n-1

      取出gn

      打開紫色套娃*n-1

      取出pn

      pn+灰色套娃裝配

      gn+紫色套娃裝配

      看著很簡單啊,假若套娃代表了DOM NODE,這些操作在代碼層面上還可以更加高效

      通過對DOM樹的逐層解析(打開套娃),獲取對應節點(取出),然後經過appendTo(裝配),事情就完成了

      但是由於我們的web頁面是堆疊式的布局,DOM之間的位置會相互影響(這種情景前提是撇除絕對定位之類的脫離DOM流的那些節點),位置影響就是會重繪,重繪過多過重會有什麼效果,很簡單,你把它認為會卡就好了

      由於DOM的這種會互相影響的性質,所以我需要變換一下套娃s的擺放了

      如圖

      如果我依然要交換g3和p3,然後我還不能讓PDD動太多太廣(減少重繪次數與規模)

      Virtual DOM是怎麼做的呢

      首先,我們還是要MAP這些DOM節點。但是我們不能每次都MAP一遍,我們把MAP到的DOM樹存在js里

      然後js運行畢竟比逐步DOM操作要記者不少,所以我們先用js把DOM關係重新梳理了

      g.g3 swap p.p3

      然後就是最一顆賽艇的分步了

      我們把先前的套娃全部摧毀,這時pdd理應就是掉下來了

      然後我們直接在原本套娃的位置按照js裡面的DOM數據生成新的套娃

      如果不深究下去的話,這樣就是Virtual DOM的基本方案了

      通過js對DOM的數據做處理,而不是直接操作DOM,換句話說,我們操作的是Virtual DOM(數據),不是DOM。

      這樣就夠了嗎?

      當然不是了,畢竟你看,PDD在這個過程中還是移動了3個套娃的距離

      如果這裡有n個套娃,那PDD就要掉落n個套娃的距離了

      所以我們還有優化的空間

      如果我們將舊的DOM結構和新的DOM結構比一比(Diff),只改變有變化的地方,這時PDD就不會掉啦

      那麼這時,我們只需改變套娃裡面的第N層就好了

      這樣最外面的套娃就不會動了,然後就可以PDD就不會掉下來了

      之後,一切就順理成章的解決了。


      從來不相信facebook所謂vdom很牛逼不負責任的言論,沒有研究過webkit渲染原理就不要說什麼直接操作DOM慢,難道diff就不耗性能?深度優先顯然是很爛的。

      有本事打開Paint Flash和FPS meter來比較下?


      簡單來說,就是用一個輕量級的dom結構(用js模擬實現),來模擬重量級的dom結構,進而通過輕量級結構的操作來減少重量級結構的操作,最終達到性能優化的目的。

      內部實現大致如下:

      - 對於CompositeComponent,執行render方法,得到renderedElement,這樣就構成了父元素與子元素的層級結構

      - 對於DOMComponent,通過props.children,形成父元素與資源的層級結構

      - 有父子之間的關係後,通過遞歸渲染的方式,就得到了一個輕量級的虛擬DOM


      樓上的都回答挺好的,自己回答這個問題,算是自己最近的一個總結吧。

      從用Egret寫HTML5遊戲,轉到純web開發。今年5月份開始ReactJS ,差不多正好5個月的時間,中間邊學邊寫公司項目。

      最近出去找工作,被問到ReactJS實現原理,自己是各種懵逼的。自己只能回來惡補知識了。各種搜索,找到了一些文章。

      https://medium.com/@deathmood/how-to-write-your-own-virtual-dom-ee74acc13060#.16vb88o6p

      中文翻譯 如何實現 Virtual DOM // 九月煙樹

      其中也找到目前排名第一作者的博客,然後細細的拜讀了一下。發現最終的重點回到了演算法上面,也就是Virtual DOM 中的diff 演算法,然後就是DOM 的基本操作了。

      想起大家經常說的,基礎才是重點。現在經歷過了,才知道其中的緣由。才有點理解一些公司為什麼面試主要就考些演算法了。

      現在惡補基礎知識和演算法,過段時間再去找工作。

      有事沒事多刷刷演算法題

      https://leetcode.com/problemset/algorithms/


      我錄了一個視頻,就是講如何理解這個 虛擬dom的。

      點擊查看:誘人的 react 視頻教程 - 基礎篇 #9 react 如何更新 dom


      50行代碼實現Virtual DOM


      大神太多了,你在vm中批量,可是在webkit渲染時也是批量嗎,同時給你蹭出來10個標籤?c++它還是要給循環遍及,遞歸生成標籤對象,然後添加dom中。這種的高效是在於實現了一個真正狀態機,擺脫了dom操作,偉大在於此。


      樓主,我想問下, 你的diff.js 所對應的的 var listDiff = require("list-diff2")這個文件在git上怎麼沒有啊。可以分享一下嗎。


      你最好不要把他看成virtual dom,其實就是內存里的一大堆原始的javascript對象,和dom一樣,維持著一個樹形的結構,這顆樹和dom沒半點毛關係,你操作這些原始的對象,virtual dom的代碼根據一些對應來操作dom。

      virtual dom的作者認為大多數人都是傻x,他操作dom效率比較高,所以搞的這項目。嘻嘻,其實也不是啦,批量操作dom肯定效率高點,不過估計最終瀏覽器會開放一種效率高點的操作dom的介面。


      推薦閱讀:

如何用React做一個MVC項目?
在 componentWillUnmount 中到底應該清除哪些變數?
store的組織是扁平化好,還是分層級樹狀的好?大型的項目store該怎麼組織?
新手學習前端開發加了很多技術群有必要天天看群聊天記錄學習嗎?
截止到2017年7月,手淘內部還在用vue嗎,有替換成react嗎?

TAG:DOM | React |