[譯] React性能優化:Virtual Dom原理淺析

[譯] React性能優化:Virtual Dom原理淺析

來自專欄 集異璧

本文譯自《Optimizing React: Virtual DOM explained》,作者是Alexey Ivanov和Andy Barnov,來自Evil Martians』 team團隊。

Optimizing React: Virtual DOM explained — Martian Chronicles?

evilmartians.com圖標

譯者:Yuying Wu,前端愛好者 & 鼓勵師 / WHVer / 鏟屎官 / 咖啡愛好者。目前就職於阿里1688前端小分隊,歡迎找我內推。

譯者說:通過一些實際場景和demo,給大家描述React的Virtual Dom Diff一些核心的原理和規則,以及基於這些我們可以做些什麼提高應用的性能,很棒的文章。

本文首發於個人博客的同名文章。


通過學習React的Virtual DOM的知識,去加速你們的應用吧。對框架內部實現的介紹,比較全面且適合初學者,我們會讓JSX更加簡單易懂,給你展示React是如何判斷要不要重新render,解釋如何找到應用的性能瓶頸,以及給大家一些小貼士,如何避免常見錯誤。

React在前端圈內保持領先的原因之一,因為它的學習曲線非常平易近人:把你的模板包在JSX,了解一下propsstate的概念之後,你就可以輕鬆寫出React代碼了。

如果你已經熟悉React的工作方式,可以直接跳至「優化我的代碼」篇。

但要真正掌握React,你需要像React一樣思考(think in React)。本文也會試圖在這個方面幫助你。

下面看看我們其中一個項目中的React table:

eBay上的一個複雜的React表格(用於業務)

這個表裡有數百個動態(表格內容變化)和可過濾的選項,理解這個框架更精細的點,對於保證順暢的用戶體驗至關重要。


當事情出錯時,你一定能感覺到。輸入欄位變得遲緩,複選框需要檢查一秒鐘,彈窗一個世紀後才出現,等等。


為了能夠解決這些問題,我們需要完成一個React組件的整個生命旅程,從一開始的聲明定義到在頁面上渲染(再然後可能會更新)。系好安全帶,我們要發車了!

JSX的背後

這個過程一般在前端會稱為「轉譯」,但其實「彙編」將是一個更精確的術語。

React開發人員敦促你在編寫組件時使用一種稱為JSX的語法,混合了HTML和JavaScript。但瀏覽器對JSX及其語法毫無頭緒,瀏覽器只能理解純碎的JavaScript,所以JSX必須轉換成JavaScript。這裡是一個div的JSX代碼,它有一個class name和一些內容:

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

以上的代碼,被轉換成「正經」的JavaScript代碼,其實是一個帶有一些參數的函數調用:

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

讓我們仔細看看這些參數。

  • 第一個是元素的type。對於HTML標籤,它將是一個帶有標籤名稱的字元串。
  • 第二個參數是一個包含所有元素屬性(attributes)的對象。如果沒有,它也可以是空的對象。
  • 剩下的參數都可以認為是元素的子元素(children)。元素中的文本也算作一個child,是個字元串Content! 作為函數調用的第三個參數放置。

你應該可以想像,當我們有更多的children時會發生什麼:

<div className=cn> Content 1! <br /> Content 2!</div>

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

我們的函數現在有五個參數:

  • 一個元素的類型
  • 一個屬性對象
  • 三個子元素。

因為其中一個child是一個React已知的HTML標籤(<br/>),所以它也會被描述為一個函數調用(React.createElement(br))。

到目前為止,我們已經涵蓋了兩種類型的children:

  • 簡單的String
  • 另一種會調用React.createElement

然而,還有其他值可以作為參數:

  • 基本類型 false, null, undefined, true
  • 數組
  • React Components

可以使用數組是因為可以將children分組並作為一個參數傳遞:

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

當然了,React的厲害之處,不僅僅因為我們可以把HTML標籤直接放在JSX中使用,而是我們可以自定義自己的組件,例如:

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

組件可以讓我們把模板分解為多個可重用的塊。在上面的「函數式」(functional)組件的例子里,我們接收一個包含表格行數據的對象數組,最後返回一個調用React.createElement方法的<table>元素,rows則作為children傳進table。

無論什麼時候,我們這樣去聲明一個組件時:

<Table rows={rows} />

從瀏覽器的角度來看,我們是這麼寫的:

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

注意,這次我們的第一個參數不是String描述的HTML標籤,而是一個引用,指向我們編寫組件時編寫的函數。組件的attributes現在是接收的props參數了。

把組件(components)組合成頁面(page)

所以,我們已經將所有JSX組件轉換為純JavaScript,現在我們有一大堆函數調用,它的參數會被其他函數調用的,或者還有更多的其他函數調用這些參數......這些帶參數的函數調用,是怎麼轉化成組成這個頁面的實體DOM的呢?

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

function Table({ rows }) { /* ... */ } // defining a component// rendering a componentReactDOM.render( React.createElement(Table, { rows: rows }), // "creating" a component document.getElementById(#root) // inserting it on a page);

ReactDOM.render被調用時,React.createElement最終也會被調用,返回以下對象:

// There are more fields, but these are most important to us{ type: Table, props: { rows: rows }, // ...}


這些對象,在React的角度上,構成了虛擬DOM。


他們將在所有進一步的渲染中相互比較,並最終轉化為 真正的DOM(virtual VS real, 虛擬DOM VS 真實DOM)。

下面是另一個例子:這次div有一個class屬性和幾個children:

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

變成:

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

需要注意的是,那些除了typeattribute以外的屬性,原本是單獨傳進來的,轉換之後,會作為在props.children以一個數組的形式打包存在。也就是說,無論children是作為數組還是參數列表傳遞都沒關係 —— 在生成的虛擬DOM對象的時候,它們最後都會被打包在一起的。

進一步說,我們可以直接在組件中把children作為一項屬性傳進去,結果還是一樣的:

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

在構建虛擬DOM對象完成之後,ReactDOM.render將會按下面的原則,嘗試將其轉換為瀏覽器可以識別和展示的DOM節點:

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

最後,得到以下HTML(對於我們的表格示例):

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

重新構建DOM(Rebuilding the DOM)

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

請注意,標題中的「重新」!當我們想更新一個頁面而不是全部替換時,React中的魔法就開始了。我們有一些實現它的方式。我們先從最簡單的開始 —— 在同一個node節點再次執行ReactDOM.render

// Second callReactDOM.render( React.createElement(Table, { rows: rows }), document.getElementById(#root));

這一次,上面的代碼的表現,跟我們已經看到的有所不同。React將從頭開始創建所有DOM節點並將其放在頁面上,而不是從頭開始創建所有DOM節點,React將啟動其diff演算法,來確定節點樹的哪些部分必須更新,哪些可以保持不變。

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

場景1:type是一個字元串,type在通話中保持不變,props也沒有改變。

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

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

場景2:type仍然是相同的字元串,props是不同的。

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

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

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

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

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

劃重點,記住React使用===(triple equals)來比較type的值,所以這兩個值需要是相同類或相同函數的相同實例。

下一個場景更加有趣,通常我們會這麼使用React。

場景4:type是一個component

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


你可能會說,「咦,但沒有任何變化啊!」,但是你錯了。


如果type是對函數或類的引用(即常規的React組件),並且我們啟動了tree diff的過程,則React會持續地去檢查組件的內部邏輯,以確保render返回的值不會改變(類似對副作用的預防措施)。對樹中的每個組件進行遍歷和掃描 —— 是的,在複雜的渲染場景下,成本可能會非常昂貴!

值得注意的是,一個componentrender(只有類組件在聲明時有這個函數)跟ReactDom.render不是同一個函數。

關注子組件(children)的情況

除了上述四種常見場景之外,當一個元素有多個子元素時,我們還需要考慮React的行為。現在假設我們有這麼一個元素:

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

我們想要交換一下這些children的順序:

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

之後會發生什麼呢?

diffing的時候,如果React在檢查props.children下的數組時,按順序去對比數組內元素的話:index 0將與index 0進行比較,index 1和index 1,等等。對於每一次對比,React會使用之前提過的diff規則。在我們的例子里,它認為div成為一個span,那麼就會運用到情景3。這樣不是很有效率的:想像一下,我們已經從1000行中刪除了第一行。React將不得不「更新」剩餘的999個子項,因為按index去對比的話,內容從第一條開始就不相同了。

幸運的是,React有一個內置的方法(built-in)來解決這個問題。如果一個元素有一個key屬性,那麼元素將按key而不是index來比較。只要key是唯一的,React就會移動元素,而不是將它們從DOM樹中移除然後再將它們放回(這個過程在React里叫mounting和unmounting)。

// ...props: { children: [ // Now React will look on key, not index { type: div, key: div }, { type: span, key: span }, { type: br, key: bt } ]},// ...

當state發生了改變

到目前為止,我們只聊了下React哲學裡面的props部分,卻忽視了另外很重要的一部分state。下面是一個簡單的stateful組件:

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對象里,我們有一個keycounter。點擊按鈕時,這個值會增加,然後按鈕的文本也會發生相應的改變。但是,當我們這樣做時,DOM中發生了什麼?哪部分將被重新計算和更新?

調用this.setState會導致re-render(重新渲染),但不會影響到整個頁面,而只會影響組件本身及其children組件。父母和兄弟姐妹都不會受到影響。當我們有一個層級很深的組件鏈時,這會讓狀態更新變得非常方便,因為我們只需要重繪(redraw)它的一部分。

把問題說清楚

我們準備了一個小demo,以便你可以在看到在「野蠻生長」的React編碼方式下最常見的問題,後續我也告訴大家怎麼去解決這些問題。你可以在這裡看看它的源代碼。你還需要React Developer Tools,請確保瀏覽器安裝了它們。

我們首先要看看的是,哪些元素以及什麼時候導致Virtual DOM的更新。在瀏覽器的開發工具中,打開React面板並選擇「Highlight Updates」複選框:

在Chrome中使用「突出顯示更新」複選框選中DevTools

現在嘗試在表格中添加一行。如你所見,頁面上的每個元素周圍都會顯示一個邊框。這意味著每次添加一行時,React都在計算和比較整個虛擬DOM樹。現在嘗試點擊一行內的counter按鈕。你將看到state更新後虛擬DOM如何更新 —— 只有引用了state key的元素及其children受到影響。

React DevTools會提示問題出在哪裡,但不會告訴我們有關細節的信息:特別是所涉及的更新,是由diffing元素引起的?還是被掛載(mounting)或者被卸載(unmounting)了?要了解更多信息,我們需要使用React的內置分析器(注意它不適用於生產模式)。

添加?react_perf到應用的URL,然後轉到Chrome DevTools中的「Performance」標籤。點擊「錄製」(Record)並在表格上點擊。添加一些row,更改一下counter,然後點擊「停止」(Stop)。

React DevTools的「Performance」選項卡

在輸出的結果中,我們關注「User timing」這項指標。放大時間軸直到看到「React Tree Reconciliation」這個組及其子項。這些就是我們組件的名稱,它們旁邊都寫著[update]或[mount]。


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


無論是組件(還是從它分支的其他組件)出於某種原因都會在每次更新時re-mounted(慢),又或者我們在大型應用上執行對每個分支做diff,儘管這些組件並沒有發生改變,我們不希望這些情況的發生。

優化我們的代碼:Mounting / Unmounting

現在,我們已經了解到當需要update Virtual Dom時,React是依據哪些規則去判斷要不要更新,以及也知道了我們可以通過什麼方式去追蹤這些diff場景的背後發生了什麼,我們終於準備好優化我們的代碼了!首先,我們來看看mounts/unmounts。

如果你能夠注意到當一個元素包含的多個children,他們是由array組成的話,你可以實現十分顯著的速度優化。

我們來看看這個case:

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

在我們的Virtual DOM里這麼表示:

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

這裡有一個簡單的Message例子,就是一個div寫著一些簡單的文本,和以及一個巨大的Table,比方說,超過1000行。它們(MessageTable)都是頂級div的子組件,所以它們被放置在父節點的props.children下,並且它們key都不會有。React甚至不會通過控制台警告我們要給每個child分配key,因為children正在React.createElement作為參數列表傳遞給父元素,而不是直接遍歷一個數組。

現在我們的用戶已讀了一個通知,Message(譬如新通知按鈕)從DOM上移除。TableFooter是剩下的全部。

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

React會怎麼處理呢?它會看作是一個array類型的children,現在少了第一項,從前第一項是Message現在是Table了,也沒有key作為索引,比較type的時候又發現它們倆不是同一個function或者class的同一個實例,於是會把整個Tableunmount,然後在mount回去,渲染它的1000+行子數據。

因此,你可以給每個component添加唯一的key(但在目特殊的case下,使用key並不是最佳選擇),或者採用更聰明的小技巧:使用短路求值(又名「最小化求值」),這是JavaScript和許多其他現代語言的特性。看:

// Using a boolean trick<div> {isShown && <Message />} <Table /> <Footer /></div>

雖然Message會離開屏幕,父元素divprops.children仍然會擁有三個元素,children[0]具有一個值false(一個布爾值)。請記住true, false, null, undefined是虛擬DOM對象type屬性的允許值,我們最終得到了類似的結果:

// ...props: { children: [ false, // isShown && <Message /> evaluates to false { type: Table }, { type: Footer } ]}// ...

因此,有沒有Message組件,我們的索引值都不會改變,Table當然仍然會跟Table比較(當type是一個函數或類的引用時,diff比較的成本還是會有的),但僅僅比較虛擬DOM的成本,通常比「刪除DOM節點」並「從0開始創建」它們要來得快。

現在我們來看看更多的東西。大家都挺喜歡用HOC的,高階組件是一個將組件作為參數,執行某些操作,最後返回另外一個不同功能的組件:

function withName(SomeComponent) { // Computing name, possibly expensive... return function(props) { return <SomeComponent {...props} name={name} />; }}

這是一種常見的模式,但你需要小心。如果我們這麼寫:

class App extends React.Component() { render() { // Creates a new instance on each render const ComponentWithName = withName(SomeComponent); return <SomeComponentWithName />; }}

我們在父節點的render方法內部創建一個HOC。當我們重新渲染(re-render)樹時,虛擬DOM是這樣子的:

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

現在,React會對ComponentWithName這個實例做diff,但由於此時同名引用了不同的實例,因此全等比較(triple equal)失敗,一個完整的re-mount會發生(整個節點換掉),而不是調整屬性值或順序。注意它也會導致狀態丟失,如此處所述。幸運的是,這很容易解決,你需要始終在render外面創建一個HOC:

// Creates a new instance just onceconst ComponentWithName = withName(Component);class App extends React.Component() { render() { return <ComponentWithName />; }}

優化我的代碼:Updating

現在我們可以確保在非必要的時候,不做re-mount的事情了。然而,對位於DOM樹根部附近(層級越上面的元素)的組件所做的任何更改都會導致其所有children的diffing和調整(reconciliation)。在層級很多、結構複雜的應用里,這些成本很昂貴,但經常是可以避免的。


如果有一種方法可以告訴React你不用來檢查這個分支了,因為我們可以肯定那個分支不會有更新,那就太棒了!


這種方式是真的有的哈,它涉及一個built-in方法叫shouldComponentUpdate,它也是組件生命周期的一部分。這個方法的調用時機:組件的render和組件接收到state或props的值的更新時。然後我們可以自由地將它們與我們當前的值進行比較,並決定是否更新我們的組件(返回truefalse)。如果我們返回false,React將不會重新渲染組件,也不會檢查它的所有子組件。

通常來說,比較兩個集合(set)propsstate一個簡單的淺層比較(shallow comparison)就足夠了:如果頂層的值不同,我們不必接著比較了。淺比較不是JavaScript的一個特性,但有很多小而美的庫(utilities)可以讓我們用上那麼棒的功能。

現在可以像這樣編寫我們的代碼:

class TableRow extends React.Component { // will return true if new props/state are different from old ones shouldComponentUpdate(nextProps, nextState) { const { props, state } = this; return !shallowequal(props, nextProps) && !shallowequal(state, nextState); } render() { /* ... */ }}

但是你甚至都不需要自己寫代碼,因為React把這個特性內置在一個類React.PureComponent裡面。它類似於 React.Component,只是shouldComponentUpdate已經為你實施了一個淺的props/state比較。

這聽起來很「不動腦」,在聲明class繼承(extends)的時候,把Component換成PureComponent就可以享受高效率。事實上,並不是這麼「傻瓜」,看看這些例子:

<Table // map returns a new instance of array so shallow comparison will fail rows={rows.map(/* ... */)} // object literal is always "different" from predecessor stylex={ { color: red } } // arrow function is a new unnamed thing in the scope, so there will always be a full diffing onUpdate={() => { /* ... */ }}/>

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


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


你在updated demo,所有table的rows都被「凈化」(purified)過,你可以看到PureComponent的表現了。如果你在React DevTools中打開「Highlight Updates」,你會注意到只有表格本身和新行在插入時會觸發render,其他的行保持不變。

[譯者說:為了便於大家理解purified,譯者在下面插入了原文demo的一段代碼]

class TableRow extends React.PureComponent { render() { return React.createElement(tr, { className: row }, React.createElement(td, { className: cell }, this.props.title), React.createElement(td, { className: cell }, React.createElement(Button)), ); }};

不過,如果你迫不及待地all in PureComponent,在應用里到處都用的話 —— 控制住你自己!

shallow比較兩組propsstate不是免費的,對於大多數基本組件來說,甚至都不值得:shallowComparediffing演算法需要耗費更多的時間。

使用這個經驗法則:pure component適用於複雜的表單和表格,但它們通常會減慢簡單元素(按鈕、圖標)的效率。


感謝你的閱讀!現在你已準備好將這些見解應用到你的應用程序中。可以使用我們的小demo(用了或沒有用PureComponent)的倉庫作為你的實驗的起點。此外,請繼續關注本系列的下一部分,我們計劃涵蓋Redux並優化你的數據,目標是提高整個應用的總體性能。

譯者說

正如原文末所說,Alex和Andy後續會繼續寫一個關於整體性能的系列,包括核心React和Redux等,我也會繼續跟蹤這個系列的文章,到時po到我的個人博客和知乎專欄《集異璧》,感興趣的同學們可以關注一下哈 :)

歡迎對本文的翻譯質量、內容的各種討論。若有表述不當,歡迎斧正。

2018.05.13,晴,杭州濱江

Yuying Wu


推薦閱讀:

React 沒有中間件還能用嗎?
一條命令創建免配置的 React + Antd + Typescript 項目
React 整潔代碼最佳實踐
#研發解決方案#共享能力的數屏

TAG:React | 前端性能優化 | 前端開發 |