【React深入】深入分析虛擬DOM的渲染過程和特性

導讀

React的虛擬 DOMDiff算法是 React的非常重要的核心特性,這部分源碼也非常複雜,理解這部分知識的原理對更深入的掌握 React是非常必要的。

本來想將虛擬 DOMDiff算法放到一篇文章,寫完虛擬 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代碼:


 
  1. <div class="title">

  2. <span>Hello ConardLi</span>

  3. <ul>

  4. <li>蘋果</li>

  5. <li>橘子</li>

  6. </ul>

  7. </div>

React可能存儲爲這樣的 JS代碼:


 
  1. const VitrualDom = {

  2. type: 'div',

  3. props: { class: 'title' },

  4. children: [

  5. {

  6. type: 'span',

  7. children: 'Hello ConardLi'

  8. },

  9. {

  10. type: 'ul',

  11. children: [

  12. { type: 'ul', children: '蘋果' },

  13. { type: 'ul', children: '橘子' }

  14. ]

  15. }

  16. ]

  17. }

當我們需要創建或更新元素時, 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的優勢在於 ReactDiff算法和批處理策略, React在頁面更新之前,提前計算好了如何進行更新和渲染 DOM。實際上,這個計算過程我們在直接操作 DOM時,也是可以自己判斷和實現的,但是一定會耗費非常多的精力和時間,而且往往我們自己做的是不如 React好的。所以,在這個過程中 React幫助我們"提升了性能"。

所以,我更傾向於說, VitrualDom幫助我們提高了開發效率,在重複渲染時它幫助我們計算如何更高效的更新,而不是它比 DOM操作更快。

如果您對本部分的分析有什麼不同見解,歡迎在評論區拍磚。

跨瀏覽器兼容

React基於 VitrualDom自己實現了一套自己的事件機制,自己模擬了事件冒泡和捕獲的過程,採用了事件代理,批量更新等方法,抹平了各個瀏覽器的事件兼容性問題。

跨平臺兼容

VitrualDomReact帶來了跨平臺渲染的能力。以 ReactNative爲例子。 React根據 VitrualDom畫出相應平臺的 ui層,只不過不同平臺畫的姿勢不同而已。

虛擬DOM實現原理

如果你不想看繁雜的源碼,或者現在沒有足夠時間,可以跳過這一章,直接👇虛擬DOM原理總結

在上面的圖上我們繼續進行擴展,按照圖中的流程,我們依次來分析虛擬 DOM的實現原理。

JSX和createElement

我們在實現一個 React組件時可以選擇兩種編碼方式,第一種是使用 JSX編寫:


 
  1. class Hello extends Component {

  2. render() {

  3. return <div>Hello ConardLi</div>;

  4. }

  5. }

第二種是直接使用 React.createElement編寫:


 
  1. class Hello extends Component {

  2. render() {

  3. return React.createElement('div', null, `Hello ConardLi`);

  4. }

  5. }

實際上,上面兩種寫法是等價的, JSX只是爲 React.createElement(component,props,...children)方法提供的語法糖。也就是說所有的 JSX代碼最後都會轉換成 React.createElement(...)Babel幫助我們完成了這個轉換的過程。

如下面的 JSX


 
  1. <div>

  2. <img src="avatar.png" className="profile" />

  3. <Hello />

  4. </div>;

將會被 Babel轉換爲


 
  1. React.createElement("div", null, React.createElement("img", {

  2. src: "avatar.png",

  3. className: "profile"

  4. }), React.createElement(Hello, null));

注意, babel在編譯時會判斷 JSX中組件的首字母,當首字母爲小寫時,其被認定爲原生 DOM標籤, createElement的第一個變量被編譯爲字符串;當首字母爲大寫時,其被認定爲自定義組件, createElement的第一個變量被編譯爲對象;

另外,由於 JSX提前要被 Babel編譯,所以 JSX是不能在運行時動態選擇類型的,比如下面的代碼:


 
  1. function Story(props) {

  2. // Wrong! JSX type can't be an expression.

  3. return <components[props.storyType] story={props.story} />;

  4. }

需要變成下面的寫法:


 
  1. function Story(props) {

  2. // Correct! JSX type can be a capitalized variable.

  3. const SpecificStory = components[props.storyType];

  4. return <SpecificStory story={props.story} />;

  5. }

所以,使用 JSX你需要安裝 Babel插件 babel-plugin-transform-react-jsx


 
  1. {

  2. "plugins": [

  3. ["transform-react-jsx", {

  4. "pragma": "React.createElement"

  5. }]

  6. ]

  7. }

創建虛擬DOM

下面我們來看看虛擬 DOM的真實模樣,將下面的 JSX代碼在控制檯打印出來:


 
  1. <div className="title">

  2. <span>Hello ConardLi</span>

  3. <ul>

  4. <li>蘋果</li>

  5. <li>橘子</li>

  6. </ul>

  7. </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


 
  1. var REACT_ELEMENT_TYPE =

  2. (typeof Symbol === 'function' && Symbol.for && Symbol.for('react.element')) ||

  3. 0xeac7;

可見, $$typeof是一個 Symbol類型的變量,這個變量可以防止 XSS

如果你的服務器有一個漏洞,允許用戶存儲任意 JSON對象, 而客戶端代碼需要一個字符串,這可能會成爲一個問題:


 
  1. // JSON

  2. let expectedTextButGotJSON = {

  3. type: 'div',

  4. props: {

  5. dangerouslySetInnerHTML: {

  6. __html: '/* put your exploit here */'

  7. },

  8. },

  9. };

  10. let message = { text: expectedTextButGotJSON };

  11. <p>

  12. {message.text}

  13. </p>

JSON中不能存儲 Symbol類型的變量。

ReactElement.isValidElement函數用來判斷一個 React組件是否是有效的,下面是它的具體實現。


 
  1. ReactElement.isValidElement = function (object) {

  2. return typeof object === 'object' && object !== null && object.$$typeof === REACT_ELEMENT_TYPE;

  3. };

可見 React渲染時會把沒有 $$typeof標識,以及規則校驗不通過的組件過濾掉。

當你的環境不支持 Symbol時, $$typeof被賦值爲 0xeac7,至於爲什麼, React開發者給出了答案:

0xeac7看起來有點像 React

selfsource只有在非生產環境纔會被加入對象中。

  • self指定當前位於哪個組件實例。

  • _source指定調試代碼來自的文件( fileName)和代碼行數( lineNumber)。

虛擬DOM轉換爲真實DOM

上面我們分析了代碼轉換成了虛擬 DOM的過程,下面來看一下 React如何將虛擬 DOM轉換成真實 DOM

本部分邏輯較複雜,我們先用流程圖梳理一下整個過程,整個過程大概可分爲四步:

過程1:初始參數處理

在編寫好我們的 React組件後,我們需要調用 ReactDOM.render(element,container[,callback])將組件進行渲染。

render函數內部實際調用了 _renderSubtreeIntoContainer,我們來看看它的具體實現:


 
  1. render: function (nextElement, container, callback) {

  2. return ReactMount._renderSubtreeIntoContainer(null, nextElement, container, callback);

  3. },

  • 1.將當前組件使用 TopLevelWrapper進行包裹

TopLevelWrapper只一個空殼,它爲你需要掛載的組件提供了一個 rootID屬性,並在 render函數中返回該組件。


 
  1. TopLevelWrapper.prototype.render = function () {

  2. return this.props.child;

  3. };

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進行批處理。


 
  1. ReactUpdates.batchedUpdates(batchedMountComponentIntoNode, componentInstance, container, shouldReuseMarkup, context);

batchedMountComponentIntoNode中,使用 transaction.perform調用 mountComponentIntoNode讓其基於事務機制進行調用。


 
  1. 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屬性、孩子節點是否爲文本和其他節點分別調用 DOMLazyTreequeueHTMLqueueTextqueueChild

可以發現: DOMLazyTree實際上是一個包裹對象, node屬性中存儲了真實的 DOM節點, childrenhtmltext分別存儲孩子、html節點和文本節點。

它提供了幾個方法用於插入孩子、 html以及文本節點,這些插入都是有條件限制的,當 enableLazy屬性爲 true時,這些孩子、 html以及文本節點會被插入到 DOMLazyTree對象中,當其爲 false時會插入到真實 DOM節點中。


 
  1. var enableLazy = typeof document !== 'undefined' &&

  2. typeof document.documentMode === 'number' ||

  3. typeof navigator !== 'undefined' &&

  4. typeof navigator.userAgent === 'string' &&

  5. /\bEdge\/\d/.test(navigator.userAgent);

可見: enableLazy是一個變量,當前瀏覽器是 IEEdge時爲 true

IE(8-11)Edge瀏覽器中,一個一個插入無子孫的節點,效率要遠高於插入一整個序列化完整的節點樹。

所以 lazyTree主要解決的是在 IE(8-11)Edge瀏覽器中插入節點的效率問題,在後面的過程4我們會分析到:若當前是 IEEdge,則需要遞歸插入 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

     

  •  

    若當前不是 IEEdge,則不需要再遞歸插入子節點,只需要插入一次當前節點。

     

  • 判斷不是 IE或 bEdge時 return

  • 若 children不爲空,遞歸 insertTreeBefore進行插入

  • 渲染html節點

  • 渲染文本節點

原生DOM事件代理

有關虛擬 DOM的事件機制,我曾專門寫過一篇文章,有興趣可以👇【React深入】React事件機制

虛擬DOM原理、特性總結

React組件的渲染流程

  •  

    使用 React.createElementJSX編寫 React組件,實際上所有的 JSX代碼最後都會轉換成 React.createElement(...)Babel幫助我們完成了這個轉換的過程。

     

  •  

    createElement函數對 keyref等特殊的 props進行處理,並獲取 defaultProps對默認 props進行賦值,並且對傳入的孩子節點進行處理,最終構造成一個 ReactElement對象(所謂的虛擬 DOM)。

     

  •  

    ReactDOM.render將生成好的虛擬 DOM渲染到指定容器上,其中採用了批處理、事務等機制並且對特定瀏覽器進行了性能優化,最終轉換爲真實 DOM

     

虛擬DOM的組成

ReactElementelement對象,我們的組件最終會被渲染成下面的結構:

  • 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的過程,下一篇文章我們再來詳細探討。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章