- 原文地址:React is Slow, React is Fast: Optimizing React Apps in Practice
- 原文作者:Fran?ois Zaninotto
- 譯文出自:掘金翻譯計劃
- 譯者:Jiang Haichao
- 校對者:Wneil, Chen Lu
React 是慢的。我的意思是,任何中等規(guī)模的 React 應(yīng)用都是慢的。但是在開始找備選方案之前,你應(yīng)該明白任何中等規(guī)模的 Angular 或 Ember 應(yīng)用也是慢的。好消息是:如果你在乎性能,使 React 應(yīng)用變得超級快則相當(dāng)容易。這篇文章就是案例。
衡量 React 性能
我說的 “慢” 到底是什么意思?舉個例子。
我正在為 admin-on-rest 這個開源項目工作,它使用 material-ui 和 Redux 為任一 REST API 提供一個 admin 用戶圖形界面。這個應(yīng)用已經(jīng)有一個數(shù)據(jù)頁,在一個表格中展示一系列記錄。當(dāng)用戶改變排列順序,導(dǎo)航到下一個頁面,或者做結(jié)果篩選,這個界面的響應(yīng)式做的我不夠滿意。接下來的截屏是刷新放慢了 5x 的結(jié)果。

來看看發(fā)生了什么,我在 URL 里插入一個 ?react_perf。自 React 15.4,可以通過這個屬性啟用 組件 Profiling。等待初始化數(shù)據(jù)頁加載完畢。在 Chrome 開發(fā)者工具打開 Timeline 選項卡,點擊 "Record" 按鈕,并單擊表頭更新排列順序。一旦數(shù)據(jù)更新,再次點擊 "Record" 按鈕停止記錄,Chrome 會在 "User Timing" 標(biāo)簽下展示一個黃色的火焰圖。

如果你從未見過火焰圖,看起來會有點嚇人,但它其實非常易于使用。這個 "User Timing" 圖顯示的是每個組件占用的時間。它隱藏了 React 內(nèi)部花費的時間(這部分時間是你無法優(yōu)化的),所以這圖使你專注優(yōu)化你的應(yīng)用。這個 Timeline 顯示的是不同階段的窗口截屏,這就能聚焦到點擊表頭時對應(yīng)的時間點情況。

似乎在點擊排序按鈕后,甚至在拿到 REST 數(shù)據(jù) 之前 就已經(jīng)重新渲染,我的應(yīng)用就重新渲染了 <List> 組件。這個過程花費了超過 500ms。這個應(yīng)用僅僅更新了表頭的排序 icon,和在數(shù)據(jù)表之上展示灰色遮罩表明數(shù)據(jù)仍在傳輸。
另外,這個應(yīng)用花了半秒鐘提供點擊的視覺反饋。500ms 絕對是可感知的 - UI 專家如是說,當(dāng)視覺層改變低于 100ms 時,用戶感知才是瞬時的。這一可覺察的變更即是我所說的 ”慢“。
為何而更新?
根據(jù)上述火焰圖,你會看到許多小的凹陷。那不是一個好標(biāo)志。這意味著許多組件被重繪了。火焰圖顯示,<Datagrid> 組件更新花費了最多時間。為什么在獲取到新數(shù)據(jù)之前應(yīng)用會重繪整個數(shù)據(jù)表呢?讓我們來深入探討。
要理解重繪的原因,通常要借助在 render 函數(shù)里添加 console.log() 語句完成。因為函數(shù)式的組件,你可以使用如下的單行高階組件(HOC):
// in src/log.js
const log = BaseComponent => props => {
console.log(`Rendering ${BaseComponent.name}`);
return <BaseComponent {...props} />;
}
export default log;
// in src/MyComponent.js
import log from './log';
export default log(MyComponent);
小提示:另一值得一提的 React 性能工具是 why-did-you-update。這個 npm 包在 React 基礎(chǔ)上打了一個補丁,當(dāng)一個組件基于相同 props 重繪時會打出 console 警告。說明:輸出十分冗長,并且在函數(shù)式組件中不起作用。
在這個例子中,當(dāng)用戶點擊列的標(biāo)題,應(yīng)用觸發(fā)一個 action 來改變 state:此列的排序 [currentSort] 被更新。這個 state 的改變觸發(fā)了 <List> 頁的重繪,反過來造成了整個 <Datagrid> 組件的重繪。在點擊排序按鈕后,我們希望 datagrid 表頭能夠立刻被重繪,作為用戶行為的反饋。
使得 React 應(yīng)用遲緩的通常不是單個慢的組件(在火焰圖中反映為一個大的區(qū)塊)。大多數(shù)時候,使 React 應(yīng)用變慢的是許多組件無用的重繪。 你也許曾讀到,React 虛擬 DOM 超級快的言論。那是真的,但在一個中等規(guī)模的應(yīng)用中,全量重繪容易造成成百的組件重繪。甚至最快的虛擬 DOM 模板引擎也不能使這一過程低于 16ms。
切割組件即優(yōu)化
這是 <Datagrid> 組件的 render() 方法:
// in Datagrid.js
render() {
const { resource, children, ids, data, currentSort } = this.props;
return (
<table>
<thead>
<tr>
{React.Children.map(children, (field, index) => (
<DatagridHeaderCell key={index} field={field} currentSort={currentSort} updateSort={this.updateSort}
/>
))}
</tr>
</thead>
<tbody>
{ids.map(id => (
<tr key={id}>
{React.Children.map(children, (field, index) => (
<DatagridCell record={data[id]} key={`${id}-${index}`} field={field} resource={resource} />
))}
</tr>
))}
</tbody>
</table>
);
}
這看起來是一個非常簡單的 datagrid 的實現(xiàn),然而這 非常低效。每個 <DatagridCell> 調(diào)用會渲染至少兩到三個組件。正如你在初次界面截圖里看到的,這個表有 7 列,11 行,即 7x11x3 = 231 個組件會重新渲染。僅僅是 currentSort 的改變時,這簡直是浪費時間。雖然在虛擬 DOM 沒有更新的情況下,React 不會更新真實DOM,所有組件的處理也會耗費 500ms。
為了避免無用的表體渲染,第一步就是把它 抽取 出來:
// in Datagrid.js
render() {
const { resource, children, ids, data, currentSort } = this.props;
return (
<table>
<thead>
<tr>
{React.Children.map(children, (field, index) => (
<DatagridHeaderCell key={index} field={field} currentSort={currentSort} updateSort={this.updateSort}
/>
))}
</tr>
</thead>
<DatagridBody resource={resource} ids={ids} data={data}>
{children}
</DatagridBody>
</table>
);
);
}
通過抽取表體邏輯,我創(chuàng)建了新的 <DatagridBody> 組件:
// in DatagridBody.js
import React from 'react';
const DatagridBody = ({ resource, ids, data, children }) => (
<tbody>
{ids.map(id => (
<tr key={id}>
{React.Children.map(children, (field, index) => (
<DatagridCell record={data[id]} key={`${id}-${index}`} field={field} resource={resource} />
))}
</tr>
))}
</tbody>
);
export default DatagridBody;
抽取表體對性能上毫無影響,但它反映了一條優(yōu)化之路。龐大的,通用的組件優(yōu)化起來有難度。小的,單一職責(zé)的組件更容易處理。
shouldComponentUpdate
React 文檔 里對于避免無用的重繪有非常明確的方法:shouldComponentUpdate()。默認(rèn)的,React 一直重繪 組件到虛擬 DOM 中。換句話說,作為開發(fā)者,在那種情況下,檢查 props 沒有改變的組件和跳過繪制都是你的工作。
以上述 <DatagridBody> 組件為例,除非 props 改變,否則 body 就不應(yīng)該重繪。
所以組件應(yīng)該如下:
import React, { Component } from 'react';
class DatagridBody extends Component {
shouldComponentUpdate(nextProps) {
return (nextProps.ids !== this.props.ids
|| nextProps.data !== this.props.data);
}
render() {
const { resource, ids, data, children } = this.props;
return (
<tbody>
{ids.map(id => (
<tr key={id}>
{React.Children.map(children, (field, index) => (
<DatagridCell record={data[id]} key={`${id}-${index}`} field={field} resource={resource} />
))}
</tr>
))}
</tbody>
);
}
}
export default DatagridBody;
小提示:相比手工實現(xiàn) shouldComponentUpdate() 方法,我可以繼承 React 的 PureComponent 而不是 Component。這個組件會用嚴(yán)格對等(===)對比所有的 props,并且僅當(dāng) 任一 props 變更時重繪。但是我知道在例子的上下文中 resource 和 children 不會變更,所以無需檢查他們的對等性。
有了這一優(yōu)化,點擊表頭后,<Datagrid> 組件的重繪會跳過表體及其全部 231 個組件。這會將 500ms 的更新時間減少到 60ms。網(wǎng)絡(luò)性能提高超過 400ms!

小提示:別被火焰圖的寬度騙了,比前一個火焰圖而言,它放大了。這幅火焰圖顯示的性能絕對是最好的!
shouldComponentUpdate 優(yōu)化在圖中去掉了許多凹坑,并減少了整體渲染時間。我會用同樣的方法避免更多的重繪(例如:避免重繪 sidebar,操作按鈕,沒有變化的表頭和頁碼)。一個小時的工作之后, 點擊表頭的列后,整個頁面的渲染時間僅僅是 100ms。那相當(dāng)快了 - 即使仍然存在優(yōu)化空間。
添加一個 shouldComponentUpdate 方法也許似乎很麻煩,但如果你真的在乎性能,你所寫的大多數(shù)組件都應(yīng)該加上。
別哪里都加上 shouldComponentUpdate - 在簡單組件上執(zhí)行 shouldComponentUpdate 方法有時比僅渲染組件要耗時。也別在應(yīng)用的早期使用 - 這將過早地進(jìn)行優(yōu)化。但隨著應(yīng)用的壯大,你會發(fā)現(xiàn)組件上的性能瓶頸,此時才添加 shouldComponentUpdate 邏輯保持快速地運行。
重組
我不是很滿意之前在 <DatagridBody> 上的改造:由于使用了 shouldComponentUpdate,我不得不改造成簡單的基于類的函數(shù)式組件。這增加了許多行代碼,每一行代碼都要耗費精力 - 去寫,調(diào)試和維護(hù)。
幸運的是,得益于 recompose,你能夠在高階組件(HOC)上實現(xiàn) shouldComponentUpdate 的邏輯。它是一個 React 的函數(shù)式工具,提供 pure() 高階實例。
// in DatagridBody.js
import React from 'react';
import pure from 'recompose/pure';
const DatagridBody = ({ resource, ids, data, children }) => (
<tbody>
{ids.map(id => (
<tr key={id}>
{React.Children.map(children, (field, index) => (
<DatagridCell record={data[id]} key={`${id}-${index}`} field={field} resource={resource} />
))}
</tr>
))}
</tbody>
);
export default pure(DatagridBody);
這段代碼與上述的初始實現(xiàn)僅有的差異是:我導(dǎo)出了 pure(DatagridBody) 而非 DatagridBody。pure 就像 PureComponent,但是沒有額外的類模板。
當(dāng)使用 recompose 的 shouldUpdate() 而不是 pure() 的時候,我甚至可以更加具體,只瞄準(zhǔn)我知道可能改變的 props:
// in DatagridBody.js
import React from 'react';
import shouldUpdate from 'recompose/shouldUpdate';
const DatagridBody = ({ resource, ids, data, children }) => (
...
);
const checkPropsChange = (props, nextProps) =>
(nextProps.ids !== this.props.ids
|| nextProps.data !== this.props.data);
export default shouldUpdate(checkPropsChange)(DatagridBody);
checkPropsChange 是純函數(shù),我甚至可以導(dǎo)出做單元測試。
recompose 庫提供了更多 HOC 的性能優(yōu)化方案,例如 onlyUpdateForKeys(),這個方法所做的檢查,與我自己寫的 checkPropsChange 那類檢查完全相同。
// in DatagridBody.js
import React from 'react';
import onlyUpdateForKeys from 'recompose/onlyUpdateForKeys';
const DatagridBody = ({ resource, ids, data, children }) => (
...
);
export default onlyUpdateForKeys(['ids', 'data'])(DatagridBody);
強烈推薦 recompose 庫,除了能優(yōu)化性能,它能幫助你以函數(shù)和可測的方式抽取數(shù)據(jù)獲取邏輯,HOC 組合和進(jìn)行 props 操作。
Redux
如果你正在使用 Redux 管理應(yīng)用的 state (我也推薦這一方式),那么 connected 組件已經(jīng)是純組件了。不需要添加 HOC。只要記住一旦其中一個 props 改變了,connected 組件就會重繪 - 這也包括了所有子組件。因此即使你在頁面組件上使用 Redux,你也應(yīng)該在渲染樹的深層用 pure() 或 shouldUpdate()。
并且,當(dāng)心 Redux 用嚴(yán)格模式對比 props。因為 Redux 將 state 綁定到組件的 props 上,如果你修改 state 上的一個對象,Redux 的 props 對比會錯過它。這也是為什么你必須在 reducer 中用 不可變性原則
舉個栗子,在 admin-on-rest 中,點擊表頭 dispatch 一個 SET_SORT action。監(jiān)聽這個 action 的 reducer 必須 替換 state 中的 object,而不是 更新 他們。
// in listReducer.js
export const SORT_ASC = 'ASC';
export const SORT_DESC = 'DESC';
const initialState = {
sort: 'id',
order: SORT_DESC,
page: 1,
perPage: 25,
filter: {},
};
export default (previousState = initialState, { type, payload }) => {
switch (type) {
case SET_SORT:
if (payload === previousState.sort) {
// inverse sort order
return {
...previousState,
order: oppositeOrder(previousState.order),
page: 1,
};
}
// replace sort field
return {
...previousState,
sort: payload,
order: SORT_ASC,
page: 1,
};
// ...
default:
return previousState;
}
};
還是這個 reducer,當(dāng) Redux 用 '===' 檢查到變化時,它發(fā)現(xiàn) state 對象的不同,然后重繪 datagrid。但是我們修改 state 的話,Redux 將會忽略 state 的改變并錯誤地跳過重繪:
// don't do this at home
export default (previousState = initialState, { type, payload }) => {
switch (type) {
case SET_SORT:
if (payload === previousState.sort) {
// never do this
previousState.order = oppositeOrder(previousState.order);
return previousState;
}
// never do that either
previousState.sort = payload;
previousState.order = SORT_ASC;
previousState.page = 1;
return previousState;
// ...
default:
return previousState;
}
};
為了不可變的 reducer,其他開發(fā)者喜歡用同樣來自 Facebook 的 immutable.js。我覺得這沒必要,因為 ES6 解構(gòu)賦值使得有選擇地替換組件屬性十分容易。另外,Immutable 也很笨重(60kB),所以在你的項目中添加它之前請三思。
重新選擇
為了防止(Redux 中)無用的繪制 connected 組件,你必須確保 mapStateToProps 方法每次調(diào)用不會返回新的對象。
以 admin-on-rest 中的 <List> 組件為例。它用以下代碼從 state 中為當(dāng)前 resource 獲取一系列記錄(如:帖子,評論等):
// in List.js
import React from 'react';
import { connect } from 'react-redux';
const List = (props) => ...
const mapStateToProps = (state, props) => {
const resourceState = state.admin[props.resource];
return {
ids: resourceState.list.ids,
data: Object.keys(resourceState.data)
.filter(id => resourceState.list.ids.includes(id))
.map(id => resourceState.data[id])
.reduce((data, record) => {
data[record.id] = record;
return data;
}, {}),
};
};
export default connect(mapStateToProps)(List);
state 包含了一個數(shù)組,是以前獲取的記錄,以 resource 做索引。舉例,state.admin.posts.data 包含了一系列帖子:
{
23: { id: 23, title: "Hello, World", /* ... */ },
45: { id: 45, title: "Lorem Ipsum", /* ... */ },
67: { id: 67, title: "Sic dolor amet", /* ... */ },
}
mapStateToProps 方法篩選 state 對象,只返回在 list 中展示的部分。如下所示:
{
23: { id: 23, title: "Hello, World", /* ... */ },
67: { id: 67, title: "Sic dolor amet", /* ... */ },
}
問題是每次 mapStateToProps 執(zhí)行,它會返回一個新的對象,即使底層對象沒有被改變。結(jié)果,<List> 組件每次都會重繪,即使只有 state 的一部分改變了 - date 或 ids 改變造成 id 改變。
Reselect 通過備忘錄模式解決這個問題。相比在 mapStateToProps 中直接計算 props,從 reselect 中用 selector 如果輸入沒有變化,則返回相同的輸出。
import React from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect'
const List = (props) => ...
const idsSelector = (state, props) => state.admin[props.resource].ids
const dataSelector = (state, props) => state.admin[props.resource].data
const filteredDataSelector = createSelector(
idsSelector,
dataSelector
(ids, data) => Object.keys(data)
.filter(id => ids.includes(id))
.map(id => data[id])
.reduce((data, record) => {
data[record.id] = record;
return data;
}, {})
)
const mapStateToProps = (state, props) => {
const resourceState = state.admin[props.resource];
return {
ids: idsSelector(state, props),
data: filteredDataSelector(state, props),
};
};
export default connect(mapStateToProps)(List);
現(xiàn)在 <List> 組件僅在 state 的子集改變時重繪。
作為重組問題,reselect selector 是純函數(shù),易于測試和組合。它是為 Redux connected 組件編寫 selector 的最佳方式。
當(dāng)心 JSX 中的對象字面量
當(dāng)你的組件變得更 “純” 時,你開始檢測導(dǎo)致無用重繪壞模式。最常見的是 JSX 中對象字面量的使用,我更喜歡稱之為 "臭名昭著的 {{"。請允許我舉例說明:
import React from 'react';
import MyTableComponent from './MyTableComponent';
const Datagrid = (props) => (
<MyTableComponent style={{ marginTop: 10 }}>
...
</MyTableComponent>
)
每次 <Datagrid> 組件重繪,<MyTableComponent> 組件的 style 屬性都會得到一個新值。所以即使 <MyTableComponent> 是純的,每次 <Datagrid> 重繪時它也會跟著重繪。事實上,每次把對象字面量當(dāng)做屬性值傳遞到子組件時,你就打破了純函數(shù)。解法很簡單:
import React from 'react';
import MyTableComponent from './MyTableComponent';
const tableStyle = { marginTop: 10 };
const Datagrid = (props) => (
<MyTableComponent style={tableStyle}>
...
</MyTableComponent>
)
這看起來很基礎(chǔ),但是我見過太多次這個錯誤,因而生成了檢測臭名昭著的 {{ 的敏銳直覺。我把他們一律替換成常量。
另一個常用來劫持純函數(shù)的 suspect 是 React.cloneElement()。如果你把 prop 值作為第二參數(shù)傳入方法,每次渲染就會生成一個帶新 props 的新 clone 組件。
// bad
const MyComponent = (props) => <div>{React.cloneElement(Foo, { bar: 1 })}</div>;
// good
const additionalProps = { bar: 1 };
const MyComponent = (props) => <div>{React.cloneElement(Foo, additionalProps)}</div>;
material-ui 已經(jīng)困擾了我一段時間,舉例如下:
import { CardActions } from 'material-ui/Card';
import { CreateButton, RefreshButton } from 'admin-on-rest';
const Toolbar = ({ basePath, refresh }) => (
<CardActions>
<CreateButton basePath={basePath} />
<RefreshButton refresh={refresh} />
</CardActions>
);
export default Toolbar;
盡管 <CreateButton> 是純函數(shù),但每次 <Toolbar> 繪制它也會繪制。那是因為 material-ui 的 <CardActions> 添加了一個特殊 style,為了使第一個子節(jié)點適應(yīng) margin - 它用了一個對象字面量來做這件事。所以 <CreateButton> 每次都收到不同的 style 屬性。我用 recompose 的 onlyUpdateForKeys() HOC 解決了這個問題。
// in Toolbar.js
import onlyUpdateForKeys from 'recompose/onlyUpdateForKeys';
const Toolbar = ({ basePath, refresh }) => (
...
);
export default onlyUpdateForKeys(['basePath', 'refresh'])(Toolbar);
結(jié)論
還有許多可以使 React 應(yīng)用更快的方法(使用 keys、懶加載重路由、react-addons-perf 包、使用 ServiceWorkers 緩存應(yīng)用狀態(tài)、使用同構(gòu)等等),但正確實現(xiàn) shouldComponentUpdate 是第一步 - 也是最有用的。
React 默認(rèn)是不快的,但是無論是什么規(guī)模的應(yīng)用,它都提供了許多工具來加速。這也許是違反直覺的,尤其自從許多框架提供了 React 的替代品,它們聲稱比 React 快 n 倍。但 React 把開發(fā)者的體驗放在了性能之前。這也是為什么用 React 開發(fā)大型應(yīng)用是個愉快的體驗,沒有驚嚇,只有不變的實現(xiàn)速度。
只要記住,每隔一段時間 profile 你的應(yīng)用,讓出一些時間在必要的地方添加一些 pure() 調(diào)用。別一開始就做優(yōu)化,別花費過多時間在每個組件的過度優(yōu)化上 - 除非你是在移動端。記住在不同設(shè)備進(jìn)行測試,讓用戶對應(yīng)用的響應(yīng)式有良好印象。
掘金翻譯計劃 是一個翻譯優(yōu)質(zhì)互聯(lián)網(wǎng)技術(shù)文章的社區(qū),文章來源為 掘金 上的英文分享文章。內(nèi)容覆蓋 Android、iOS、React、前端、后端、產(chǎn)品、設(shè)計 等領(lǐng)域,想要查看更多優(yōu)質(zhì)譯文請持續(xù)關(guān)注 掘金翻譯計劃。