譯者:Kite
作者:Gethyl George Kurian
原文連結:medium.com/@gethylgeor…
文章已經過時,基於 react v16 的將會近期發出,此文僅供參考
我曾經嘗試去深層而清晰地去理解 Virtual-DOM
的工作原理,也一直在尋找可以更詳細地解釋其工作細節的資料。
由於在我大量搜尋的資料中沒有獲取到一點有用的資料,我最終決定探究 react
和 react-dom
的原始碼來更好地理解它們的工作原理。
但是在我們開始之前,你有思考過為什麼我們不直接渲染
DOM
的更新嗎?
接下來的一節中,我將介紹 DOM
是如何建立的,以及讓你瞭解為什麼 React
一開始就建立了 Virtual-DOM
DOM
是如何建立的
(圖片來自 Mozilla - https://developer.mozilla.org/en-US/docs/Introduction_to_Layout_in_Mozilla)
我不會說太多關於 DOM
是如何建立且是如何繪製到螢幕上的,但可以查閱這裡和這裡去理解將整個 HTML
轉換成 DOM
以及繪製到螢幕的步驟。
因為 DOM
是一個樹形結構,每次DOM
中的某些部分發生變化時,雖然這些變化 已經相當地快了,但它改變的元素不得不經過迴流的步驟,且它的子節點不得不被重繪,因此,如果專案中越多的節點需要經歷迴流/重繪,你的應用就會表現得越慢。
什麼是 Virtual-DOM ? 它嘗試去最小化迴流/重繪步驟,從而在大型且複雜的專案中得到更好的效能。
接下來一節中將會解釋更多有關於Virtual-DOM
如何工作的細節。
理解 Virtual-DOM
既然你已經瞭解了 DOM
是如何構建的,那現在就讓我們去更多地瞭解一下 Virtual-DOM
吧。
在這裡,我會先用一個小型的 app 去解釋 virtual dom
是如何工作的,這樣,你可以容易地去看到它的工作過程。
我不會深入到最初渲染的工作細節,僅關注重新渲染時所發生的事情,這將幫助你去理解
virtual dom
與diff
演算法是如何工作的,一旦你理解了這個過程,理解初始的渲染就變得很簡單:)。
可以在這個git repo 上找到這個 app 的原始碼。這個簡單的計算器介面長這樣:
除了 Main.js
和 Calculator.js
之外,在這個 repo 中的其他檔案都可以不用關心。
// Calculator.js
import React from "react"
import ReactDOM from "react-dom"
export default class Calculator extends React.Component{
constructor(props) {
super(props);
this.state = {output: ""};
}
render(){
let IntegerA,IntegerB,IntegerC;
return(
<div className="container">
<h2>using React</h2>
<div>Input 1:
<input type="text" placeholder="Input 1" ref="input1"></input>
</div>
<div>Input 2 :
<input type="text" placeholder="Input 2" ref="input2"></input>
</div>
<div>
<button id="add" onClick={ () => {
IntegerA = parseInt(ReactDOM.findDOMNode(this.refs.input1).value)
IntegerB = parseInt(ReactDOM.findDOMNode(this.refs.input2).value)
IntegerC = IntegerA+IntegerB
this.setState({output:IntegerC})
}
}>Add</button>
<button id="subtract" onClick={ () => {
IntegerA = parseInt(ReactDOM.findDOMNode(this.refs.input1).value)
IntegerB = parseInt(ReactDOM.findDOMNode(this.refs.input2).value)
IntegerC = IntegerA-IntegerB
this.setState({output:IntegerC})
}
}>Subtract</button>
</div>
<div>
<hr/>
<h2>Output: {this.state.output}</h2>
</div>
</div>
);
}
}
複製程式碼
// Main.js
import React from "react";
import Calculator from "./Calculator"
export default class Layout extends React.Component{
render(){
return(
<div>
<h1>Basic Calculator</h1>
<Calculator/>
</div>
);
}
}
複製程式碼
初始載入時產生的 DOM
長這樣:
(初始渲染後的 DOM)
下面是 React 內部構建的上述 DOM 樹的結構:
現在新增兩個數字並點選「Add」按鈕去更深入的理解
為了去理解 Diff
演算法是如何工作及reconciliation
如何排程 virtual-dom
到真實的DOM
的,在這個計算器中,我將輸入 100 和 50 並點選「Add」按鈕,期待輸出 150:
輸入1: 100
輸入2: 50
輸出: 150
複製程式碼
那麼,當你按下「Add」按鈕時,發生了什麼?
在我們的例子中,當點選了「Add」按鈕,我們 set 了一個包含有輸出值 150 的 state:
// Calculator.js
<button id="add" onClick={() => {
IntegerA = parseInt(ReactDOM.findDOMNode(this.refs.input1).value);
IntegerB = parseInt(ReactDOM.findDOMNode(this.refs.input2).value);
IntegerC = IntegerA+IntegerB;
this.setState({output:IntegerC});
}}>Add</button>
複製程式碼
標記元件
(注: 將發生變化的元件)
首先,讓我們理解第一步,一個元件是如何被標記的:
-
所有的
DOM
事件監聽器都被包裹在React
自定義的事件監聽器中,因此,當點選「Add」按鈕時,這個點選事件被髮送到 react 的事件監聽器,從而執行上面程式碼中你所看到的匿名函式 -
在匿名函式中,我們調取
this.setState
方法得到了一個新的 state 值。 -
這個
setState()
方法將如以下幾行程式碼一樣,依次標記元件。
// ReactUpdates.js - enqueueUpdate(component) function
dirtyComponents.push(component);
複製程式碼
你是否在思考為什麼 react 不直接標記這個 button, 而是標記整個元件?好了,這是因為你用了
this.setState()
來調取setState
方法,而這個 this 指向的就是這個 Calculator 元件
- 所以現在,我們的 Calculator 元件被標記了,讓我們看看接下來又將發生什麼。
遍歷元件的生命週期
很好!現在這個元件被標記了,那麼接下來會發生什麼呢?接下來是更新 virtual dom
,然後使用diff
演算法做 reconciliation
並更新真實的 DOM
在我們進行下一步之前,熟悉元件生命週期的不同之處是非常重要的
以下是我們的 Calculator 元件在 react
中的樣子:
Calculator Wrapper
以下是這個元件被更新的步驟:
-
這是通過
react
執行批量更新而更新的; -
在批量更新中,它會檢查是否元件被標記,然後開始更新。
//ReactUpdates.js
var flushBatchedUpdates = function () {
while (dirtyComponents.length || asapEnqueued) {
if (dirtyComponents.length) {
var transaction = ReactUpdatesFlushTransaction.getPooled();
transaction.perform(runBatchedUpdates, null, transaction);
複製程式碼
- 接下來,它會檢查是否存在必須更新的待處理狀態或是否發出了
forceUpdate
。
if (this._pendingStateQueue !== null || this._pendingForceUpdate) {
this.updateComponent(transaction, this._currentElement, this._currentElement, this._context, this._context);
複製程式碼
在我們的例子中,您可以看到 this._pendingStateQueue
在具有新輸出狀態的計算器包裝器裡
-
首先,它會檢查我們是否使用了
componentWillReceiveProps()
,如果我們使用了,則允許使用收到的props
更新state
。 -
接下來,
react
會檢查我們在元件裡是否使用了shouldComponentUpdate()
,如果我們使用了,我們可以檢查一個元件是否需要根據它的state
或props
的改變而重新渲染。
當你知道不需要重新渲染元件時,請使用此方案,從而提高效能
- 接下來的步驟依次是
componentWillUpdate()
,render()
, 最後是componentDidUpdate()
從第 4,5 和 6 步, 我們只使用
render()
- 現在,讓我們深入看看
render()
期間發生了什麼?
渲染即是
Virtual-DOM
比較差異並重新構建
渲染元件 - 更新 Virtual-DOM
, 執行diff
演算法並更新到真實的DOM
中
在我們的例子中,所有在這個元件裡的元素都會在 Virtual-DOM
中被重新構建
它會檢查相鄰已渲染的元素是否具有相同的型別和鍵,然後協調這個型別與鍵匹配的元件。
var prevRenderedElement = this._renderedComponent._currentElement;
//Calculator.render() method is called and the element is build.
var nextRenderedElement = this._instance.render();
複製程式碼
有一個重要的點就是這裡是呼叫元件
render
方法的地方。比如,Calculator.render()
這個 reconciliation
過程通常採用以下步驟:
元件的 render 方法 - 更新Virtual DOM,執行 diff 演算法,最後更新 DOM
紅色虛線意味著所有的
reconciliation
步驟都將在下一個子節點及子節點中的子節點裡重複。
上述的流程圖總結了 Virtual DOM
是如何更新實際 DOM 的。
我可能在知情或不知情的情況下錯過了幾個步驟,但此圖表涵蓋了大部分關鍵步驟。
因此,你可以在我們的示例中看到這個reconciliation
是如何像以下這樣進行運作的:
我先跳過前一個<div>
的 reconciliation
,引導你看看 DOM
變成 Output:150
的更新步驟,
Reconciliation
從這個元件的類名為 "container" 的<div>
開始- 它的孩子是一個包含了輸出的
<div>
, 因此,react
將從這個子節點開始reconciliation
- 現在這個子節點擁有了子節點
<hr>
和<h2>
- 所以
react
將為<hr>
執行reconciliation
- 接下來,它將從
<h2>
的reconciliation
開始,因為它有自己的子節點,即輸出和state
的輸出,它將開始對這兩個進行reconciliation
- 第一個輸出文字經過了
reconciliation
,因為它沒有任何變化,所以DOM
沒有什麼需要改變。 - 接下來,來自
state
的輸出經過reconciliation
,因為我們現在有了一個新值,即 150,react
會更新真實的DOM
。 ...
真實 DOM
的渲染
我們的例子中,在 reconciliation
期間,只有輸出欄位有如下所示的更改和在開發人員控制檯出現繪製閃爍。
僅重繪輸出
以及在真實 DOM
上更新的元件樹
結論
結論雖然這個例子非常簡單,但它可以讓你基本瞭解react
內部所發生的事情。
我沒有選擇更復雜的應用程式是因為繪製整個元件樹真的很煩人。:-|
reconciliation
過程就是 React
- 比較前一個的內部例項與下一個內部例項
- 更新內部例項
Virtual DOM
(JavaScript
物件) 中的元件樹結構。 - 僅更新存在實際變化的節點及其子節點的真實
DOM
。
(注: 作者文中的 react
版本是 v15.4.1
)