本文譯自《Optimizing React: Virtual DOM explained》,做者是Alexey Ivanov和Andy Barnov,來自Evil Martians’ team團隊。javascript
譯者說:經過一些實際場景和demo,給你們描述React的Virtual Dom Diff一些核心的原理和規則,以及基於這些咱們能夠作些什麼提升應用的性能,很棒的文章。html
經過學習React的Virtual DOM的知識,去加速大家的應用吧。對框架內部實現的介紹,比較全面且適合初學者,咱們會讓JSX更加簡單易懂,給你展現React是如何判斷要不要從新render,解釋如何找到應用的性能瓶頸,以及給你們一些小貼士,如何避免常見錯誤。前端
React在前端圈內保持領先的緣由之一,由於它的學習曲線很是平易近人:把你的模板包在JSX
,瞭解一下props
和state
的概念以後,你就能夠輕鬆寫出React代碼了。java
若是你已經熟悉React的工做方式,能夠直接跳至「優化個人代碼」篇。node
但要真正掌握React,你須要像React同樣思考(think in React)。本文也會試圖在這個方面幫助你。react
下面看看咱們其中一個項目中的React table:git
這個表裏有數百個動態(表格內容變化)和可過濾的選項,理解這個框架更精細的點,對於保證順暢的用戶體驗相當重要。github
當事情出錯時,你必定能感受到。輸入字段變得遲緩,複選框須要檢查一秒鐘,彈窗一個世紀後纔出現,等等。算法
爲了可以解決這些問題,咱們須要完成一個React組件的整個生命旅程,從一開始的聲明定義到在頁面上渲染(再而後可能會更新)。繫好安全帶,咱們要發車了!chrome
這個過程通常在前端會稱爲「轉譯」,但其實「彙編」將是一個更精確的術語。
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
可使用數組是由於能夠將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
參數了。
因此,咱們已經將全部JSX組件轉換爲純JavaScript,如今咱們有一大堆函數調用,它的參數會被其餘函數調用的,或者還有更多的其餘函數調用這些參數......這些帶參數的函數調用,是怎麼轉化成組成這個頁面的實體DOM的呢?
爲此,咱們有一個ReactDOM
庫及其它的render
方法:
function Table({ rows }) { /* ... */ } // defining a component // rendering a component ReactDOM.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!' ] } }
須要注意的是,那些除了type
和attribute
之外的屬性,本來是單獨傳進來的,轉換以後,會做爲在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>
在實際應用場景,render
一般在根節點調用一次,後續的更新會有state
來控制和觸發調用。
請注意,標題中的「從新」!當咱們想更新一個頁面而不是所有替換時,React中的魔法就開始了。咱們有一些實現它的方式。咱們先從最簡單的開始 —— 在同一個node節點再次執行ReactDOM.render
。
// Second call ReactDOM.render( React.createElement(Table, { rows: rows }), document.getElementById('#root') );
這一次,上面的代碼的表現,跟咱們已經看到的有所不一樣。React將啓動其diff
算法,而不是從頭開始建立全部DOM節點並將其放在頁面上,來肯定節點樹的哪些部分必須更新,哪些能夠保持不變。
那麼,它是怎樣工做的呢?其實只有少數幾個簡單的場景,理解它們將對咱們的優化幫助很大。請記住,如今咱們在看的,是在React Virtual DOM
裏面用來表明節點的對象
。
type
是一個字符串,type
在通話中保持不變,props
也沒有改變。// before update { type: 'div', props: { className: 'cn' } } // after update { type: 'div', props: { className: 'cn' } }
這是最簡單的狀況:DOM保持不變。
type
仍然是相同的字符串,props
是不一樣的。// before update: { type: 'div', props: { className: 'cn' } } // after update: { type: 'div', props: { className: 'cnn' } }
type
仍然表明HTML元素,React知道如何經過標準DOM API調用來更改元素的屬性,而無需從DOM樹中刪除一個節點。
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。
type
是一個component
。// before update: { type: Table, props: { rows: rows } } // after update: { type: Table, props: { rows: rows } }
你可能會說,「咦,但沒有任何變化啊!」,可是你錯了。
若是type
是對函數或類的引用(即常規的React組件),而且咱們啓動了tree diff的過程,則React會持續地去檢查組件的內部邏輯,以確保render
返回的值不會改變(相似對反作用的預防措施)。對樹中的每一個組件進行遍歷和掃描 —— 是的,在複雜的渲染場景下,成本可能會很是昂貴!
值得注意的是,一個component
的render
(只有類組件在聲明時有這個函數)跟ReactDom.render
不是同一個函數。
除了上述四種常見場景以外,當一個元素有多個子元素時,咱們還須要考慮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' } ] }, // ...
到目前爲止,咱們只聊了下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」複選框:
如今嘗試在表格中添加一行。如你所見,頁面上的每一個元素周圍都會顯示一個邊框。這意味着每次添加一行時,React都在計算和比較整個虛擬DOM樹。如今嘗試點擊一行內的counter按鈕。你將看到state
更新後虛擬DOM如何更新 —— 只有引用了state key
的元素及其children受到影響。
React DevTools會提示問題出在哪裏,但不會告訴咱們有關細節的信息:特別是所涉及的更新,是由diffing
元素引發的?仍是被掛載(mounting
)或者被卸載(unmounting
)了?要了解更多信息,咱們須要使用React的內置分析器(注意它不適用於生產模式)。
添加?react_perf
到應用的URL,而後轉到Chrome DevTools中的「Performance」標籤。點擊「錄製」(Record)並在表格上點擊。添加一些row,更改一下counter,而後點擊「中止」(Stop)。
在輸出的結果中,咱們關注「User timing」這項指標。放大時間軸直到看到「React Tree Reconciliation」這個組及其子項。這些就是咱們組件的名稱,它們旁邊都寫着[update]或[mount]。
咱們的大部分性能問題都屬於這兩類問題之一。
不管是組件(仍是從它分支的其餘組件)出於某種緣由都會在每次更新時re-mounted(慢),又或者咱們在大型應用上執行對每一個分支作diff,儘管這些組件並無發生改變,咱們不但願這些狀況的發生。
如今,咱們已經瞭解到當須要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行。它們(Message
和Table
)都是頂級div
的子組件,因此它們被放置在父節點的props.children
下,而且它們key
都不會有。React甚至不會經過控制檯警告咱們要給每一個child
分配key
,由於children正在React.createElement
做爲參數列表傳遞給父元素,而不是直接遍歷一個數組。
如今咱們的用戶已讀了一個通知,Message
(譬如新通知按鈕)從DOM上移除。Table
和Footer
是剩下的所有。
// ... props: { children: [ { type: Table }, { type: Footer } ] } // ...
React會怎麼處理呢?它會看做是一個array類型的children,如今少了第一項,從前第一項是Message
如今是Table
了,也沒有key
做爲索引,比較type
的時候又發現它們倆不是同一個function或者class的同一個實例,因而會把整個Table
unmount,而後在mount回去,渲染它的1000+行子數據。
所以,你能夠給每一個component添加惟一的key
(但在目特殊的case下,使用key並非最佳選擇),或者採用更聰明的小技巧:使用短路求值(又名「最小化求值」),這是JavaScript和許多其餘現代語言的特性。看:
// Using a boolean trick <div> {isShown && <Message />} <Table /> <Footer /> </div>
雖然Message
會離開屏幕,父元素div
的props.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 once const ComponentWithName = withName(Component); class App extends React.Component() { render() { return <ComponentWithName />; } }
如今咱們能夠確保在非必要的時候,不作re-mount的事情了。然而,對位於DOM樹根部附近(層級越上面的元素)的組件所作的任何更改都會致使其全部children的diffing和調整(reconciliation
)。在層級不少、結構複雜的應用裏,這些成本很昂貴,但常常是能夠避免的。
若是有一種方法能夠告訴React你不用來檢查這個分支了,由於咱們能夠確定那個分支不會有更新,那就太棒了!
這種方式是真的有的哈,它涉及一個built-in方法叫shouldComponentUpdate
,它也是組件生命週期的一部分。這個方法的調用時機:組件的render
和組件接收到state或props的值的更新時。而後咱們能夠自由地將它們與咱們當前的值進行比較,並決定是否更新咱們的組件(返回true
或false
)。若是咱們返回false
,React將不會從新渲染組件,也不會檢查它的全部子組件。
一般來講,比較兩個集合(set)props
和state
一個簡單的淺層比較(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 style={ { 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比較兩組props
和state
不是免費的,對於大多數基本組件來講,甚至都不值得:shallowCompare
比diffing
算法須要耗費更多的時間。
使用這個經驗法則:pure component適用於複雜的表單和表格,但它們一般會減慢簡單元素(按鈕、圖標)的效率。
感謝你的閱讀!如今你已準備好將這些看法應用到你的應用程序中。可使用咱們的小demo(用了或沒有用PureComponent)的倉庫做爲你的實驗的起點。此外,請繼續關注本系列的下一部分,咱們計劃涵蓋Redux並優化你的數據,目標是提升整個應用的整體性能。
正如原文末所說,Alex和Andy後續會繼續寫一個關於總體性能的系列,包括核心React和Redux等,我也會繼續跟蹤這個系列的文章,到時po到個人我的博客和知乎專欄《集異璧》,感興趣的同窗們能夠關注一下哈 :)
歡迎對本文的翻譯質量、內容的各類討論。如有表述不當,歡迎斧正。
2018.05.13,晴,杭州濱江
Yuying Wu
筆者 @Yuying Wu,前端愛好者 / 鼓勵師 / 新西蘭打工度假 / 鏟屎官。目前就任於某大型電商的B2B前端團隊。
感謝你讀到這裏。若是你和我同樣喜歡前端,喜歡搗騰獨立博客或者前沿技術,或者有什麼職業疑問,歡迎關注我以及各類交流哈。
獨立博客:wuyuying.com
知乎ID:@Yuying Wu
Github:Yuying Wu