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

image

導讀

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

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

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

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

開發中的常見問題

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

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

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

虛擬DOM

image

在原生的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的優勢在於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的過程,下一篇文章我們再來詳細探討。

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

推薦閱讀

末尾

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

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

推薦關注我的微信公衆號【code祕密花園】,每天推送高質量文章,我們一起交流成長。

圖片描述

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