virtual DOM快在哪裡?

Anonlyyy發表於2018-12-28

在聊virtual DOM前我們要先來說說瀏覽器的渲染流程.

瀏覽器如何渲染頁面

作為一名web前端碼農,每天都在接觸著瀏覽器.長此以往我們都會有疑惑,瀏覽器是怎麼解析我們的程式碼然後渲染的呢?弄明白瀏覽器的渲染原理,對於我們日常前端開發中的效能優化有重要意義。

所以今天我們來給大家詳細說說瀏覽器是怎麼渲染DOM的。

瀏覽器渲染大致流程

首先,瀏覽器會通過請求的 URL 進行域名解析,向伺服器發起請求,接收資源(HTML、CSS、JS、Images)等等,那麼之後瀏覽器又會進行以下解析:

  1. 解析HTML文件,生成DOM Tree
  2. CSS 樣式檔案載入後,開始解析和構建 CSS Rule Tree
  3. Javascript 指令碼檔案載入後, 通過 DOM API 和CSSOM API 來操作改動 DOM Tree 和 CSS Rule Tree

而解析完以上步驟後, 瀏覽器會通過DOM Tree 和CSS Rule Tree來構建 Render Tree(渲染樹)。

根據渲染樹來佈局,以計算每個節點的幾何資訊。

最後將各個節點繪製到頁面上。

HTML解析

<html>
<html>
<head>
    <title>Web page parsing</title>
</head>
<body>
    <div>
        <h1>Web page parsing</h1>
        <p class="text">This is an example Web page.</p>
    </div>
</body>
</html>
複製程式碼

那麼解析的DOM樹就是以下這樣

CSS解析

/* rule 1 */ div { display: block; text-indent: 1em; }
/* rule 2 */ h1 { display: block; font-size: 3em; }
/* rule 3 */ p { display: block; }
/* rule 4 */ [class="text"] { font-style: italic; }
複製程式碼

CSS Rule Tree會比照著DOM樹來對應生成,在這裡需要注意的就是CSS匹配DOM的規則。很多人都以為CSS匹配DOM樹的速度會很快,其實不然。

樣式系統從最右邊的選擇符開始向左側移動來匹配一條規則。樣式系統會一直向左匹配選擇符直到規則匹配完畢或者由於出錯停止匹配.

這裡就衍生出一個問題,為什麼解析CSS的時候選擇從右往左呢?

為了匹配效率。

所有樣式規則極有可能數量很大,而且絕大多數不會匹配到當前的 DOM 元素,所以有一個快速的方法來判斷「這個 selector 不匹配當前元素」就是極其重要的。

如果正向解析,例如「div div p em」,我們首先就要檢查當前元素到 html 的整條路徑,找到最上層的 div,再往下找,如果遇到不匹配就必須回到最上層那個 div,往下再去匹配選擇器中的第一個 div,回溯若干次才能確定匹配與否,效率很低。

可以看以下的例子:

<div>
   <div class="jartto">
      <p><span> 111 </span></p>
      <p><span> 222 </span></p>
      <p><span> 333 </span></p>
      <p><span class='yellow'> 444 </span></p>
   </div>
</div>
<div>
   <div class="jartto1">
      <p><span> 111 </span></p>
      <p><span> 222 </span></p>
      <p><span> 333 </span></p>
      <p><span class='red'> 555 </span></p>
   </div>
</div>

div > div.jartto p span.yellow{
   color:yellow;
}
複製程式碼

對於上述例子,如果按從左到右的方式進行查詢:

1.先找到所有 div 節點;

2.在 div 節點內找到所有的子 div ,並且是 class = “jartto”

3.然後再依次匹配 p span.yellow 等情況;

4.遇到不匹配的情況,就必須回溯到一開始搜尋的 div 或者 p 節點,然後去搜尋下個節點,重複這樣的過程。

試想一下,如果採用從左至右的方式讀取 CSS 規則,那麼大多數規則讀到最後(最右)才會發現是不匹配的,這樣會做費時耗能,最後有很多都是無用的;而如果採取從右向左的方式,那麼只要發現最右邊選擇器不匹配,就可以直接捨棄了,避免了許多無效匹配。

所以瀏覽器 CSS 匹配核心演算法的規則是以從右向左方式匹配節點的。這樣做是為了減少無效匹配次數,從而匹配快、效能更優。

CSS匹配HTML元素是一個相當複雜和有效能問題的事情。所以,你就會在N多地方看到很多人都告訴你,DOM樹要小,CSS儘量用id和class,千萬不要過渡層疊下去,……

構建渲染樹

經執行過Javascript指令碼後解析出了最終的DOM Tree 和 CSS Rule Tree, 根據這兩者,就能合成我們的Render Tree,網羅網頁上所有可見的 DOM 內容,以及每個節點的所有 CSSOM 樣式資訊。

為構建渲染樹,瀏覽器大體上完成了下列工作:

  1. 從 DOM 樹的根節點開始遍歷每個可見節點。
    • 某些節點不可見(例如指令碼標記、元標記等),因為它們不會體現在渲染輸出中,所以會被忽略。
    • 某些節點通過 CSS 隱藏,因此在渲染樹中也會被忽略,例如,上例中的 span 節點---不會出現在渲染樹中,---因為有一個顯式規則在該節點上設定了“display: none”屬性。
  2. 對於每個可見節點,為其找到適配的 CSSOM 規則並應用它們。
  3. 輸出可見節點,連同其內容和計算的樣式。

渲染的注意事項

在這裡要說下兩個概念,一個是repaint和reflow,這兩個是影響瀏覽器渲染的主要原因:

  • Repaint--重繪,螢幕的某一部分要重新繪製,比如某個DOM元素的背景顏色改動了,但元素的位置大小沒有改變。
  • Reflow--迴流,代表著元素的幾何尺寸(如位置、寬高、隱藏等)變了,我們需要重新驗證並計算Render Tree。是Render Tree的一部分或全部發生了變化。 由此可以看出,我們的Reflow的成本要比Repaint高的多,在一些高效能的電腦上也許還沒什麼,但是如果reflow發生在手機上,那麼這個過程是非常痛苦和耗電的。 這也是JQuery在移動端頁面上使用的障礙。、

我們來看一段javascript程式碼:

var bstyle = document.body.style; // cache
 
bstyle.padding = "20px"; // reflow, repaint
bstyle.border = "10px solid red"; //  再一次的 reflow 和 repaint
 
bstyle.color = "blue"; // repaint
bstyle.backgroundColor = "#fad"; // repaint
 
bstyle.fontSize = "2em"; // reflow, repaint
 
// new DOM element - reflow, repaint
document.body.appendChild(document.createTextNode('dude!'));
複製程式碼

當然,我們的瀏覽器是聰明的,它不會像上面那樣,你每改一次樣式,它就reflow或repaint一次。一般來說,瀏覽器會把這樣的操作積攢一批,然後做一次reflow,這又叫非同步reflow或增量非同步reflow。

雖然瀏覽器會幫我們優化reflow的操作,但在實際開發過程中,我們還是得通過幾種方法去減少reflow的操作

減少reflow/repaint的方法

  1. 不要一條一條地修改DOM的樣式。與其這樣,還不如預先定義好css的class,然後修改DOM的className。

    // bad var left = 10, top = 10; el.style.left = left + "px"; el.style.top = top + "px";

    // Good el.className += " theclassname";

    // Good el.style.cssText += "; left: " + left + "px; top: " + top + "px;";

2)把DOM離線後修改。如:

  • 使用documentFragment 物件在記憶體裡操作DOM
  • 先把DOM給display:none(有一次reflow),然後你想怎麼改就怎麼改。比如修改100次,然後再把他顯示出來。
  • clone一個DOM結點到記憶體裡,然後想怎麼改就怎麼改,改完後,和線上的那個的交換一下。

3)不要把DOM結點的屬性值放在一個迴圈裡當成迴圈裡的變數。不然這會導致大量地讀寫這個結點的屬性。

4)千萬不要使用table佈局。因為可能很小的一個小改動會造成整個table的重新佈局。

5)儘可能的修改層級比較低的DOM。當然,改變層級比較底的DOM有可能會造成大面積的reflow,但是也可能影響範圍很小。


Virtual DOM

Virtual DOM是什麼?

大部分前端開發者對Virtual DOM這個詞都很熟悉了,簡單來講,Virtual DOM就是在資料和真實 DOM 之間建立了一層緩衝層。當資料變化觸發渲染後,並不直接更新到DOM上,而是先生成 Virtual DOM,與上一次渲染得到的 Virtual DOM 進行比對,在渲染得到的 Virtual DOM 上發現變化,然後將變化的地方更新到真實 DOM 上。 
複製程式碼

為什麼說Virtual DOM快?

1)DOM結構複雜,操作很慢

我們在控制檯輸入

var div = document.createElement('div')
var str = '' 
for (var key in div) {
    str = str + key + "\n"
}
console.log(str)
複製程式碼

可以很容易發現,我們的一個空div物件,他的屬性就有幾百個,所以說DOM的操作慢是可以理解的。不是瀏覽器不想好好實現DOM,而是DOM設計得太複雜,沒辦法。

2)JS計算很快

julialang.org/benchmarks/

Julia有一個Benchmark,Julia Benchmarks, 可以看到Javascript跟C語言很接近了,也就幾倍的差距,跟Java基本也是一個量級。 這就說明,單純的Javascript執行起來其實速度是很快的。

而相對於DOM,我們原生的JavaScript物件處理起來則會更快更簡單.

我們通過JavaScript,可以很容易的用JavaScript物件表示出來.

var olE = {
  tagName: 'ul', // 標籤名
  props: { // 屬性用物件儲存鍵值對
    id: 'ul-list',
    class: '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寫法:

<ul id='ol-list'>
  <li class='item'>Item 1</li>
  <li class='item'>Item 2</li>
  <li class='item'>Item 3</li>
</ul>
複製程式碼

那麼,既然我們可以用javascript來表示DOM,那麼代表我們可以用JavaScript來構造我們的真實DOM樹,當我們的DOM樹需要更新了,那我們先渲染更改這個JavaScript構造的Virtual DOM樹,再更新到真實DOM樹上。

所以Virtual DOM演算法就是:

一開始先用 JavaScript 物件結構表示 DOM 樹的結構;然後用這個樹構建一個真正的 DOM 樹,插到文

檔當中。當狀態變更時,重新構造一棵新的物件樹。然後用新的樹和舊的樹進行比較兩個樹的差異。

然後把差異更新到舊的樹上,最後再把整個變更寫入真實 DOM。

簡單Virtual DOM 演算法實現

步驟一:用JS物件模擬DOM樹,並構建

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

// 建立虛擬DOM函式
function Element (tagName, props, children) {
  this.tagName = tagName // 標籤名
  this.props = props // 對應屬性(如ID、Class)
  this.children = children // 子元素
}

module.exports = function (tagName, props, children) {
  return new Element(tagName, props, children)
}
複製程式碼

實際應用如下:

var el = require('./element')
// 普通ul和li物件就可以表示為這樣
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構建真正的

    元素:

// 構建真實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
}
複製程式碼

我們的render方法會根據tagName去構建一個真實的DOM節點,設定節點屬性,再遞迴到子元素構建:

var ulRoot = ul.render() // 將js構建的dom物件傳給render構建
document.body.appendChild(ulRoot) // 真實的DOM物件塞入body
複製程式碼

這樣我們body中就有了ul和li的DOM元素了

<body>
    <ul id='list'>
      <li class='item'>Item 1</li>
      <li class='item'>Item 2</li>
      <li class='item'>Item 3</li>
    </ul>
</body>
複製程式碼

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

在這裡我們假設對我們修改了某個狀態或者某個資料,這就會產生新的虛擬DOM

// 新DOM
var ol = el('ol', {id: 'ol-list'}, [
  el('li', {class: 'ol-item'}, ['Item 1']),
  el('li', {class: 'ol-item'}, ['Item 2']),
  el('li', {class: 'ol-item'}, ['Item 3']),
  el('li', {class: 'ol-item'}, ['Item 4'])
])

// 舊DOM
var ul = el('ul', {id: 'list'}, [
  el('li', {class: 'item'}, ['Item 1']),
  el('li', {class: 'item'}, ['Item 3']),
  el('li', {class: 'item'}, ['Item 2'])
])
複製程式碼

那麼我們會和先和,剛剛上一次生成的虛擬DOM樹進行比對.

我們應該都很清楚,virtual DOM演算法的核心部分,就在比較差異這一部分,也就是所謂的 diff演算法。

因為很少出現跨層級的移動。

diff演算法一般來說,都是同一層級比對同一層級的

var patch = {
    'REPLACE' : 0, // 替換
    'REORDER' : 1, // 新增、刪除、移動
    'PROPS' : 2, // 屬性更改
    'TEXT' : 3 // 文字內容更改
}
複製程式碼

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

// 用陣列儲存新舊節點的不同
patches = [
    // 每個陣列表示一個元素的差異
    [ 
        {difference}, 
    	{difference}
    ],
    [
        {difference}, 
    	{difference}
    ]  
] 

patches[0] = [
  {
  	type: REPALCE,
  	node: newNode // el('section', props, children)
  },
  {
  	type: PROPS,
    props: {
        id: "container"
    }
  },   
  {
  	type: REORDER,
      moves: [
          {index: 2, item: item, type: 1}, // 保留的節點
          {index: 0, type: 0}, // 該節點被刪除
          {index: 1, item: item, type: 1} // 保留的節點
      ]
  }
];
如果是文字節點內容更改,就記錄下:
patches[2] = [{
  type: TEXT,
  content: "我是新修改的文字內容"
}]

// 詳細演算法檢視diff.js
複製程式碼

每種差異都會有不同的對比方式,通過比對後會將差異記錄下來,應用到真實DOM上,並把最近最新的虛擬DOM樹儲存下來,以便下次比對使用。

步驟三:把差異應用到真正的DOM樹上

通過比對後,我們已經知道了,差異的節點是哪些,我們可以方便對真實DOM做最小化的修改。

// 詳情看patch.js
複製程式碼

發現問題

到這裡我們發現一個問題,不是說 Virtual DOM更快嗎? 可是最終你還是要進行DOM操作呀?那意義何在?還不如一開始我們就直接進行DOM操作來的方便。

所以到這裡我們要對Virtual DOM 有一個正確的認識

網上都說操作真實 DOM 慢,但測試結果卻比 React 更快,為什麼?

chrisharrington.github.io/demos/perfo…

最優更改

Virtual DOM的演算法能夠向你保證的就是,每一次的DOM操作我都能達到演算法上的理論最優,而如果是你自己去操作DOM,這並不能保證。

其次

開發模式的更改

為了讓開發者把精力集中在運算元據,而非接管 DOM 操作。Virtual DOM能讓我們在實際開發過程中,不需要去理會複雜的DOM結構,而只需理會繫結DOM結構的狀態和資料即可,這從開發上來說 就是一個很大的進步

相關文章