React基礎——虛擬DOM和Diff算法

目錄

一、虛擬DOM和Diff算法

    1、爲什麼需要虛擬DOM

    2、什麼是虛擬DOM

    3、用JS對象模擬DOM樹

    4、Diff算法比較兩棵虛擬DOM樹的差異

    5、將差異應用到真正的DOM樹

    6、總結

React起源於Facebook的內部項目,用來架設Instagram(照片交友)網站。在2013年5月開源。React的設計思想極其獨特,屬於革命性創新,性能出衆,代碼邏輯卻非常簡單,它可能是將來Web開發的主流工具。

前端三大主流框架(https://2018.stateofjs.com/front-end-frameworks/overview/):
Angular.js:較早。學習曲線比較陡,Ng1學起來比較麻煩,Ng2~5進行了一系列的改革(1和2像是兩套框架),提供了組件化開發的概念,支持使用TypeScript。
Vue.js:最火(關注的人多)。中國人開發。
React.js:最流行(用的多)。設計很優秀。

React與Vue:
組件化:Vue通過.vue文件創建對應的組件,template、script、style;React沒有組件模板文件,一切都是以JS來表現。
開發團隊:Vue以尤雨溪爲主導的開源小團隊;React是由FaceBook前端官方團隊,技術實力比較雄厚。
社區方面:Vue是近兩年才火起來的;React誕生較早,社區強大,常見的問題、最優解決方案、文檔、博客較全面。
移動APP開發:Weex目前只是一個小玩具,並沒有很成功的大案例;ReactNative提供了無縫遷移到移動App的開發體驗,用的最多。

模塊化:從代碼的角度,把一些可複用的代碼抽離爲單個的模塊,便於項目的維護和開發。
組件化:從UI界面的角度,把一些可複用的UI元素抽離爲單獨的組件,便於項目的維護和開發。

一、虛擬DOM和Diff算法

Why Virtual DOM

如何理解虛擬DOM?-EMayej Bee的回答-知乎

深度剖析:如何實現一個Virtual DOM算法

深入淺出React(四):虛擬DOM Diff算法解析

虛擬DOM介紹

1、爲什麼需要虛擬DOM

瀏覽器的工作流程幾乎是相似的。How Browsers Work

(1)創建DOM樹:瀏覽器收到HTML文件後,渲染引擎就會對其進行解析並創建一個DOM樹節點,這些節點與HTML元素具有一對一的關係。

(2)創建渲染(Render)樹:解析來自外部CSS文件和內聯的樣式,樣式信息和DOM樹中的節點用於創建渲染樹。在WebKit中,DOM樹中的所有節點都有attach方法,它接收計算出的樣式信息並返回一個渲染對象。

(3)佈局(也稱迴流):渲染樹中的每個節點都有屏幕座標,使其出現在屏幕上的確切位置。

(4)繪畫:繪製渲染對象。遍歷渲染樹並調用每個節點的paint()方法,在屏幕上顯示內容。

虛擬Dom快,有兩個前提:(1)Javascript很快。Julia Micro-Benchmarks可看到Javascript跟Java基本是一個量級,是C語言的幾倍。(2)DOM很慢。用document.createElement()創建一個空元素時,有以下東西要實現HTMLElement的屬性和方法 Element的屬性和方法 GlobalEventHandlers的屬性和方法,還有不少嵌套引用,DOM設計得太複雜。原生及很多框架在調用DOM的API的時候做得不好,導致整個過程更加慢。每次更改DOM時,所有創建DOM樹至渲染對象都將重做,假設一個接一個地修改了30個節點,可能30次重新計算佈局、重新渲染等。

目的是爲了實現頁面元素的高效更新。

2、什麼是虛擬DOM

虛擬DOM是對DOM的“雙緩衝”應用,可以在脫機DOM樹中執行每個更改,之後將所有更改存儲爲真正的DOM,佈局渲染到頁面上(類似硬盤和緩存),只執行一次,減少了計算的數量。虛擬DOM自動化和抽象DOM片段的管理,不必手動執行,不必手動跟蹤哪部分已改變需要刷新。通過放棄對自身的DOM操作,允許代碼的不同組件或部分請求DOM修改而不必相互交互,避免在修改DOM的所有部分之間進行同步。

React虛擬DOM的運作是透明的,根本沒有DOM這個概念。只需提供component和數據,無需擔心性能問題,由虛擬DOM來確保只對界面上真正變化的部分進行實際的DOM操作。

虛擬DOM並沒有完全實現DOM,只保留了Element之間的層次關係和一些基本屬性。創建新的虛擬DOM時速度很快,每個component的render函數就是給新的虛擬dom提供input。(1)用JavaScript對象表示DOM樹的結構,構建一個真正的DOM樹,插到文檔當中;(2)當狀態變更時,重新構造一棵新的JS對象樹,將新舊樹進行比較,記錄差異;(3)將差異應用到步驟1所構建的真正的DOM樹上,實現視圖更新。

例子如下:通常是先把0, 1, 2元素刪除,再加3, 4, 5, 6新元素,有3次刪除元素和4次添加元素。而React會把這兩個虛擬DOM樹做Diff,只修改innerHTML和添加一個元素6。

<ul>
  <li>0</li>
  <li>1</li>
  <li>2</li>
</ul>
<!-- 更改後 -->
<ul>
  <li>3</li>
  <li>4</li>
  <li>5</li>
  <li>6</li>
</ul>

JS對象只需要記錄DOM樹的節點類型、屬性和子節點。DOM樹和JavaScript對象可以互相轉換。

<!-- HTML寫法 -->
<ul id='list'>
  <li class='item'>Item 1</li>
  <li class='item'>Item 2</li>
  <li class='item'>Item 3</li>
</ul>
// 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"]},
  ]
}

3、用JS對象模擬DOM樹

(1)構建JS對象

// element.js
function Element(tagName, props, children) {
    if (!(this instanceof Element)) {
        return new Element(tagName, props, children);
    }

    this.tagName = tagName;
    this.props = props || {};
    this.children = children || [];
    this.key = props ? props.key : undefined;

    let count = 0;
    this.children.forEach((child) => {
        if (child instanceof Element) {
            count += child.count;
        }
        count++;
    });
    this.count = count;
}

module.exports = function (tagName, props, children) {
  return new Element(tagName, props, children)
}
var el = require('./element')
// 目前ul只是一個JavaScript對象表示的DOM結構
var ul = el('ul', {id: 'list'}, [
  el('li', {class: 'item'}, ['Item 1']),
  el('li', {class: 'item'}, ['Item 2']),
  el('li', {class: 'item'}, ['Item 3'])
])

(2)根據JS對象構建真正的DOM樹 

render方法根據tagName構建一個真正的DOM節點,設置這個節點的屬性,遞歸地把子節點也構建起來。

// 根據JS對象構建真正的DOM樹
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
}

(3)將真正的DOM樹渲染至頁面中

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

4、Diff算法比較兩棵虛擬DOM樹的差異

新舊虛擬DOM樹需要進行Diff算法分析,找到差異。 標準的Diff算法複雜度需要O(n^3),Facebook工程師結合Web界面的特點做出兩個簡單的假設,使得Diff算法複雜度直接降低到O(n)。算法上的優化是React整個界面Render的基礎。(1)兩個相同組件產生類似的DOM結構,不同的組件產生不同的DOM結構;(2)對於同一層次的一組子節點,可以通過唯一的id進行區分。

(1)逐層進行節點比較

tree diff:逐層對比;
component diff:進行Tree Diff時,每一層中,組件級別的對比;
element diff:進行組件對比時,如果兩個組件類型相同,則需要進行元素級別的對比。

在React中比較兩個虛擬DOM節點,分爲兩種情況:
節點類型不同 。當在樹中的同一位置前後輸出了不同類型的節點,直接刪除之前的節點,創建並插入新的節點。注意,該刪除的節點的子節點也會被完全刪除,它們也不會用於後面的比較。同樣的邏輯也被用在React組件的比較,這正是應用了第一個假設。
節點類型相同,但是屬性不同。對屬性進行重設從而實現節點的轉換。虛擬DOM的style屬性稍有不同,其值並不是一個簡單字符串而必須爲一個對象。

在React中,樹的算法非常簡單,兩棵樹只會對同一層次的節點進行比較,即同一個父節點下的所有子節點。當發現節點已經不存在,則該節點及其子節點會被完全刪除掉,不會用於進一步的比較。這樣只需要對樹進行一次遍歷,便能完成整個DOM樹的比較。

這一假設至今爲止沒有導致嚴重的性能問題。在實現自己的組件時,保持穩定的DOM結構有助於性能的提升,例如可以通過CSS隱藏或顯示某些節點,而不是真的移除或添加DOM節點。

例如下面的DOM結構轉換:A節點被整個移動到D節點下,DOM Diff操作如下。

深度優先遍歷,記錄差異,每個節點都會有一個唯一的標記。patches是一個對象,用來記錄每個節點的差異。

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

差異類型如下。

var REPLACE = 0 // 替換掉原來的節點
var REORDER = 1 // 移動、刪除、新增子節點
var PROPS = 2 // 修改了節點的屬性
var TEXT = 3 // 對於文本節點,文本內容改變
{
    0: [{ type: REPALCE, node: newNode }, { type: PROPS,  props: { id: "container" }}],
    2: [{ type: TEXT, content: "Virtual DOM2" }]
}

(2)列表節點的比較

React在遇到列表時卻又找不到key時提示警告,通常意味着潛在的性能問題。對於列表節點提供唯一的key屬性可以幫助React定位到正確的節點進行比較,從而大幅減少DOM操作次數,提高了性能。

列表節點的操作通常包括添加、刪除和排序。(列表節點不只由v-for生成,指的是同一級的節點)

假設用英文字母唯一地標識每一個子節點,舊的節點順序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}, ...] }]

5、將差異應用到真正的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)
    }
  })
}

6、總結

Virtual DOM算法主要實現三個函數:element, diff, patch,之後就可以進行使用。

下面是最簡單的實踐,實際中還需要處理事件監聽、加入JSX語法等,完整代碼見simple-virtual-dom

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

 

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