導讀
React
的虛擬DOM
和Diff
演算法是React
的非常重要的核心特性,這部分原始碼也非常複雜,理解這部分知識的原理對更深入的掌握React
是非常必要的。
本來想將虛擬DOM
和Diff
演算法放到一篇文章,寫完虛擬DOM
發現文章已經很長了,所以本篇只分析虛擬DOM
。
本篇文章從原始碼出發,分析虛擬DOM
的核心渲染原理(首次渲染),以及React
對它做的效能優化點。
說實話React
原始碼真的很難讀?,如果本篇文章幫助到了你,那麼請給個贊?支援一下吧。
開發中的常見問題
- 為何必須引用
React
- 自定義的
React
元件為何必須大寫 React
如何防止XSS
React
的Diff
演算法和其他的Diff
演算法有何區別key
在React
中的作用- 如何寫出高效能的
React
元件
如果你對上面幾個問題還存在疑問,說明你對React
的虛擬DOM
以及Diff
演算法實現原理還有所欠缺,那麼請好好閱讀本篇文章吧。
首先我們來看看到底什麼是虛擬DOM
:
虛擬DOM
在原生的JavaScript
程式中,我們直接對DOM
進行建立和更改,而DOM
元素通過我們監聽的事件和我們的應用程式進行通訊。
而React
會先將你的程式碼轉換成一個JavaScript
物件,然後這個JavaScript
物件再轉換成真實DOM
。這個JavaScript
物件就是所謂的虛擬DOM
。
比如下面一段html
程式碼:
<div class="title">
<span>Hello ConardLi</span>
<ul>
<li>蘋果</li>
<li>橘子</li>
</ul>
</div>
複製程式碼
在React
可能儲存為這樣的JS
程式碼:
const VitrualDom = {
type: 'div',
props: { class: 'title' },
children: [
{
type: 'span',
children: 'Hello ConardLi'
},
{
type: 'ul',
children: [
{ type: 'li', children: '蘋果' },
{ type: 'li', children: '橘子' }
]
}
]
}
複製程式碼
當我們需要建立或更新元素時,React
首先會讓這個VitrualDom
物件進行建立和更改,然後再將VitrualDom
物件渲染成真實DOM
;
當我們需要對DOM
進行事件監聽時,首先對VitrualDom
進行事件監聽,VitrualDom
會代理原生的DOM
事件從而做出響應。
為何使用虛擬DOM
React
為何採用VitrualDom
這種方案呢?
提高開發效率
使用JavaScript
,我們在編寫應用程式時的關注點在於如何更新DOM
。
使用React
,你只需要告訴React
你想讓檢視處於什麼狀態,React
則通過VitrualDom
確保DOM
與該狀態相匹配。你不必自己去完成屬性操作、事件處理、DOM
更新,React
會替你完成這一切。
這讓我們更關注我們的業務邏輯而非DOM
操作,這一點即可大大提升我們的開發效率。
關於提升效能
很多文章說VitrualDom
可以提升效能,這一說法實際上是很片面的。
直接操作DOM
是非常耗費效能的,這一點毋庸置疑。但是React
使用VitrualDom
也是無法避免操作DOM
的。
如果是首次渲染,VitrualDom
不具有任何優勢,甚至它要進行更多的計算,消耗更多的記憶體。
VitrualDom
的優勢在於React
的Diff
演算法和批處理策略,React
在頁面更新之前,提前計算好了如何進行更新和渲染DOM
。實際上,這個計算過程我們在直接操作DOM
時,也是可以自己判斷和實現的,但是一定會耗費非常多的精力和時間,而且往往我們自己做的是不如React
好的。所以,在這個過程中React
幫助我們"提升了效能"。
所以,我更傾向於說,VitrualDom
幫助我們提高了開發效率,在重複渲染時它幫助我們計算如何更高效的更新,而不是它比DOM
操作更快。
如果您對本部分的分析有什麼不同見解,歡迎在評論區拍磚。
跨瀏覽器相容
React
基於VitrualDom
自己實現了一套自己的事件機制,自己模擬了事件冒泡和捕獲的過程,採用了事件代理,批量更新等方法,抹平了各個瀏覽器的事件相容性問題。
跨平臺相容
VitrualDom
為React
帶來了跨平臺渲染的能力。以React Native
為例子。React
根據VitrualDom
畫出相應平臺的ui
層,只不過不同平臺畫的姿勢不同而已。
虛擬DOM實現原理
如果你不想看繁雜的原始碼,或者現在沒有足夠時間,可以跳過這一章,直接?虛擬DOM原理、特性總結
在上面的圖上我們繼續進行擴充套件,按照圖中的流程,我們依次來分析虛擬DOM
的實現原理。
JSX和createElement
我們在實現一個React
元件時可以選擇兩種編碼方式,第一種是使用JSX
編寫:
class Hello extends Component {
render() {
return <div>Hello ConardLi</div>;
}
}
複製程式碼
第二種是直接使用React.createElement
編寫:
class Hello extends Component {
render() {
return React.createElement('div', null, `Hello ConardLi`);
}
}
複製程式碼
實際上,上面兩種寫法是等價的,JSX
只是為 React.createElement(component, props, ...children)
方法提供的語法糖。也就是說所有的JSX
程式碼最後都會轉換成React.createElement(...)
,Babel
幫助我們完成了這個轉換的過程。
如下面的JSX
<div>
<img src="avatar.png" className="profile" />
<Hello />
</div>;
複製程式碼
將會被Babel
轉換為
React.createElement("div", null, React.createElement("img", {
src: "avatar.png",
className: "profile"
}), React.createElement(Hello, null));
複製程式碼
注意,babel
在編譯時會判斷JSX
中元件的首字母,當首字母為小寫時,其被認定為原生DOM
標籤,createElement
的第一個變數被編譯為字串;當首字母為大寫時,其被認定為自定義元件,createElement
的第一個變數被編譯為物件;
另外,由於JSX
提前要被Babel
編譯,所以JSX
是不能在執行時動態選擇型別的,比如下面的程式碼:
function Story(props) {
// Wrong! JSX type can't be an expression.
return <components[props.storyType] story={props.story} />;
}
複製程式碼
需要變成下面的寫法:
function Story(props) {
// Correct! JSX type can be a capitalized variable.
const SpecificStory = components[props.storyType];
return <SpecificStory story={props.story} />;
}
複製程式碼
所以,使用JSX
你需要安裝Babel
外掛babel-plugin-transform-react-jsx
{
"plugins": [
["transform-react-jsx", {
"pragma": "React.createElement"
}]
]
}
複製程式碼
建立虛擬DOM
下面我們來看看虛擬DOM
的真實模樣,將下面的JSX
程式碼在控制檯列印出來:
<div className="title">
<span>Hello ConardLi</span>
<ul>
<li>蘋果</li>
<li>橘子</li>
</ul>
</div>
複製程式碼
這個結構和我們上面自己描繪的結構很像,那麼React
是如何將我們的程式碼轉換成這個結構的呢,下面我們來看看createElement
函式的具體實現(文中的原始碼經過精簡)。
createElement
函式內部做的操作很簡單,將props
和子元素進行處理後返回一個ReactElement
物件,下面我們來逐一分析:
(1).處理props:
- 1.將特殊屬性
ref
、key
從config
中取出並賦值 - 2.將特殊屬性
self
、source
從config
中取出並賦值 - 3.將除特殊屬性的其他屬性取出並賦值給
props
後面的文章會詳細介紹這些特殊屬性的作用。
(2).獲取子元素
- 1.獲取子元素的個數 —— 第二個引數後面的所有引數
- 2.若只有一個子元素,賦值給
props.children
- 3.若有多個子元素,將子元素填充為一個陣列賦值給
props.children
(3).處理預設props
- 將元件的靜態屬性
defaultProps
定義的預設props
進行賦值
ReactElement
ReactElement
將傳入的幾個屬性進行組合,並返回。
type
:元素的型別,可以是原生html型別(字串),或者自定義元件(函式或class
)key
:元件的唯一標識,用於Diff
演算法,下面會詳細介紹ref
:用於訪問原生dom
節點props
:傳入元件的props
owner
:當前正在構建的Component
所屬的Component
$$typeof
:一個我們不常見到的屬性,它被賦值為REACT_ELEMENT_TYPE
:
var REACT_ELEMENT_TYPE =
(typeof Symbol === 'function' && Symbol.for && Symbol.for('react.element')) ||
0xeac7;
複製程式碼
可見,$$typeof
是一個Symbol
型別的變數,這個變數可以防止XSS
。
如果你的伺服器有一個漏洞,允許使用者儲存任意JSON
物件, 而客戶端程式碼需要一個字串,這可能會成為一個問題:
// JSON
let expectedTextButGotJSON = {
type: 'div',
props: {
dangerouslySetInnerHTML: {
__html: '/* put your exploit here */'
},
},
};
let message = { text: expectedTextButGotJSON };
<p>
{message.text}
</p>
複製程式碼
JSON
中不能儲存Symbol
型別的變數。
ReactElement.isValidElement
函式用來判斷一個React
元件是否是有效的,下面是它的具體實現。
ReactElement.isValidElement = function (object) {
return typeof object === 'object' && object !== null && object.$$typeof === REACT_ELEMENT_TYPE;
};
複製程式碼
可見React
渲染時會把沒有$$typeof
標識,以及規則校驗不通過的元件過濾掉。
當你的環境不支援Symbol
時,$$typeof
被賦值為0xeac7
,至於為什麼,React
開發者給出了答案:
0xeac7
看起來有點像React
。
self
、source
只有在非生產環境才會被加入物件中。
self
指定當前位於哪個元件例項。_source
指定除錯程式碼來自的檔案(fileName
)和程式碼行數(lineNumber
)。
虛擬DOM轉換為真實DOM
上面我們分析了程式碼轉換成了虛擬DOM
的過程,下面來看一下React
如何將虛擬DOM
轉換成真實DOM
。
本部分邏輯較複雜,我們先用流程圖梳理一下整個過程,整個過程大概可分為四步:
過程1:初始引數處理
在編寫好我們的React
元件後,我們需要呼叫ReactDOM.render(element, container[, callback])
將元件進行渲染。
render
函式內部實際呼叫了_renderSubtreeIntoContainer
,我們來看看它的具體實現:
render: function (nextElement, container, callback) {
return ReactMount._renderSubtreeIntoContainer(null, nextElement, container, callback);
},
複製程式碼
- 1.將當前元件使用
TopLevelWrapper
進行包裹
TopLevelWrapper
只一個空殼,它為你需要掛載的元件提供了一個rootID
屬性,並在render
函式中返回該元件。
TopLevelWrapper.prototype.render = function () {
return this.props.child;
};
複製程式碼
ReactDOM.render
函式的第一個引數可以是原生DOM
也可以是React
元件,包裹一層TopLevelWrapper
可以在後面的渲染中將它們進行統一處理,而不用關心是否原生。
- 2.判斷根結點下是否已經渲染過元素,如果已經渲染過,判斷執行更新或者解除安裝操作
- 3.處理
shouldReuseMarkup
變數,該變數表示是否需要重新標記元素 - 4.呼叫將上面處理好的引數傳入
_renderNewRootComponent
,渲染完成後呼叫callback
。
在_renderNewRootComponent
中呼叫instantiateReactComponent
對我們傳入的元件進行分類包裝:
根據元件的型別,React
根據原元件建立了下面四大類元件,對元件進行分類渲染:
ReactDOMEmptyComponent
:空元件ReactDOMTextComponent
:文字ReactDOMComponent
:原生DOM
ReactCompositeComponent
:自定義React
元件
他們都具備以下三個方法:
construct
:用來接收ReactElement
進行初始化。mountComponent
:用來生成ReactElement
對應的真實DOM
或DOMLazyTree
。unmountComponent
:解除安裝DOM
節點,解綁事件。
具體是如何渲染我們在過程3中進行分析。
過程2:批處理、事務呼叫
在_renderNewRootComponent
中使用ReactUpdates.batchedUpdates
呼叫batchedMountComponentIntoNode
進行批處理。
ReactUpdates.batchedUpdates(batchedMountComponentIntoNode, componentInstance, container, shouldReuseMarkup, context);
複製程式碼
在batchedMountComponentIntoNode
中,使用transaction.perform
呼叫mountComponentIntoNode
讓其基於事務機制進行呼叫。
transaction.perform(mountComponentIntoNode, null, componentInstance, container, transaction, shouldReuseMarkup, context);
複製程式碼
關於批處理事務,在我前面的分析setState執行機制中有更多介紹。
過程3:生成html
在mountComponentIntoNode
函式中呼叫ReactReconciler.mountComponent
生成原生DOM
節點。
mountComponent
內部實際上是呼叫了過程1生成的四種物件的mountComponent
方法。首先來看一下ReactDOMComponent
:
- 1.對特殊
DOM
標籤、props
進行處理。 - 2.根據標籤型別建立
DOM
節點。 - 3.呼叫
_updateDOMProperties
將props
插入到DOM
節點,_updateDOMProperties
也可用於props Diff
,第一個引數為上次渲染的props
,第二個引數為當前props
,若第一個引數為空,則為首次建立。 - 4.生成一個
DOMLazyTree
物件並呼叫_createInitialChildren
將孩子節點渲染到上面。
那麼為什麼不直接生成一個DOM
節點而是要建立一個DOMLazyTree
呢?我們先來看看_createInitialChildren
做了什麼:
判斷當前節點的dangerouslySetInnerHTML
屬性、孩子節點是否為文字和其他節點分別呼叫DOMLazyTree
的queueHTML
、queueText
、queueChild
。
可以發現:DOMLazyTree
實際上是一個包裹物件,node
屬性中儲存了真實的DOM
節點,children
、html
、text
分別儲存孩子、html節點和文字節點。
它提供了幾個方法用於插入孩子、html
以及文字節點,這些插入都是有條件限制的,當enableLazy
屬性為true
時,這些孩子、html
以及文字節點會被插入到DOMLazyTree
物件中,當其為false
時會插入到真實DOM
節點中。
var enableLazy = typeof document !== 'undefined' &&
typeof document.documentMode === 'number' ||
typeof navigator !== 'undefined' &&
typeof navigator.userAgent === 'string' &&
/\bEdge\/\d/.test(navigator.userAgent);
複製程式碼
可見:enableLazy
是一個變數,當前瀏覽器是IE
或Edge
時為true
。
在IE(8-11)
和Edge
瀏覽器中,一個一個插入無子孫的節點,效率要遠高於插入一整個序列化完整的節點樹。
所以lazyTree
主要解決的是在IE(8-11)
和Edge
瀏覽器中插入節點的效率問題,在後面的過程4我們會分析到:若當前是IE
或Edge
,則需要遞迴插入DOMLazyTree
中快取的子節點,其他瀏覽器只需要插入一次當前節點,因為他們的孩子已經被渲染好了,而不用擔心效率問題。
下面來看一下ReactCompositeComponent
,由於程式碼非常多這裡就不再貼這個模組的程式碼,其內部主要做了以下幾步:
- 處理
props
、contex
等變數,呼叫建構函式建立元件例項 - 判斷是否為無狀態元件,處理
state
- 呼叫
performInitialMount
生命週期,處理子節點,獲取markup
。 - 呼叫
componentDidMount
生命週期
在performInitialMount
函式中,首先呼叫了componentWillMount
生命週期,由於自定義的React
元件並不是一個真實的DOM,所以在函式中又呼叫了孩子節點的mountComponent
。這也是一個遞迴的過程,當所有孩子節點渲染完成後,返回markup
並呼叫componentDidMount
。
過程4:渲染html
在mountComponentIntoNode
函式中呼叫將上一步生成的markup
插入container
容器。
在首次渲染時,_mountImageIntoNode
會清空container
的子節點後呼叫DOMLazyTree.insertTreeBefore
:
判斷是否為fragment
節點或者<object>
外掛:
-
如果是以上兩種,首先呼叫
insertTreeChildren
將此節點的孩子節點渲染到當前節點上,再將渲染完的節點插入到html
-
如果是其他節點,先將節點插入到插入到
html
,再呼叫insertTreeChildren
將孩子節點插入到html
。 -
若當前不是
IE
或Edge
,則不需要再遞迴插入子節點,只需要插入一次當前節點。
- 判斷不是
IE
或bEdge
時return
- 若
children
不為空,遞迴insertTreeBefore
進行插入 - 渲染html節點
- 渲染文字節點
原生DOM事件代理
有關虛擬DOM
的事件機制,我曾專門寫過一篇文章,有興趣可以?【React深入】React事件機制
虛擬DOM原理、特性總結
React元件的渲染流程
-
使用
React.createElement
或JSX
編寫React
元件,實際上所有的JSX
程式碼最後都會轉換成React.createElement(...)
,Babel
幫助我們完成了這個轉換的過程。 -
createElement
函式對key
和ref
等特殊的props
進行處理,並獲取defaultProps
對預設props
進行賦值,並且對傳入的孩子節點進行處理,最終構造成一個ReactElement
物件(所謂的虛擬DOM
)。 -
ReactDOM.render
將生成好的虛擬DOM
渲染到指定容器上,其中採用了批處理、事務等機制並且對特定瀏覽器進行了效能優化,最終轉換為真實DOM
。
虛擬DOM的組成
即ReactElement
element物件,我們的元件最終會被渲染成下面的結構:
type
:元素的型別,可以是原生html型別(字串),或者自定義元件(函式或class
)key
:元件的唯一標識,用於Diff
演算法,下面會詳細介紹ref
:用於訪問原生dom
節點props
:傳入元件的props
,chidren
是props
中的一個屬性,它儲存了當前元件的孩子節點,可以是陣列(多個孩子節點)或物件(只有一個孩子節點)owner
:當前正在構建的Component
所屬的Component
self
:(非生產環境)指定當前位於哪個元件例項_source
:(非生產環境)指定除錯程式碼來自的檔案(fileName
)和程式碼行數(lineNumber
)
防止XSS
ReactElement
物件還有一個$$typeof
屬性,它是一個Symbol
型別的變數Symbol.for('react.element')
,當環境不支援Symbol
時,$$typeof
被賦值為0xeac7
。
這個變數可以防止XSS
。如果你的伺服器有一個漏洞,允許使用者儲存任意JSON
物件, 而客戶端程式碼需要一個字串,這可能為你的應用程式帶來風險。JSON
中不能儲存Symbol
型別的變數,而React
渲染時會把沒有$$typeof
標識的元件過濾掉。
批處理和事務
React
在渲染虛擬DOM
時應用了批處理以及事務機制,以提高渲染效能。
關於批處理以及事務機制,在我之前的文章【React深入】setState的執行機制中有詳細介紹。
針對性的效能優化
在IE(8-11)
和Edge
瀏覽器中,一個一個插入無子孫的節點,效率要遠高於插入一整個序列化完整的節點樹。
React
通過lazyTree
,在IE(8-11)
和Edge
中進行單個節點依次渲染節點,而在其他瀏覽器中則首先將整個大的DOM
結構構建好,然後再整體插入容器。
並且,在單獨渲染節點時,React
還考慮了fragment
等特殊節點,這些節點則不會一個一個插入渲染。
虛擬DOM事件機制
React
自己實現了一套事件機制,其將所有繫結在虛擬DOM
上的事件對映到真正的DOM
事件,並將所有的事件都代理到document
上,自己模擬了事件冒泡和捕獲的過程,並且進行統一的事件分發。
React
自己構造了合成事件物件SyntheticEvent
,這是一個跨瀏覽器原生事件包裝器。 它具有與瀏覽器原生事件相同的介面,包括stopPropagation()
和preventDefault()
等等,在所有瀏覽器中他們工作方式都相同。這抹平了各個瀏覽器的事件相容性問題。
上面只分析虛擬DOM
首次渲染的原理和過程,當然這並不包括虛擬 DOM
進行 Diff
的過程,下一篇文章我們再來詳細探討。
關於開篇提的幾個問題,我們在下篇文章中進行統一回答。
推薦閱讀
末尾
本文原始碼中的版本為React
15版本,相對16
版本會有一些出入,關於16
版本的改動,後面的文章會單獨分析。
文中如有錯誤,歡迎在評論區指正,或者您對文章的排版,閱讀體驗有什麼好的建議,歡迎在評論區指出,謝謝閱讀。
想閱讀更多優質文章、下載文章中思維導圖原始檔、閱讀文中demo
原始碼、可關注我的github部落格,你的star✨、點贊和關注是我持續創作的動力!
推薦關注我的微信公眾號【code祕密花園】,每天推送高質量文章,我們一起交流成長。