【React深刻】深刻分析虛擬DOM的渲染原理和特性

導讀

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

原本想將虛擬DOMDiff算法放到一篇文章,寫完虛擬DOM發現文章已經很長了,因此本篇只分析虛擬DOMnode

本篇文章從源碼出發,分析虛擬DOM的核心渲染原理(首次渲染),以及React對它作的性能優化點。react

說實話React源碼真的很難讀😅,若是本篇文章幫助到了你,那麼請給個贊👍支持一下吧。git

開發中的常見問題

  • 爲什麼必須引用React
  • 自定義的React組件爲什麼必須大寫
  • React如何防止XSS
  • ReactDiff算法和其餘的Diff算法有何區別
  • keyReact中的做用
  • 如何寫出高性能的React組件

若是你對上面幾個問題還存在疑問,說明你對React的虛擬DOM以及Diff算法實現原理還有所欠缺,那麼請好好閱讀本篇文章吧。github

首先咱們來看看到底什麼是虛擬DOM:web

虛擬DOM

image

在原生的JavaScript程序中,咱們直接對DOM進行建立和更改,而DOM元素經過咱們監聽的事件和咱們的應用程序進行通信。算法

React會先將你的代碼轉換成一個JavaScript對象,而後這個JavaScript對象再轉換成真實DOM。這個JavaScript對象就是所謂的虛擬DOMexpress

好比下面一段html代碼:api

<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的優點在於ReactDiff算法和批處理策略,React在頁面更新以前,提早計算好了如何進行更新和渲染DOM。實際上,這個計算過程咱們在直接操做DOM時,也是能夠本身判斷和實現的,可是必定會耗費很是多的精力和時間,並且每每咱們本身作的是不如React好的。因此,在這個過程當中React幫助咱們"提高了性能"。

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

若是您對本部分的分析有什麼不一樣看法,歡迎在評論區拍磚。

跨瀏覽器兼容

image

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

跨平臺兼容

image

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

虛擬DOM實現原理

若是你不想看繁雜的源碼,或者如今沒有足夠時間,能夠跳過這一章,直接👇虛擬DOM原理、特性總結

image

在上面的圖上咱們繼續進行擴展,按照圖中的流程,咱們依次來分析虛擬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>
複製代碼

image

這個結構和咱們上面本身描繪的結構很像,那麼React是如何將咱們的代碼轉換成這個結構的呢,下面咱們來看看createElement函數的具體實現(文中的源碼通過精簡)。

image

createElement函數內部作的操做很簡單,將props和子元素進行處理後返回一個ReactElement對象,下面咱們來逐一分析:

(1).處理props:

image

  • 1.將特殊屬性refkeyconfig中取出並賦值
  • 2.將特殊屬性selfsourceconfig中取出並賦值
  • 3.將除特殊屬性的其餘屬性取出並賦值給props

後面的文章會詳細介紹這些特殊屬性的做用。

(2).獲取子元素

image

  • 1.獲取子元素的個數 —— 第二個參數後面的全部參數
  • 2.若只有一個子元素,賦值給props.children
  • 3.如有多個子元素,將子元素填充爲一個數組賦值給props.children

(3).處理默認props

image

  • 將組件的靜態屬性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

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

  • self指定當前位於哪一個組件實例。
  • _source指定調試代碼來自的文件(fileName)和代碼行數(lineNumber)。

虛擬DOM轉換爲真實DOM

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

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

image

過程1:初始參數處理

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

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

render: function (nextElement, container, callback) {
    return ReactMount._renderSubtreeIntoContainer(null, nextElement, container, callback);
  },
複製代碼

image

  • 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對咱們傳入的組件進行分類包裝:

image

根據組件的類型,React根據原組件建立了下面四大類組件,對組件進行分類渲染:

  • ReactDOMEmptyComponent:空組件
  • ReactDOMTextComponent:文本
  • ReactDOMComponent:原生DOM
  • ReactCompositeComponent:自定義React組件

他們都具有如下三個方法:

  • construct:用來接收ReactElement進行初始化。
  • mountComponent:用來生成ReactElement對應的真實DOMDOMLazyTree
  • 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

image

  • 1.對特殊DOM標籤、props進行處理。
  • 2.根據標籤類型建立DOM節點。
  • 3.調用_updateDOMPropertiesprops插入到DOM節點,_updateDOMProperties也可用於props Diff,第一個參數爲上次渲染的props,第二個參數爲當前props,若第一個參數爲空,則爲首次建立。
  • 4.生成一個DOMLazyTree對象並調用_createInitialChildren將孩子節點渲染到上面。

那麼爲何不直接生成一個DOM節點而是要建立一個DOMLazyTree呢?咱們先來看看_createInitialChildren作了什麼:

image

判斷當前節點的dangerouslySetInnerHTML屬性、孩子節點是否爲文本和其餘節點分別調用DOMLazyTreequeueHTMLqueueTextqueueChild

image

能夠發現:DOMLazyTree其實是一個包裹對象,node屬性中存儲了真實的DOM節點,childrenhtmltext分別存儲孩子、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是一個變量,當前瀏覽器是IEEdge時爲true

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

因此lazyTree主要解決的是在IE(8-11)Edge瀏覽器中插入節點的效率問題,在後面的過程4咱們會分析到:若當前是IEEdge,則須要遞歸插入DOMLazyTree中緩存的子節點,其餘瀏覽器只須要插入一次當前節點,由於他們的孩子已經被渲染好了,而不用擔憂效率問題。

下面來看一下ReactCompositeComponent,因爲代碼很是多這裏就再也不貼這個模塊的代碼,其內部主要作了如下幾步:

  • 處理propscontex等變量,調用構造函數建立組件實例
  • 判斷是否爲無狀態組件,處理state
  • 調用performInitialMount生命週期,處理子節點,獲取markup
  • 調用componentDidMount生命週期

performInitialMount函數中,首先調用了componentWillMount生命週期,因爲自定義的React組件並非一個真實的DOM,因此在函數中又調用了孩子節點的mountComponent。這也是一個遞歸的過程,當全部孩子節點渲染完成後,返回markup並調用componentDidMount

過程4:渲染html

mountComponentIntoNode函數中調用將上一步生成的markup插入container容器。

在首次渲染時,_mountImageIntoNode會清空container的子節點後調用DOMLazyTree.insertTreeBefore

image

判斷是否爲fragment節點或者<object>插件:

  • 若是是以上兩種,首先調用insertTreeChildren將此節點的孩子節點渲染到當前節點上,再將渲染完的節點插入到html

  • 若是是其餘節點,先將節點插入到插入到html,再調用insertTreeChildren將孩子節點插入到html

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

image

  • 判斷不是IEbEdgereturn
  • 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:傳入組件的propschidrenprops中的一個屬性,它存儲了當前組件的孩子節點,能夠是數組(多個孩子節點)或對象(只有一個孩子節點)
  • 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的過程,下一篇文章咱們再來詳細探討。

關於開篇提的幾個問題,咱們在下篇文章中進行統一回答。

推薦閱讀

末尾

本文源碼中的版本爲React15版本,相對16版本會有一些出入,關於16版本的改動,後面的文章會單獨分析。

文中若有錯誤,歡迎在評論區指正,或者您對文章的排版,閱讀體驗有什麼好的建議,歡迎在評論區指出,謝謝閱讀。

想閱讀更多優質文章、下載文章中思惟導圖源文件、閱讀文中demo源碼、可關注個人github博客,你的star✨、點贊和關注是我持續創做的動力!

推薦關注個人微信公衆號【code祕密花園】,天天推送高質量文章,咱們一塊兒交流成長。

相關文章
相關標籤/搜索