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

ConardLi發表於2019-04-17

導讀

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

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

推薦閱讀

末尾

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

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

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

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

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

相關文章