導讀
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
帶來了跨平臺渲染的能力。以 ReactNative
爲例子。 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
也可用於propsDiff
,第一個參數爲上次渲染的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
的過程,下一篇文章我們再來詳細探討。