導讀
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: 'ul', children: '蘋果' },
{ type: 'ul', 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
的過程,下一篇文章我們再來詳細探討。
關於開篇提的幾個問題,我們在下篇文章中進行統一回答。
推薦閱讀
末尾
文中如有錯誤,歡迎在評論區指正,或者您對文章的排版,閱讀體驗有什麼好的建議,歡迎在評論區指出,謝謝閱讀。
想閱讀更多優質文章、下載文章中思維導圖源文件、閱讀文中demo
源碼、可關注我的github博客,你的star✨、點贊和關注是我持續創作的動力!
推薦關注我的微信公衆號【code祕密花園】,每天推送高質量文章,我們一起交流成長。