本文譯自《Optimizing React: Virtual DOM explained》,作者是Alexey Ivanov和Andy Barnov,來自Evil Martians’ team團隊。
譯者說:通過一些實際場景和demo,給大家描述React的Virtual Dom Diff一些核心的原理和規則,以及基於這些我們可以做些什麼提高應用的效能,很棒的文章。
通過學習React的Virtual DOM的知識,去加速你們的應用吧。對框架內部實現的介紹,比較全面且適合初學者,我們會讓JSX更加簡單易懂,給你展示React是如何判斷要不要重新render,解釋如何找到應用的效能瓶頸,以及給大家一些小貼士,如何避免常見錯誤。
React在前端圈內保持領先的原因之一,因為它的學習曲線非常平易近人:把你的模板包在JSX
,瞭解一下props
和state
的概念之後,你就可以輕鬆寫出React程式碼了。
如果你已經熟悉React的工作方式,可以直接跳至“優化我的程式碼”篇。
但要真正掌握React,你需要像React一樣思考(think in React)。本文也會試圖在這個方面幫助你。
下面看看我們其中一個專案中的React table:
這個表裡有數百個動態(表格內容變化)和可過濾的選項,理解這個框架更精細的點,對於保證順暢的使用者體驗至關重要。
當事情出錯時,你一定能感覺到。輸入欄位變得遲緩,核取方塊需要檢查一秒鐘,彈窗一個世紀後才出現,等等。
為了能夠解決這些問題,我們需要完成一個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)組合成頁面(a page)
所以,我們已經將所有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>
複製程式碼
重新構建DOM(Rebuilding the DOM)
在實際應用場景,render
通常在根節點呼叫一次,後續的更新會有state
來控制和觸發呼叫。
請注意,標題中的“重新”!當我們想更新一個頁面而不是全部替換時,React中的魔法就開始了。我們有一些實現它的方式。我們先從最簡單的開始 —— 在同一個node節點再次執行ReactDOM.render
。
// Second call
ReactDOM.render(
React.createElement(Table, { rows: rows }),
document.getElementById('#root')
);
複製程式碼
這一次,上面的程式碼的表現,跟我們已經看到的有所不同。React將啟動其diff演算法,而不是從頭開始建立所有DOM節點並將其放在頁面上,來確定節點樹的哪些部分必須更新,哪些可以保持不變。
那麼,它是怎樣工作的呢?其實只有少數幾個簡單的場景,理解它們將對我們的優化幫助很大。請記住,現在我們在看的,是在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
返回的值不會改變(類似對副作用的預防措施)。對樹中的每個元件進行遍歷和掃描 —— 是的,在複雜的渲染場景下,成本可能會非常昂貴!
值得注意的是,一個component
的render
(只有類元件在宣告時有這個函式)跟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”核取方塊:
現在嘗試在表格中新增一行。如你所見,頁面上的每個元素周圍都會顯示一個邊框。這意味著每次新增一行時,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,儘管這些元件並沒有發生改變,我們不希望這些情況的發生。
優化我們的程式碼: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行。它們(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 />;
}
}
複製程式碼
優化我的程式碼:Updating
現在我們可以確保在非必要的時候,不做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