深入理解 React 的 Virtual DOM

2021-01-10 php中文網

React在前端界一直很流行,而且學起來也不是很難,只需要學會JSX、理解

State

Props

,然後就可以愉快的玩耍了,但想要成為React的專家你還需要對React有一些更深入的理解,希望本文對你有用。

這是Choerodon的一個前端頁面

在複雜的前端項目中一個頁面可能包含上百個狀態,對React框架理解得更精細一些對前端優化很重要。曾經這個頁面點擊一條記錄展示詳情會卡頓數秒,而這僅僅是前端渲染造成的。

為了能夠解決這些問題,開發者需要了解React組件從定義到在頁面上呈現(然後更新)的整個過程。

React在編寫組件時使用混合

HTML

JavaScript

的一種語法(稱為JSX)。 但是,瀏覽器對JSX及其語法一無所知,瀏覽器只能理解純

JavaScript

,因此必須將JSX轉換為

HTML

。 這是一個div的JSX代碼,它有一個類和一些內容:

<divclassName='cn'> 文本</div>

在React中將這段jsx變成普通的js之後它就是一個帶有許多參數的函數調用:

React.createElement( 'div', { className: 'cn' }, '文本');

它的第一個參數是一個字符串,對應html中的標籤名,第二個參數是它的所有屬性所構成的對象,當然,它也有可能是個空對象,剩下的參數都是這個元素下的子元素,這裡的文本也會被當作一個子元素,所以第三個參數是

「文本」

到這裡你應該就能想像這個元素下有更多

children

的時候會發生什麼。

<divclassName='cn'> 文本1 <br /> 文本2</div>

React.createElement( 'div', { className: 'cn' }, '文本1', // 1st child React.createElement('br'), // 2nd child'文本1'// 3rd child)

目前的函數有五個參數:元素的類型,全部屬性的對象和三個子元素。 由於一個

child

也是React已知的

HTML

標籤,因此它也將被解釋成函數調用。

到目前為止,本文已經介紹了兩種類型的

child

參數,一種是

string

純文本,一種是調用其他的

React.createElement

函數。其實,其他值也可以作為參數,比如:

基本類型 false,null,undefined和 true數組React組件使用數組是因為可以將子組件分組並作為一個參數傳遞:

React.createElement( 'div', { className: 'cn' }, ['Content 1!', React.createElement('br'), 'Content 2!'])

當然,React的強大功能不是來自

HTML

規範中描述的標籤,而是來自用戶創建的組件,例如:

functionTable({ rows }) { return ( <table> {rows.map(row => ( <trkey={row.id}><td>{row.title}</td></tr> ))} </table> );}

組件允許開發者將模板分解為可重用的塊。在上面的「純函數」組件的示例中,組件接受一個包含表行數據的對象數組,並返回

React.createElement

對<table>元素及其行作為子元素的單個調用 。

每當開發者將組件放入JSX布局中時它看上去是這樣的:

<Tablerows={rows} />

但從瀏覽器角度,它看到的是這樣的:

React.createElement(Table, { rows: rows });

請注意,這次的第一個參數不是以

string

描述的HTML元素,而是組件的引用(即函數名)。第二個參數是傳入該組件的

props

對象。

將組件放在頁面上

現在,瀏覽器已經將所有JSX組件轉換為純

JavaScript

,現在瀏覽器獲得了一堆函數調用,其參數是其他函數調用,還有其他函數調用......如何將它們轉換為構成網頁的DOM元素?

為此,開發者需要使用

ReactDOM

庫及其

render

方法:

functionTable({ rows }) { /* ... */ } // 組件定義// 渲染一個組件ReactDOM.render( React.createElement(Table, { rows: rows }), // "創建" 一個 componentdocument.getElementById('#root') // 將它放入DOM中);

ReactDOM.render

被調用時,

React.createElement

最終也會被調用,它返回以下對象:

// 這個對象裡還有很多其他的欄位,但現在對開發者來說重要的是這些。{ type: Table, props: { rows: rows }, // ...}

這些對象構成了React意義上的Virtual DOM

它們將在所有進一步渲染中相互比較,並最終轉換為真正的DOM(與Virtual DOM對比)。

這是另一個例子:這次有一個div具有class屬性和幾個子節點:

React.createElement( 'div', { className: 'cn' }, 'Content 1!', 'Content 2!',);

變成:

{ type: 'div', props: { className: 'cn', children: [ 'Content 1!', 'Content 2!' ] }}

所有的傳入的展開函數,也就是

React.createElement

除了第一第二個參數剩下的參數都會在

props

對象中的

children

屬性中,不管傳入的是什麼函數,他們最終都會作為

children

傳入

props

中。

而且,開發者可以直接在JSX代碼中添加

children

屬性,將子項直接放在

children

中,結果仍然是相同的:

<divclassName='cn'children={['Content1!', 'Content2!']} />

在Virtual DOM對象被建立出來之後

ReactDOM.render

會嘗試按以下規則把它翻譯成瀏覽器能夠看得懂的DOM節點:

如果Virtual DOM對象中的type屬性是一個string類型的tag名稱,就創建一個tag,包含props裡的全部屬性。如果Virtual DOM對象中的type屬性是一個函數或者class,就調用它,它返回的可能還是一個Virtual DOM然後將結果繼續遞歸調用此過程。如果props中有children屬性,就對children中的每個元素進行以上過程,並將返回的結果放到父DOM節點中。最後,瀏覽器獲得了以下HTML(對於上述table的例子):

<table><tr><td>Title</td></tr> ...</table>

重建DOM

接下瀏覽器要「重建」一個DOM節點,如果瀏覽器要更新一個頁面,顯然,開發者並不希望替換頁面中的全部元素,這就是React真正的魔法了。如何才能實現它?先從最簡單的方法開始,重新調用這個節點的

ReactDOM.render

方法。

// 第二次調用ReactDOM.render( React.createElement(Table, { rows: rows }), document.getElementById('#root'));

這一次,上面的代碼執行邏輯將與看到的代碼不同。React不是從頭開始創建所有DOM節點並將它們放在頁面上,React將使用「diff」算法,以確定節點樹的哪些部分必須更新,哪些部分可以保持不變。

那麼它是怎樣工作的?只有少數幾個簡單的情況,理解它們將對React程序的優化有很大幫助。請記住,接下來看到的對象是用作表示React Virtual DOM中節點的對象。

▌Case 1:type是一個字符串,type在調用之間保持不變,props也沒有改變。

// before update{ type: 'div', props: { className: 'cn' } }// after update{ type: 'div', props: { className: 'cn' } }

這是最簡單的情況:DOM保持不變。

▌Case 2:type仍然是相同的字符串,props是不同的。

// before update:{ type: 'div', props: { className: 'cn' } }// after update:{ type: 'div', props: { className: 'cnn' } }

由於type仍然代表一個HTML元素,React知道如何通過標準的DOM API調用更改其屬性,而無需從DOM樹中刪除節點。

▌Case 3:type已更改為不同的組件String或從String組件更改為組件。

// before update:{ type: 'div', props: { className: 'cn' } }// after update:{ type: 'span', props: { className: 'cn' } }

由於React現在看到類型不同,它甚至不會嘗試更新DOM節點:舊元素將與其所有子節點一起被刪除(unmount)。因此,在DOM樹上替換完全不同的元素的代價會非常之高。幸運的是,這在實際情況中很少發生。

重要的是要記住React使用===(三等)來比較type值,因此它們必須是同一個類或相同函數的相同實例。

下一個場景更有趣,因為這是開發者最常使用React的方式。

▌Case 4:type是一個組件。

// before update:{ type: Table, props: { rows: rows } }// after update:{ type: Table, props: { rows: rows } }

你可能會說,「這好像沒有任何變化」,但這是不對的。

如果type是對函數或類的引用(即常規React組件),並且啟動了樹diff比較過程,那麼React將始終嘗試查看組件內部的所有

child

以確保

render

的返回值沒有更改。即在樹下比較每個組件 - 是的,複雜的渲染也可能變得昂貴!

組件中的children

除了上面描述的四種常見場景之外,當元素有多個子元素時,開發者還需要考慮React的行為。假設有這樣一個元素:

// ...props: { children: [ { type: 'div' }, { type: 'span' }, { type: 'br' } ]},// ...

開發者開發者想將它重新渲染成這樣(

span

div

交換了位置):

// ...props: { children: [ { type: 'span' }, { type: 'div' }, { type: 'br' } ]},// ...

那麼會發生什麼?

當React看到裡面的任何數組類型的

props.children

,它會開始將它中的元素與之前看到的數組中的元素按順序進行比較:index 0將與index 0,index 1與index 1進行比較,對於每對子元素,React將應用上述規則集進行比較更新。在以上的例子中,它看到

div

變成一個

span

這是一個情景3中的情況。但這有一個問題:假設開發者想要從1000行表中刪除第一行。React必須「更新」剩餘的999個孩子,因為如果與先前的逐個索引表示相比,他們的內容現在將不相等。

幸運的是,React有一種內置的方法來解決這個問題。如果元素具有

key

屬性,則元素將通過

key

而不是索引進行比較。只要

key

是唯一的,React就會移動元素而不將它們從DOM樹中移除,然後將它們放回(React中稱為掛載/卸載的過程)。

// ...props: { children: [ // 現在react就是根據key,而不是索引來比較了 { type: 'div', key: 'div' }, { type: 'span', key: 'span' }, { type: 'br', key: 'bt' } ]},// ...

當狀態改變時

到目前為止,本文只觸及了

props

,React哲學的一部分,但忽略了

state

。這是一個簡單的「有狀態」組件:

classAppextendsComponent{ state = { counter: 0 } increment = () =>this.setState({ counter: this.state.counter + 1, }) render = () => (<buttononClick={this.increment}> {'Counter: ' + this.state.counter} </button>)}

現在,上述例子中的

state

對象有一個

counter

屬性。單擊按鈕會增加其值並更改按鈕文本。但是當用戶點擊時,DOM會發生什麼?它的哪一部分將被重新計算和更新?

調用

this.setState

也會導致重新渲染,但不會導致整個頁面重渲染,而只會導致組件本身及其子項。父母和兄弟姐妹都可以倖免於難。

修復問題

本文準備了一個DEMO,這是修復問題前的樣子。你可以在這裡查看其原始碼。不過在此之前,你還需要安裝React Developer Tools。

打開demo要看的第一件事是哪些元素以及何時導致Virtual DOM更新。導航到瀏覽器的Dev Tools中的React面板,點擊設置然後選擇「Highlight Updates」複選框:

現在嘗試在表中添加一行。如你所見,頁面上的每個元素周圍都會出現邊框。這意味著每次添加行時,React都會計算並比較整個Virtual DOM樹。現在嘗試按一行內的計數器按鈕。你將看到Virtual DOM如何更新 (state僅相關元素及其子元素更新)。

React DevTools暗示了問題可能出現的地方,但沒有告訴開發者任何細節:特別是有問題的更新是指元素「diff」之後有不同,還是組件被unmount/mount了。要了解更多信息,開發者需要使用React的內置分析器(請注意,它不能在生產模式下工作)。

轉到Chrome DevTools中的「Performance」標籤。點擊record按鈕,然後點擊表格。添加一些行,更改一些計數器,然後點擊「Stop」按鈕。稍等一會兒之後開發者會看到:

在結果輸出中,開發者需要關注「Timing」。縮放時間軸,直到看到「React Tree Reconciliation」組及其子項。這些都是組件的名稱,旁邊有[update]或[mount]。可以看到有一個TableRow被mount了,其他所有的TableRow都在update,這並不是開發者想要的。

大多數性能問題都由[update]或[mount]引起

一個組件(以及組件下的所有東西)由於某種原因在每次更新時重新掛載,開發者不想讓它發生(重新掛載很慢),或者在大型分支上執行代價過大的重繪,即使組件似乎沒有發生任何改變。

修復mount/unmount

現在,當開發者了解React如何決定更新Virtual DOM並知道幕後發生的事情時,終於準備好解決問題了!修復性能問題首先要解決 mount/unmount。

如果開發者將任何元素/組件的多個子元素在內部表示為數組,那麼程序可以獲得非常明顯的速度提升。

考慮一下:

<div><Message /><Table /><Footer /></div>

在虛擬DOM中,將表示為:

// ...props: { children: [ { type: Message }, { type: Table }, { type: Footer } ]}// ...

一個簡單的

Message

組件(是一個

div

帶有一些文本,像是豬齒魚的頂部通知)和一個很長的

Table

,比方說1000多行。它們都是

div

元素的

child

,因此它們被放置在父節點的

props.children

之下,並且它們沒有

key

。React甚至不會通過控制臺警告來提醒開發者分配key,因為子節點

React.createElement

作為參數列表而不是數組傳遞給父節點。

現在,用戶已經關閉了頂部通知,所以

Message

從樹中刪除。

Table

Footer

是剩下的child。

// ...props: { children: [ { type: Table }, { type: Footer } ]}// ...

React如何看待它?它將它視為一系列改變了type的child:children[0]的type本來是

Message

,但現在他是

Table

。因為它們都是對函數(和不同函數)的引用,它會卸載整個Table並再次安裝它,渲染它的所有子代:1000多行!

因此,你可以添加唯一鍵(但在這種特殊情況下使用

key

不是最佳選擇)或者採用更智能的trick:使用 && 的布爾短路運算,這是

JavaScript

和許多其他現代語言的一個特性。像這樣:

<div> {isShowMessage && <Message />} <Table /><Footer /></div>

即使

Message

被關閉了(不再顯示),

props.children

父母div仍將擁有三個元素,children[0]具有一個值

false

(布爾類型)。還記得

true

/

false

,

null

甚至

undefined

都是Virtual DOM對象type屬性的允許值嗎?瀏覽器最終得到類似這樣的東西:

// ...props: { children: [ false, // isShowMessage && <Message /> 短路成了false { type: Table }, { type: Footer } ]}// ...

所以,不管

Message

是否被顯示,索引都不會改變,

Table

仍然會和

Table

比較,但僅僅比較Virtual DOM通常比刪除DOM節點並從中創建它們要快得多。

現在來看看更高級的東西。開發者喜歡HOC。高階組件是一個函數,它將一個組件作為一個參數,添加一些行為,並返回一個不同的組件(函數):

functionwithName(SomeComponent) { returnfunction(props) { return<SomeComponent {...props} name={name} />; }}

開發者在父

render

方法中創建了一個HOC 。當

React

需要重新渲染樹時,

React

的Virtual DOM將如下所示:

// On first render:{ type: ComponentWithName, props: {},}// On second render:{ type: ComponentWithName, // Same name, but different instance props: {},}

現在,React只會在ComponentWithName上運行一個diff算法,但是這次同名引用了一個不同的實例,三等於比較失敗,必須進行完全重新掛載。注意它也會導致狀態丟失,幸運的是,它很容易修復:只要返回的實例都是同一個就好了:

// 單例const ComponentWithName = withName(Component);classAppextendsReact.Component() { render() { return<ComponentWithName />; }}

修復update

現在瀏覽器已經確保不會重新裝載東西了,除非必要。但是,對位於DOM樹根目錄附近的組件所做的任何更改都將導致其所有子項的進行對比重繪。結構複雜,價格昂貴且經常可以避免。

如果有辦法告訴React不要查看某個分支,那將是很好的,因為它沒有任何變化。

這種方式存在,它涉及一個叫

shouldComponentUpdate

的組件生命周期函數。React會在每次調用組件之前調用此方法,並接收

props

state

的新值。然後開發者可以自由地比較新值和舊值之間的區別,並決定是否應該更新組件(返回

true

false

)。如果函數返回

false

,React將不會重新渲染有問題的組件,也不會查看其子組件。

通常比較兩組

props

state

一個簡單的淺層比較就足夠了:如果頂層屬性的值相同,瀏覽器就不必更新了。淺比較不是

JavaScript

的一個特性,但開發者很多方法來自己實現它,為了不重複造輪子,也可以使用別人寫好的方法。

在引入淺層比較的npm包後,開發者可以編寫如下代碼:

classTableRowextendsReact.Component{ shouldComponentUpdate(nextProps, nextState) { const { props, state } = this; return !shallowequal(props, nextProps) && !shallowequal(state, nextState); } render() { /* ... */ }}

但是你甚至不必自己編寫代碼,因為React在一個名為React.PureComponent的類中內置了這個功能,它類似於

React.Component

,只是

shouldComponentUpdate

已經為你實現了淺層props/state比較。

或許你會有這樣的想法,能替換

Component

PureComponent

就去替換。但開發者如果錯誤地使用

PureComponent

同樣會有重新渲染的問題存在,需要考慮下面三種情況:

<Table // map每次都會返回一個新的數組實例,所以每次比較都是不同的 rows={rows.map(/* ... */)} // 每一次傳入的對象都是新的對象,引用是不同的。 style={ { color: 'red' } } // 箭頭函數也一樣,每次都是不同的引用。 onUpdate={() => { /* ... */ }}/>

上面的代碼片段演示了三種最常見的反模式,請儘量避免它們!

正確地使用

PureComponent

,你可以在這裡看到所有的TableRow都被「純化」後渲染的效果。

但是,如果你迫不及待想要全部使用純函數組件,這樣是不對的。比較兩組

props

state

不是免費的,對於大多數基本組件來說甚至都不值得:運行

shallowCompare

比diff算法需要更多時間。

可以使用此經驗法則:純組件適用於複雜的表單和表格,但它們通常會使按鈕或圖標等簡單元素變慢。

現在,你已經熟悉了React的渲染模式,接下來就開始前端優化之旅吧。

相關焦點

  • 【英】Understanding the Virtual DOM
    What does a virtual DOM look like?The name 「virtual DOM」 tends to add to the mystery of what the concept actually is. In fact, a virtual DOM is just a regular Javascript object.
  • 手寫React-Router源碼,深入理解其原理
    本文會繼續深入React-Router講講他的源碼,套路還是一樣的,我們先用官方的API實現一個簡單的例子,然後自己手寫這些API來替換官方的並且保持功能不變。as Router, Switch, Route,} from "react-router-dom";import Home from '.
  • 第329天:React的refs的理解
    React的refs的理解Refs提供了一種方式,允許我們訪問DOM節點或在render方法中創建的React
  • shadow dom 101
    介紹其基礎概念、用法、特性,並與 virtual dom 進行簡單比較。  由於擁有 shadow dom 的元素在渲染時會用 shadow dom 替代原本的子元素,所以在這裡是看不到 『hello world』 的  那麼我要顯示 『hello world』 呢?
  • React + Redux + React-router 全家桶
    web端開發需要搭配React-dom使用import React from 'react';import ReactDOM from 'react-dom';const App = () => (  <div>      <
  • react腳手架create-react-app入門
    記得關注,每天都在分享不同知識不管是哪個前端框架都支持快速開發,通過腳手架可以讓咱們更便捷的構建項目,無需考慮搭建環境問題,今天來聊聊create-react-app腳手架安裝腳手架>npm install -g create-react-app創建項目create-react-app myapp # myapp是項目的名稱,這樣就會在當前目錄生成一個myapp的項目
  • 關於react結合redux使用,或許你還應該掌握這些
    react優點以及存在的不足react的優點使用了virtual Dom,大大提升了渲染性能;代碼組件化,便於復用,使用起來更加方便也更容易維護;virtual Dom解問決了跨瀏覽器問題,並提供了標準化的API;能夠很好的和現有代碼結合使用;易理解,易上手;存在的不足上面也講到,react只是一個純粹寫UI組件的庫
  • webpack4+react16+react-router-dom4從零配置到優化,實現路由按需...
    上一篇介紹了下webpack的一些配置,接下來講下reactRouter4裡面關於路由的一些配置,如何做到模塊的按需加載,這也是常用的一種優化網站性能的方式
  • React SSR 同構入門與原理
    本文將分以下兩部分去講述:同構思路分析,讓你對同構有一個概念上的了解;手寫同構框架,深入理解同構原理。";import ReactDom from"react-dom";import Home from"..
  • React學習(二)-深入淺出JSX
    import ReactDOM from 'react-dom'; ReactDOM.render(<App />, document.getElementById('root')) // 將App組件通過ReactDOM.render()函數,掛載到root根節點上
  • React 入門
    工具—在寫文章之前,為了方便理解,我準備了一個懶人調試倉庫 simple_react[1] ,這個倉庫將 benchmark 用例(只有兩個 ^ ^)和 React 源碼共同放在 src 文件夾中,通過 snowpack 進行熱更新,可以直接在源碼中加入 log 和 debuger 進行調試。
  • 前端必備:React的生命周期和路由
    在React中,常用的有兩個包可以實現這個需求,那就是react-router和react-router-dom。react-router-dom首先進入項目目錄,使用npm安裝react-router-dom: npm install react-router-dom --save-dev //這裡可以使用cnpm代替npm命令然後我們新建兩個頁面,分別命名為「home」和「detail」。
  • React知識點總結
    元素第三步:通過this.nameInputRef.current 獲取dom第四步:操作dom>使用場景:必須手動操作dom,不能使用state。span> <br/> <button onClick={this.alertName}>alert name</button> </div> } alertName = () => { // 第三步:通過this.nameInputRef.current 獲取dom
  • 深入理解React Diff算法
    ), type: "div", key: "A" }, {$$typeof: Symbol(react.element), type: "div", key: "B" }, {$$typeof: Symbol(react.element), type: "div", key: "B" }, ]Diff的基本原則對於新舊兩種結構來說
  • 寫React Hooks前必讀
    英文文檔:https://reactjs.org/docs/hooks-intro.html中文文檔:https://zh-hans.reactjs.org/docs/hooks-intro.html其中重點必看hooks:useState、useReducer、useEffect、useCallback、useMemo
  • React-深入理解Props和States
    為了更好地理解,可以將整個UI看作一棵樹。在這裡,起始組件成為根,每個獨立的部分成為分支,這些分支又被進一步劃分為子分支。這使我們的UI具有組織性,並允許數據和狀態更改邏輯地從根流向分支,然後再流向子分支。組件直接從客戶端調用伺服器,這允許DOM在不刷新頁面的情況下動態更新。這是因為react組件是基於AJAX請求的概念構建的。每個組件都有自己的接口,可以調用伺服器並更新它們。
  • React源碼分析與實現(一):組件的初始化與渲染
    react則處理構建用戶界面通過將他們份極為virtual dom,當然這也是react的核心,整個react架構的設計理念也是為此展開的。準備工作我們採用基線法去學習react源碼,所以目前基於的版本為stable-0.3,後面我們在逐步分析學習演變的版本。
  • React系列八 - 深入理解setState
    在這篇文章中,我對React的setState進行了很多解析,希望可以幫助大家真正理解setState。(其中涉及到一個源碼,我有貼出,但是沒有詳細展開,有機會我們再對源碼進行解析,大家不是很懂也不影響你的學習,只需要知道React內部是這樣做的即可,面試時也可以回答出來)一. setState的使用 1.1.
  • 深入 React Fiber 內部
    Stack Reconciler假設我們的應用程式如下:import React from 'react'import ReactDOM from 'react-dom'function Button(props) {    return &
  • React 16.8發布:hooks終於來了!
    我們在最近發布的 React 路線圖(https://reactjs.org/blog/2018/11/27/react-16-roadmap.html)中描述了未來幾個月的計劃。請注意,React hooks 還沒有涵蓋類的所有用例,但已經非常接近了。