學習React的虛擬DOM,並使用這些知識來提高你的應用程序的速度。經過這篇對框架內部實現友好入門的介紹中,咱們將揭開JSX的神祕面紗,向您展現React如何作出渲染決策,解釋如何查找瓶頸,並分享一些避免常見錯誤的技巧。html
React不斷震撼前端世界,並且沒有衰退跡象的緣由之一是它平滑的學習曲線:在瞭解了JSX和整個「State」,「Props」概念以後,你就能夠開始了。前端
但要真正掌握React,你須要站在React之上進行思考,這篇文章將如你所願。看看咱們爲其中一個項目製做的React表:react
有數百個動態的、可過濾的行,理解框架的細微之處就成爲保證用戶體驗順暢的關鍵。當事情出錯時,你固然能感受到。輸入字段會變慢,複選框須要一秒鐘才能被選中,模態窗口展示緩慢。 爲了可以解決這類問題,咱們須要走完一個React組件從被你定義到呈現到頁面上,而後更新的一整段旅程,那咱們就立刻開始吧!web
React開發人員建議您在編寫組件時混合使用HTML和JavaScript,即JSX。可是,瀏覽器對JSX及其語法一無所知。瀏覽器只能理解普通的JavaScript,所以必須將JSX轉換成普通的JavaScript。下面是包含一個類和一些內容的div的JSX代碼算法
<div className='cn'>
Content!
</div>
複製代碼
在「正式」JavaScript中,這段代碼等同於一個帶有許多參數的函數調用數組
React.createElement(
'div',
{ className: 'cn' },
'Content!'
);
複製代碼
讓咱們仔細看看這些參數。第一個是元素的類型。對於HTML標記,它是一個帶有標記名稱的字符串。第二個參數是一個具備全部元素屬性的對象。若是沒有對象,它也能夠是空對象。餘下全部參數都是元素的子元素。元素內的文本也算做子元素,所以字符串'Content!'做爲函數調用的第三個參數瀏覽器
你能夠想象當咱們有更多的孩子會發生什麼安全
<div className='cn'>
Content 1!
<br />
Content 2!
</div>
複製代碼
React.createElement(
'div',
{ className: 'cn' },
'Content 1!', // 第一個孩子節點
React.createElement('br'), // 第二個孩子節點
'Content 2!' // 第三個孩子節點
)
複製代碼
咱們的函數如今有5個參數:一個元素的類型、一個屬性對象和3個子元素。因爲咱們的一個子標記也是已知的HTML標記,因此這個子標記也將被描述爲一個函數調用。bash
到目前爲止,咱們已經介紹了兩種類型的子元素:純字符串或另外一個對React.createElement的調用。可是,其餘值也能夠用做參數框架
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>
);
}
複製代碼
組件容許咱們將模板分解成可重用的塊。在上面的組件示例中,咱們接受一個包含錶行數據的對象數組,並返回單個table元素及其行做爲子元素的React.createElement調用。 咱們把組件放到頁面上時,咱們這樣寫
<Table rows={rows} />
複製代碼
從瀏覽器的角度來看,咱們都會這樣寫
React.createElement(Table, { rows: rows });
複製代碼
注意,這一次咱們的第一個參數不是描述HTML元素標記,而是在編寫組件時定義的函數的引用,咱們的屬性如今成了props
所以,咱們已經將全部的JSX組件轉換爲純JavaScript,如今咱們有了一堆函數調用,它們的參數是其餘函數調用的參數……它是如何所有轉換成DOM元素來造成web頁面的? 爲此,咱們有一個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(而不是虛擬的)。
下面是另外一個例子:此次的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鍵出現,所以,無論子元素是以數組仍是參數列表的形式傳遞,在最終的虛擬DOM對象中,它們最終都會在一塊兒,更重要的是,咱們能夠在JSX代碼中直接將孩子添加到props中,結果仍然是相同的。
<div className='cn' children={['Content 1!', 'Content 2!']} />
複製代碼
在構建一個虛擬DOM對象以後,根據如下規則,ReactDOM.render試圖將虛擬DOM對象轉換成瀏覽器能夠呈現的DOM節點
結果,咱們獲得如下html(對於上面表格的例子)
<table>
<tr>
<td>Title</td>
</tr>
...
</table>
複製代碼
注意到標題中的「重建」,當咱們想要更新一個頁面而不替換全部內容時,React中真正的魔力就開始了。咱們有不少途徑來實現這一目標,讓咱們從最簡單的開始--->對相同的節點再次調用React.render
// 第二次調用
ReactDOM.render(
React.createElement(Table, { rows: rows }),
document.getElementById('#root')
);
複製代碼
這一次,上面的代碼段的行爲將與咱們已經看到的不一樣。而不是從頭建立全部DOM節點並將它們放在頁面上。React將啓動調和算法,以肯定哪些節點須要更新,哪些能夠保持不變。 那麼,它是如何工做的呢?只有少數幾個簡單的場景,理解它們對咱們的優化有很大的幫助。請記住,咱們如今看到的對象是React Virtual DOM中節點的表示形式
// 更新前
{ type: 'div', props: { className: 'cn' } }
// 更新後
{ type: 'div', props: { className: 'cn' } }
複製代碼
這是最簡單的狀況:DOM保持不變
// 更新前
{ type: 'div', props: { className: 'cn' } }
// 更新後
{ type: 'div', props: { className: 'cnn' } }
複製代碼
因爲咱們的類型仍然表示HTML元素,React知道如何經過標準的DOM API調用來更改其屬性,而無需從DOM樹中刪除節點
// 更新前
{ type: 'div', props: { className: 'cn' } }
// 更新後
{ type: 'span', props: { className: 'cn' } }
複製代碼
當React如今看到類型不一樣時,它甚至不會嘗試更新咱們的節點:舊元素將與其全部子元素一塊兒被刪除(卸載)。所以,將一個元素替換爲DOM樹中徹底不一樣的元素可能很是昂貴。幸運的是,這種狀況在現實世界中不多發生。 務必記住,React使用===(三重等於)來比較類型值,所以它們必須是相同類或相同函數的相同實例。
下一個場景更有趣,由於這是咱們最經常使用的React方式
// 更新前
{ type: Table, props: { rows: rows } }
// 更新後
{ type: Table, props: { rows: rows } }
複製代碼
你可能會說「可是什麼也沒有改變!」那你就錯了。 若是類型是一個函數或一個類(即常規React組件),而後咱們開始樹的調和過程,React老是儘可能的深刻到組件內部確保render返回的值沒有變化。對樹下的每一個組件進行一樣的過程——是的,複雜的渲染也可能變得昂貴!
注意一下children
除了上面描述的四種常見場景外,咱們還須要考慮當元素有多個子元素時React的行爲。假設有這樣一個元素
// ...
props: {
children: [
{ type: 'div' },
{ type: 'span' },
{ type: 'br' }
]
},
// ...
複製代碼
咱們想把這些孩子們拖來拖去
// ...
props: {
children: [
{ type: 'span' },
{ type: 'div' },
{ type: 'br' }
]
},
// ...
複製代碼
而後會發生什麼呢?
若是,在調和的過程當中,React遇到props.children數組,它開始比較其中的元素和它以前看到的數組中的元素,經過查看它們的順序:索引0將與索引0進行比較,索引1與索引1進行比較,等等。對於每一對,React將應用上述規則集。在咱們的例子中,它看到div變成了一個span,所以將應用場景3。這不是頗有效:假設咱們從一個1000行的表中刪除了第一行。React將不得不「更新」剩下的999個子元素,由於它們的內容與以前對於索引表示的內容將不相等。
幸運的是,React有一個內置的方法來解決這個問題。若是一個元素有一個key屬性,那麼元素將經過key的值進行比較,而不是經過索引。只要key是唯一的,React就會移動元素,而不須要從DOM樹中刪除它們,而後將它們放回去(在React中稱爲掛載/卸載)。
// ...
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。下面是一個簡單的「有狀態」組件:
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>)
}
複製代碼
狀態對象中有一個counter鍵。單擊按鈕將增長其值並更改按鈕文本。但在DOM中會發生什麼呢?哪一部分須要從新計算和更新? 調用this.setState也會致使從新渲染,但不是整個頁面,而是組件自己及其子組件。父節點和兄弟節點倖免於難。當咱們有一棵很大的樹時,這是很方便的,咱們只想重畫它的一部分。
理清問題
咱們準備了一個小的演示應用程序,因此你能夠看到最多見的問題,在咱們開始修復它們以前。您能夠在這裏查看它的源代碼。您還須要React Developer工具,所以請確保爲您的瀏覽器安裝了這些工具。
咱們首先要看的是哪些元素以及何時更新虛擬DOM。導航到瀏覽器開發工具中的React面板,並選擇「高亮顯示更新」複選框
如今嘗試向表中添加一行。能夠看到,頁面上的每一個元素周圍都有一個邊框。這意味着React會在每次添加一行時計算並比較整個虛擬DOM樹。如今試着點擊一行中的計數器按鈕。您將看到有關元素及其子元素的狀態更改對虛擬DOM更新的影響
React DevTools暗示了問題可能在哪裏,但沒有告訴咱們任何細節:特別是所涉及的更新是否意味着「調和」元素或掛載/卸載它們。要了解更多信息,咱們須要使用React的內置分析器(注意,它在生產模式下沒法工做)
將?react_perf添加到應用程序的任何URL中,並在Chrome DevTools中打開「Performance」選項卡。點擊錄製按鈕,而後點擊表格。添加一些行,改變一些計數器,而後點擊「中止」
在結果輸出中,咱們感興趣的是「用戶計時」。放大到時間軸,直到看到「React Tree Reconciliation」組及其子組。這些都是咱們組件的名稱,旁邊有[更新]或[掛載]咱們的大多數性能問題都屬於這兩類
要麼某個組件(以及從它派生出來的全部組件)因爲某種緣由在每次更新時都要從新掛載,咱們不想要從新掛載(從新掛載很慢),要麼咱們在大型分支上執行代價高昂的協調,即便沒有任何更改。
解決問題:掛載/卸載
如今,當咱們瞭解了有關React如何決定更新虛擬DOM的一些理論,並瞭解瞭如何查看幕後發生的事情時,咱們終於準備好解決問題了!首先,讓咱們處理掛載/卸載。 若是你簡單的意識到任務元素/組件的多個子元素在內部被當作一個數組的事實,那麼您能夠得到很是顯著的速度提高
考慮一下這個
<div>
<Message />
<Table />
<Footer />
</div>
複製代碼
在咱們的虛擬DOM中,它將被表示爲
// ...
props: {
children: [
{ type: Message },
{ type: Table },
{ type: Footer }
]
}
// ...
複製代碼
咱們有一個簡單的消息,它是一個包含一些文本的div和一個巨大的表,比方說,跨越1000多行。它們都是封閉的div的子元素,所以它們被放置在父節點的props.children之下,它們沒有key。而React甚至不會提醒咱們經過控制檯警告來分配key,由於子節點會做爲參數列表而不是數組被傳遞到父節點的React.createElement ,如今咱們的用戶已經取消了一個通知,消息也從樹中刪除了。只剩下Table和Footer
// ...
props: {
children: [
{ type: Table },
{ type: Footer }
]
}
// ...
複製代碼
React會如何看待這種狀況?它將被視爲children數組改變了形狀:children[0]包含的是Message,如今children[0]包含的是Tabel,由於沒有可比較的key,因此它比較類型,因爲它們都是對函數的引用(以及不一樣的函數),因此它卸載整個表並再次掛載它,呈現它的全部子表:1000+行!
所以,您能夠添加惟一的鍵(但在這種狀況下,使用鍵並非最佳選擇),或者使用更聰明的方法:使用短路布爾求值,這是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,咱們的索引都不會改變,固然,Tabel仍然會與Tabel進行比較(不管如何,在類型開始協調時引用組件),可是僅僅比較虛擬DOM一般比刪除DOM節點並再次從頭建立它們要快得多
如今讓咱們看看更先進的東西。我知道你喜歡高階組件。高階組件是一個函數,它接受一個組件做爲參數,執行一些操做,而後返回一個不一樣的函數
function withName(SomeComponent) {
// Computing name, possibly expensive...
return function(props) {
return <SomeComponent {...props} name={name} />;
}
}
複製代碼
這是一個很是常見的模式,可是您須要當心使用它
考慮以下:
class App extends React.Component() {
render() {
// 在每一次渲染的時候都生成一個實例
const ComponentWithName = withName(SomeComponent);
return <ComponentWithName />;
}
}
複製代碼
咱們在父組件的render方法中建立了一個高階組件,當從新渲染的時候,咱們的虛擬dom看起來像下面同樣
// 第一次渲染:
{
type: ComponentWithName,
props: {},
}
// 第二次渲染:
{
type: ComponentWithName, // 相同的名字,不一樣的實例
props: {},
}
複製代碼
如今,React但願在ComponentWithName上運行一個擴展算法,可是因爲此次相同的名稱引用了不一樣的實例,因此三重等於比較失敗,並且必須進行完整的從新掛載,而不是調和。注意,它還會致使狀態丟失,如這裏所述。幸運的是,它很容易修復:你須要把高階組件建立在render方法以外
const ComponentWithName = withName(Component);
class App extends React.Component() {
render() {
return <ComponentWithName />;
}
}
複製代碼
解決更新問題
因此,如今咱們確保不從新掛載,除非有必要。可是,對位於DOM樹根附近的組件的任何更改都將致使其全部子組件的差別和協調。複雜的結構是昂貴的,一般能夠避免。 若是有一種方法能夠告訴React不去查看某個分支,那就太好了,由於咱們確信其中沒有任何變化。
這種方法是存在的,它涉及到一個名爲shouldComponentUpdate的方法,該方法是組件生命週期的一部分。此方法在每次調用組件的render方法以前調用,並接收新的props和state值。而後咱們能夠自由地將它們與當前值進行比較,並決定是否應該更新組件(返回true或false)。若是返回false, React將不會從新渲染有問題的組件,也不會查看它的子組件。
一般狀況下,對props和state作一個淺層的比較就已經足夠了,若是頂層的值相同,則不要須要更新。淺層比較不是JavaScript的一個特性,可是有許多實用程序能夠實現這一點。
在他們的幫助下,咱們能夠像這樣編寫代碼
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,只是經過淺層比較props和state幫你實現了shouldComponentUpdate 。這聽起來很簡單,只需將類定義的extends部分中的組件替換爲PureComponent,就能夠享受效率。不過沒那麼快!考慮這些例子:
<Table
// map返回的是數組的一個新實例,因此淺層比較會失敗
rows={rows.map(/* ... */)}
// 字符串字面量始終不會等於前一個
style={ { color: 'red' } }
// 在做用域裏箭頭函數是一個新的未命名的方法,因此它老是觸發全局 diffing
onUpdate={() => { /* ... */ }}
/>
複製代碼
上面的代碼片斷演示了三種最多見的反模式。儘可能避開他們! 若是你在render方法定義以外,建立因此對象、數組、函數,並確保它們不會在調用之間更改,那麼您就是安全的。
您能夠在更新的演示中觀察PureComponent的效果,其中全部表的行都被「淨化」了。若是您在React DevTools中打開「Highlight Updates」,您將注意到只有表自己和新行在行插入時呈現,其餘全部行都保持不變。
可是,若是您火燒眉毛地要所有使用純組件並在您的應用程序中處處實現它們,趕忙暫停。比較兩組props和state並非免費的,對於大多數基本組件來講甚至不值得:運行shallowCompare要比使用diffing算法花費更多的時間。
經驗法則:純組件適用於複雜的表單和表,可是對於簡單組件例如button、icon,純組件會下降速度