深入理解 React 的 Virtual DOM

Choerodon豬齒魚發表於2019-04-16

React在前端界一直很流行,而且學起來也不是很難,只需要學會JSX、理解StateProps,然後就可以愉快的玩耍了,但想要成為React的專家你還需要對React有一些更深入的理解,希望本文對你有用。

這是Choerodon的一個前端頁面

深入理解 React 的 Virtual DOM

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

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

React在編寫元件時使用混合HTMLJavaScript的一種語法(稱為JSX)。 但是,瀏覽器對JSX及其語法一無所知,瀏覽器只能理解純JavaScript,因此必須將JSX轉換為HTML。 這是一個div的JSX程式碼,它有一個類和一些內容:

<div className='cn'>
  文字
</div>
複製程式碼

在React中將這段jsx變成普通的js之後它就是一個帶有許多引數的函式呼叫:

React.createElement(
  'div',
  { className: 'cn' },
  '文字'
);
複製程式碼

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

到這裡你應該就能想象這個元素下有更多children的時候會發生什麼。

<div className='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規範中描述的標籤,而是來自使用者建立的元件,例如:

function Table({ rows }) {
  return (
    <table>
      {rows.map(row => (
        <tr key={row.id}>
          <td>{row.title}</td>
        </tr>
      ))}
    </table>
  );
}
複製程式碼

元件允許開發者將模板分解為可重用的塊。在上面的“純函式”元件的示例中,元件接受一個包含錶行資料的物件陣列,並返回React.createElement

元素及其行作為子元素的單個呼叫 。

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

<Table rows={rows} />
複製程式碼

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

React.createElement(Table, { rows: rows });
複製程式碼

請注意,這次的第一個引數不是以string描述的HTML元素,而是元件的引用(即函式名)。第二個引數是傳入該元件的props物件。

將元件放在頁面上

現在,瀏覽器已經將所有JSX元件轉換為純JavaScript,現在瀏覽器獲得了一堆函式呼叫,其引數是其他函式呼叫,還有其他函式呼叫......如何將它們轉換為構成網頁的DOM元素?

為此,開發者需要使用ReactDOM庫及其render方法:

function Table({ rows }) { /* ... */ } // 元件定義

// 渲染一個元件
ReactDOM.render(
  React.createElement(Table, { rows: rows }), // "建立" 一個 component
  document.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中,結果仍然是相同的:

<div className='cn' children={['Content 1!', 'Content 2!']} />
複製程式碼

在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' }
  ]
},
// ...
複製程式碼

開發者開發者想將它重新渲染成這樣(spandiv交換了位置):

// ...
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。這是一個簡單的“有狀態”元件:

class App extends Component {
  state = { counter: 0 }

  increment = () => this.setState({
    counter: this.state.counter + 1,
  })

  render = () => (<button onClick={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

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

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

轉到Chrome DevTools中的“Performance”標籤。點選record按鈕,然後點選表格。新增一些行,更改一些計數器,然後點選“Stop”按鈕。稍等一會兒之後開發者會看到:

深入理解 React 的 Virtual DOM

在結果輸出中,開發者需要關注“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從樹中刪除。TableFooter是剩下的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。高階元件是一個函式,它將一個元件作為一個引數,新增一些行為,並返回一個不同的元件(函式):

function withName(SomeComponent) {
  return function(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);

class App extends React.Component() {
  render() {
    return <ComponentWithName />;
  }
}
複製程式碼

修復update

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

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

這種方式存在,它涉及一個叫shouldComponentUpdate的元件生命週期函式。React會在每次呼叫元件之前呼叫此方法,並接收propsstate的新值。然後開發者可以自由地比較新值和舊值之間的區別,並決定是否應該更新元件(返回truefalse)。如果函式返回false,React將不會重新渲染有問題的元件,也不會檢視其子元件。

通常比較兩組propsstate一個簡單的淺層比較就足夠了:如果頂層屬性的值相同,瀏覽器就不必更新了。淺比較不是JavaScript的一個特性,但開發者很多方法來自己實現它,為了不重複造輪子,也可以使用別人寫好的方法

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

class TableRow extends React.Component {
  shouldComponentUpdate(nextProps, nextState) {
    const { props, state } = this;
    return !shallowequal(props, nextProps)
           && !shallowequal(state, nextState);
  }
  render() { /* ... */ }
}
複製程式碼

但是你甚至不必自己編寫程式碼,因為React在一個名為React.PureComponent的類中內建了這個功能,它類似於React.Component,只是shouldComponentUpdate已經為你實現了淺層props/state比較。

或許你會有這樣的想法,能替換ComponentPureComponent就去替換。但開發者如果錯誤地使用PureComponent同樣會有重新渲染的問題存在,需要考慮下面三種情況:

<Table
    // map每次都會返回一個新的陣列例項,所以每次比較都是不同的
    rows={rows.map(/* ... */)}
    // 每一次傳入的物件都是新的物件,引用是不同的。
    style={ { color: 'red' } }
    // 箭頭函式也一樣,每次都是不同的引用。
    onUpdate={() => { /* ... */ }}
/>
複製程式碼

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

正確地使用PureComponent,你可以在這裡看到所有的TableRow都被“純化”後渲染的效果。

深入理解 React 的 Virtual DOM

但是,如果你迫不及待想要全部使用純函式元件,這樣是不對的。比較兩組propsstate不是免費的,對於大多數基本元件來說甚至都不值得:執行shallowCompare比diff演算法需要更多時間。

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

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

關於Choerodon豬齒魚

Choerodon豬齒魚是一個開源企業服務平臺,基於Kubernetes的容器編排和管理能力,整合DevOps工具鏈、微服務和移動應用框架,來幫助企業實現敏捷化的應用交付和自動化的運營管理的開源平臺,同時提供IoT、支付、資料、智慧洞察、企業應用市場等業務元件,致力幫助企業聚焦於業務,加速數字化轉型。

大家也可以通過以下社群途徑瞭解豬齒魚的最新動態、產品特性,以及參與社群貢獻:

相關文章