【譯】React 優化:虛擬 DOM 詳解

作者:Alexey Ivanov、 Andy Barnov | 譯:大輝
原文地址:https://evilmartians.com/chronicles/optimizing-react-virtual-dom-explained

本文將帶你學習 React 的虛擬 DOM,並應用這些知識來加速你的應用程序。在這個對框架內部進行全面友好的初步介紹中,我們將揭開JSX的神祕面紗,向您展示React如何做出渲染決策,解釋如何找到瓶頸,並分享一些提示以避免常見錯誤。

React 持續領跑前端世界並且沒有下降跡象的原因之一,就是其平易近人的學習曲線。在用 JSX 和 “State vs Props” 的概念武裝你的大腦之後,你就可以開始開發了。

但要真正掌握 React,你需要有 React 思想。本文試着從這個方面來幫助你。下面通過我們其中的一個的項目 React table 來了解一下:

如果你已經熟悉 React 的工作方式,可以直接跳至“優化:掛載/卸載”這節來繼續閱讀。

-w550

(圖中是一個在 eBay 的業務中擁有龐大數據的 React table)

通過這上百行動態的並且可過濾的數據行,來理解框架的細節對於保證用戶的流暢體驗是至關重要。


當程序出錯的時候,你很容易感受到,輸入文字變得卡頓,複選框檢查都需要一秒鐘的時間,模態框也很難顯示出來。


爲了解決這些問題,我們這篇文章要涵蓋 React 組件從定義到在頁面上呈現(然後更新)的整個生命週期。繫好安全帶,我們要發車了。

JSX 的原理

在前端圈中稱爲“轉譯”的過程,其實用“彙編”來描述是更正確的術語。

React 開發者推薦你使用一種混合了 HTML 和 JavaScript 的語法,即 JSX 來編寫你的組件。但瀏覽器對 JSX 及其語法並不理解。瀏覽器只理解 JavaScript,所以 JSX 必須轉換爲 JavaScript。下面是 JSX 的代碼,一個有 class 和一些內容的 div。

<div className='cn'>
   Content!
</div>

上面的代碼轉換成常見的 JavaScript 代碼只需要調用一個函數傳遞一些參數即可,如下:

// createElement 接收三個參數 'div'、{className: 'cn'}、'Content!'
React.createElement(
   'div',
   { className: 'cn' },
   'Content!'
);

讓我們仔細看一下這三個參數:

  • 第一個是 元素的類型 。對應 HTML 標籤名稱;
  • 第二個是帶有 所有元素屬性 的對象。如果他們沒有屬性也可以是個空對象;
  • 餘下的參數是 元素的子節點 。元素中的文本也算一個 child,所以一個字符串 ‘Content!’被放置在函數的第三個參數。

你可以想象當我們有很多子節點的時候會發生什麼:

<div className='cn'>
  Content 1!
  <br />
  Content 2!
</div>
React.createElement(
  'div',                     // 標籤名
  { className: 'cn' },       // 元素屬性對象
  'Content 1!',              // 1st 子節點
  React.createElement('br'), // 2nd 子節點
  'Content 2!'               // 3rd 子節點
)

上面這個函數有五個參數:

  • 第一個:元素類型;
  • 第二個:屬性對象;
  • 還有三個子節點:因爲其中的一個子節點( <br/> )也是 React 已知的 HTML 標籤,所以它也會被描述爲一個函數調用。

譯者注:第三個參數到第五個參數所有的子節點,如果遇到字符串便按照子節點處理,如果碰到標籤,需要重複執行上述整個步驟,標籤被描述爲函數調用。

到目前爲止,我們介紹了兩種類型的子節點:一種是純字符串 String,剩下的是可以調用 React.createElement 函數的。然而其他值也可以作爲參數:

  1. 基本數據類型 false,null,undefined和true
  2. 數組 Arrays
  3. 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>
  );
}

組件允許我們將模版分解成多個可重用的塊。在示例 “函數式” [1]組件中我們接收一個包含表格行數據的對象數組,並返回調用 React.createElement 的 table 元素,rows 則作爲其子節點傳入。

無論何時我們都可以像下面這樣聲明我們的組件:

<Table rows={ rows } />

在瀏覽器看來,我們寫的其實是這樣的:

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

注意,這次第一個參數不再是描述 HTML 元素的字符串,而是一個在編寫組件時定義的 函數的引用 函數的屬性就是 props

使用組件拼裝頁面

所以,我們已經將所有 JSX 組件轉換爲純 JavaScript,現在我們得到了一堆函數調用,它的參數會被其他函數調用的,或者還有更多的其他函數調用這些參數…(說白了就是函數套函數) 那麼它到底是如何轉換成組成網頁的 DOM 元素的呢?

爲此,我們有一個 ReactDOM 庫及其它的 render 方法:

function Table({ rows }) { /* ... */ } // 聲明一個組件

// 渲染一個組件
ReactDOM.render(
  React.createElement(Table, { rows: rows }), // “創建”一個組件
  document.getElementById('#root')            // 將它插入頁面
);

當 ReactDom.render 被調用,React.createElement 最終也被調用,它返回下列對象:

// 這裏有一些屬性,並且他們對我們很重要。
{
  type: Table,
  props: {
    rows: rows
  },
  // ...
}

這些對象在 React 看來便構成了虛擬 DOM。


他們將在所有進一步的渲染中相互比較,並最終轉化爲真正的DOM(而不是虛擬)。

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

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

變成:

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

請注意:上面 JSX 原理裏說過的在將 JSX 轉譯成純 JavaScript 的過程中,我會傳遞很多參數,第一個參數爲元素類型,第二個參數爲屬性對象,其餘的參數我們可以獨立傳遞給 React.createElement 也可以打包以數組的形式傳遞,最後他們都會在 props 中以 children 爲 key 的屬性中找到他們。所以無論 children 是作爲數組還是參數列表傳遞都無關緊要 – 在生成的虛擬 DOM 對象中,它們總會被打包在一起。

更重要的是,我們可以直接在 JSX 代碼中將 children 添加到 props 中,效果是一樣的。比如下面:

<div className='cn' children={['Content 1!', 'Content 2!']} />

虛擬 DOM 對象構建完成後,ReactDOM.render 會嘗試將其轉譯爲瀏覽器根據以下規則可以展示的 DOM 節點:

  • 如果 type 包含一個帶有 String 類型的標籤名稱 —— 創建一個標籤並附帶 props 下所有的屬性。
  • 如果 type 是一個函數或者類,調用它,並對結果遞歸地重複這個過程。
  • 如果 props 下有 children 屬性 —— 在父節點下,針對每個 child 重複以上過程。

最後的結果,我們就得到了如下的 HTML(我們的 table 示例):

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

重新構建 DOM(Rebuilding the DOM)

在實際應用場景,render 通常在根節點調用一次,後續的更新會由 state 來控制和觸發調用。

注意標題裏的的“重新” (“re”)! 當我們想 更新一個頁面 而不是全部替換時,React 中真正的魔法就開始了。有很多方法可以實現這種效果。我們先來一種簡單的——在相同的節點再次調用 ReactDOM.render。

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

這次上面的代碼將會與之前看到的有所不同,它不是從頭開始創建所有 DOM 節點並將它們放在頁面上,而是 React 會啓動 reconciliation [2](或“diffing”)算法,以確定節點樹的哪些部分必須更新,哪些可以保持不變。

那麼,它是如何工作的呢?其實只有少數幾個簡單的場景,理解它們將對我們的優化有很大幫助。請記住,現在我們在看的,是在 React Virtual DOM 裏面用來代表節點的對象。

  • 場景1: type 是一個字符串, type 在調用過程中保持不變, props 也沒有改變。
// 更新之前
{ type: 'div', props: { className: 'cn' } }

// 更新之後
{ type: 'div', props: { className: 'cn' } }

這是一個簡單的示例:DOM 保持不變。

  • 場景2: type 仍然是那個字符串, props 不同。
// 更新之前
{ type: 'div', props: { className: 'cn' } }

// 更新之後
{ type: 'div', props: { className: 'cnn' } }

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

  • 場景3: type 改變成一個不同的字符串,或者將字符串改成一個組件。
// 更新之前
{ type: 'div', props: { className: 'cn' } }

// 更新之後
{ type: 'span', props: { className: 'cn' } }

由於 React 現在認爲類型不同,它甚至不會嘗試更新我們的節點:old 元素將與其所有子節點一起被刪除( unmounted )。因此,將元素替換爲完全不同於 DOM 樹的東西代價可能會非常昂貴。幸運的是,這在現實世界中很少發生。

記住 React 使用 ===(triple equals)來比較類型值是很重要的,所以它們必須是相同類或相同函數的相同實例。

下一個場景要有趣的多,我們大部分人經常這麼使用 React。

  • 場景4: type 是一個組件。
// 更新之前
{ type: Table, props: { rows: rows } }
// 更新之後
{ type: Table, props: { rows: rows } }

但是沒有任何改變啊!你可能會這麼說,你錯了。


值得注意的是,一個 component 的 render(只有類組件在聲明時有這個函數)跟 ReactDom.render 不是同一個函數。”render” 一詞在 React 的裏確實有點過度使用。

如果 type 是一個函數或類的引用(即常規的 React 組件),並且我們啓動了 tree diff 的過程,則 React 會嘗試一直檢查組件的內部邏輯,以確保 render 返回的值不會改變(防止副作用的措施)。對樹中的每個組件進行遍歷和掃描,但是在複雜的場景這個渲染過程成本會很高!

關注子節點

除了上述四種常見情況之外,當元素有多個子元素時,我們還需要考慮 React 的行爲。假設我們有這樣的元素:

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

接下來我們想要交換這些元素的順序

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

接下來將會發生什麼呢?

當進行 “diffing” 的時候,React 檢查 props.children 裏面的數組時,它開始將數組中的元素與之前看到的元素按照數組下標順序進行比較:0 與 0,1 與 1,以此類推,每次比較,React 都會運用上述規則進行。 在我們的例子中,它會認爲 div 變成了 span,應用之前的場景3,這並不是很高效的。想象一下我們從 1000 行的表格裏,刪除了第一行。 React 將會不得不更新其後的 999 行,因爲按照索引來對比,他們的索引都發生了變化。

幸運的是,React 有一個內置的方法 built-inway [3]來解決這個問題。如果一個元素有一個 key 屬性,元素可以通過 key 的值來比較,而不是使用索引。只要這個 key 是唯一的,React 便可以移動元素而不是從 DOM 樹中刪除它們,然後把它們再加回來(在 React 中叫掛載/卸載)。

// ...
props: {
children: [ // 現在 React 將關注 Key,不再關注下標。
{ type: 'div', key: 'div' },
{ type: 'span', key: 'span' },
{ type: 'br', key: 'bt' }
]
},
// ...

譯者注:在我們實際開發中,如果循環渲染同一個被複用的組件,使用相同 key 的數據渲染同一個組件,只會被渲染一次。

當 state 發生變化

到目前爲止,我們只涉及到 React 哲學的 props 部分,卻忽視了 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 對象中,有一個 key 爲 counter,點擊按鈕時它的值就會增加,並且按鈕的文本也會改變。但是當我們這麼做的時候,到底在 DOM 中發生了什麼?哪部分將被重新計算和更新?這是我們需要思考的。

調用 this.setState 也會導致重新渲染,但不會影響整個頁面,只會影響組件本身及其子節點。父節點和兄弟節點都不會受到影響。當我們有一個很龐大的樹形結構時,只重繪它的一部分就很方便。

確定問題

我們準備了 Demo 。 你可以看到最常見的問題。也可以在 這裏 [4]查看源碼。當然 React 開發者工具 [5]也是需要,所以確保你的瀏覽器已經裝好了它們。

我們首先要看的是,哪些元素?它們什麼時候觸發虛擬 DOM 的更新。在瀏覽器的開發工具中,打開 React 面板並選擇 “Highlight Updates” 複選框:

-w750 (在 Chrome 中使用“突出顯示更新”複選框選中 DevTools)

現在嘗試向表中添加一行。如你所見,頁面上的每個元素都有一個邊框。這意味着,每當我們添加一行時,React 都在計算和比較整個虛擬 DOM 樹。現在嘗試點擊一行中的 counter 按鈕。你將看到在 state 變化之後虛擬 DOM 是如何更新的 —— 只有引用了 state key 的元素及其 children 受到了影響。

React 開發者工具會提示問題出在哪裏,但不會告訴我們相關細節的信息:比如說所涉及的更新,是指 “diffing” 元素?還是掛載/卸載它們?要了解更多信息,我們需要使用 React 的內置分析器 (profiler) (注意它不能用於生產環境)

添加 ?react_perf 到應用的 URL,然後轉到 Chrome DevTools 中的 “Performance” 標籤。點擊 “Record” 並在表格上點擊。添加一些 row,更改一下 counter,然後點擊 “Stop”。

-w750 (React DevTools的“Performance”選項卡)

在結果輸出中,我們需要關注 “User timing”。放大時間軸直到看到 “React Tree Reconciliation” 組及其子項。這些是我們組件的所有名稱,它們旁邊都有 [update] 或 [mount]。


我們的大部分性能問題都屬於這兩類問題之一。


無論是組件(還是從它分支的所有東西)出於某種原因都會在每次更新時 re-mounted,其實我們不希望它 re-mounted (因爲很慢),即使這些分支內容沒有改變,我們卻在大型應用分支的比對上花費很大開銷。

優化:掛載/卸載

現在,當我們知道了 React 如何更新虛擬 DOM,並掌握了一些方法來看到其運行時背後到底發生了什麼,我們終於準備好優化我們的代碼了!首先,讓我們來處理掛載/卸載。

如果你只是介意一個元素或組件在其內部有很多個子節點表示爲數組,你可以獲得非常顯着的速度提升。

考慮如下示例:

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

在我們的虛擬 DOM 中,上述代碼將表現爲:

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

這裏有個簡單的 Message 例子,一個 div 有一些文本,和一個超過 1000 行的龐大 Table。它們都包括在封閉的 div 中,所以它們被放置在父節點的 props.children 下,並且它們都沒有 key。React 甚至不會通過控制檯警告我們要給他們分配 key,因爲 children 正在被 React.createElement 作爲參數列表傳遞給父元素,而不是直接遍歷一個數組。

現在我們的用戶已讀了一個通知,Message 組件從 DOM 樹上移除後,剩下 Table 和 Footer 組件。

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

站在 React 的角度看,上述過程子節點是不斷變化的:第0個子節點從 Message 組件現在變成了 Table 組件。這裏沒有 keys 來與之比較,所以它比較 types 時,又發現它們倆不是同一個 function 的引用,於是會把整個 Table 卸載,然後在掛載回去,渲染它的 1000 多行子節點數據!

因此,你可以添加唯一的 key(但在這種特殊情況下,使用 keys 並不是最佳選擇),或者採用更智能的技巧:使用短路計算 (short circuit boolean evaluation) ,這是 JavaScript 和許多其他現代語言的特性。比如:

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

雖然 Message 組件不會在畫面顯示,父元素 div 的 props.children 仍然有三個元素,children[0] 有一個值 false(一個布爾值)。請記住 true/false, null, undefined 是虛擬 DOM 對象 type 屬性允許的值,我們最終得到了下面的結果:

// ...
props: {
children: [
false, // isShown && <Message /> 結果爲 false
{ type: Table },
{ type: Footer }
]
}
// ...

所以,Message 有或沒有,我們的索引都不會變,當然,Table 仍然與 Table 進行比較(當 type 是一個引用類型時,對比一定會進行),但是僅僅對比虛擬 DOM 也比刪掉 Dom 節點再從頭創建它們來的快的多。

現在我們來看看更多高級的東西。我們知道你喜歡 HOC。HOC(高階組件)是一個將組件作爲參數,執行某些操作並返回不同功能的函數:

function withName(SomeComponent) {
// 計算名稱,可能代價很高...
return function(props) {
return <SomeComponent {...props} name={name} />;
}
}

這是一種非常常見的模式,但你需要小心。考慮:

class App extends React.Component() {
render() {
// 在每個渲染上創建一個新的實例
const ComponentWithName = withName(SomeComponent);
return <SomeComponentWithName />;
}
}

我們在父組件的渲染方法中創建一個 HOC。當我們重新渲染組件樹的時候,我們的虛擬 DOM 將如下所示:

// 第一次渲染:
{
type: ComponentWithName,
props: {},
}
// 第二次渲染:
{
type: ComponentWithName, // 相同的名字,但是不同的實例
props: {},
}

現在 React 在 ComponentWithName 組件運行 diffing 算法,但此時同名引用了不同的實例,三等於(triple equals)失敗,一個完整的 re-mount 會發生(整個節點換掉)注意它也會導致狀態丟失, 如此處所述 。幸運的是,這很容易解決,你需要始終在 render 外面創建一個 HOC:

// 僅僅創建一次一個新的實例
const ComponentWithName = withName(Component);
class App extends React.Component() {
render() {
return <ComponentWithName />;
}
}

優化:更新

所以,除非必要否則我們不建議 re-mount 。但是,對位於 DOM 樹根部附近的組件所做的任何更改都會導致其所有子節點的 diffing 和 reconciliation。對於結構複雜的應用這資源開銷也很大並且通常是可以避免的。


有一種方法可以告訴 React 不要檢查某個分支,因爲我們確定它沒有變化。


這個方法叫 shouldComponentUpdate 他是組件生命週期 component’s lifecycle [6] 的一部分。這個方法會在組件的 render 和組件接收到 state 或 props 的值更新之前調用。然後我們可以自由地將它們與我們當前的值進行比較,並決定是否更新我們的組件(返回 true 或 false )。如果我們返回 false,React 將不會重新渲染組件,也不會檢查它的所有子組件。

通常,比較兩個集合 props 和 state 一個簡單的 淺比較 (shallow comparison)就足夠了:如果頂層的值不同,我們不必接着比較了。淺比較不是 JavaScript 的特性,但有很多這方面的工具 utilities [7]。

現在可以像這樣編寫代碼了:

class TableRow extends React.Component {
// 將要返回 true 如果新的 props/state 與舊的不同
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,即可享受高效率。雖然不是很快!考慮這些例子:

<Table
// map 返回一個新的數組實例,所以淺比較將失敗
rows={rows.map(/* ... */)}
// 對象的字面量總是與前一個不一樣
style={ { color: 'red' } }
// 箭頭函數是一個新的未命名的東西在作用域內,所以總會有一個完整的 diffing
onUpdate={() => { /* ... */ }}
/>

上面的代碼片段演示了三種最常見的 反例 。儘量避免它們!


如果你能注意點,在 render 定義之外創建所有對象、數組和函數,並確保它們在調用期間不會變化 —— 你是安全的。


你可以在 updated demo [8] 中觀察 PureComponent 的效果,其中所有表格的行都是“純淨的”。如果你在 React DevTools 中打開 “Highlight Updates”,你會注意到只有表格本身和新行在行插入時渲染,所有其他行都保持不變。

但是,如果你迫不及待地想要使用 PureComponents 並在你的應用程序的任何地方使用它們,不要這麼做。比較兩組 props/state 開銷也是不小的,對於大多數基本組件來說甚至都不值得:運行淺比較(shallow Compare)比 “diffing” 算法需要更多時間。

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

感謝你的閱讀!現在你已準備好將這些見解應用到你的應用程序中。你可以使用我們的小演示(無論是否使用 PureComponent)的存儲庫作爲你的實驗的起點。此外,請繼續關注本系列的下一部分,我們計劃涵蓋 Redux 並優化你的數據以提高應用程序的總體性能。

譯者之言:

整體文章翻譯下來最大的收穫就是:大部分特性在實際項目中都使用過,但是特性背後的細節原理確實較之前理解更加到位了,通篇下來作者由淺入深的指引我們把 React 的整個知識體系串講了一遍。相信通讀之後大家會有不一樣的收穫。

當然也建議大家閱讀一下原文,閱讀過程中如有任何不同見解,歡迎大家一起交流。

擴展閱讀:

[1] 函數式 https://reactjs.org/docs/components-and-props.html#functional-and-class-components
[2] diffing 算法 https://reactjs.org/docs/reconciliation.html
[3] built-in way https://reactjs.org/docs/lists-and-keys.html
[4] Demo 源碼: https://github.com/iAdramelk/optimizing-react-demo
[5] react-devtools:https://github.com/facebook/react-devtools
[6] 生命週期: https://reactjs.org/docs/react-component.html#the-component-lifecycle
[7] utilities: https://github.com/dashed/shallowequal
[8] updated Demo: https://iadramelk.github.io/optimizing-react-demo/dist/after.html

文章來源於 全棧探索 微信公衆號,掃描下面二維碼關注:

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