天天看點

React 的慢與快:優化 React 應用實戰

<b>本文講的是React 的慢與快:優化 React 應用實戰,</b>

<b></b>

React 是慢的。我的意思是,任何中等規模的 React 應用都是慢的。但是在開始找備選方案之前,你應該明白任何中等規模的 Angular 或 Ember 應用也是慢的。好消息是:如果你在乎性能,使 React 應用變得超級快則相當容易。這篇文章就是案例。

我說的 “慢” 到底是什麼意思?舉個例子。

React 的慢與快:優化 React 應用實戰
React 的慢與快:優化 React 應用實戰

如果你從未見過火焰圖,看起來會有點吓人,但它其實非常易于使用。這個 "User Timing" 圖顯示的是每個元件占用的時間。它隐藏了 React 内部花費的時間(這部分時間是你無法優化的),是以這圖使你專注優化你的應用。這個 Timeline 顯示的是不同階段的視窗截屏,這就能聚焦到點選表頭時對應的時間點情況。

React 的慢與快:優化 React 應用實戰

似乎在點選排序按鈕後,甚至在拿到 REST 資料 之前 就已經重新渲染,我的應用就重新渲染了 <code>&lt;List&gt;</code> 元件。這個過程花費了超過 500ms。這個應用僅僅更新了表頭的排序 icon,和在資料表之上展示灰色遮罩表明資料仍在傳輸。

根據上述火焰圖,你會看到許多小的凹陷。那不是一個好标志。這意味着許多元件被重繪了。火焰圖顯示,<code>&lt;Datagrid&gt;</code> 元件更新花費了最多時間。為什麼在擷取到新資料之前應用會重繪整個資料表呢?讓我們來深入探讨。

要了解重繪的原因,通常要借助在 <code>render</code> 函數裡添加 <code>console.log()</code> 語句完成。因為函數式的元件,你可以使用如下的單行高階元件(HOC):

在這個例子中,當使用者點選列的标題,應用觸發一個 action 來改變 state:此列的排序 [<code>currentSort</code>] 被更新。這個 state 的改變觸發了 <code>&lt;List&gt;</code> 頁的重繪,反過來造成了整個 <code>&lt;Datagrid&gt;</code> 元件的重繪。在點選排序按鈕後,我們希望 datagrid 表頭能夠立刻被重繪,作為使用者行為的回報。

使得 React 應用遲緩的通常不是單個慢的元件(在火焰圖中反映為一個大的區塊)。大多數時候,使 React 應用變慢的是許多元件無用的重繪。 你也許曾讀到,React 虛拟 DOM 超級快的言論。那是真的,但在一個中等規模的應用中,全量重繪容易造成成百的元件重繪。甚至最快的虛拟 DOM 模闆引擎也不能使這一過程低于 16ms。

這是 <code>&lt;Datagrid&gt;</code> 元件的 <code>render()</code> 方法:

這看起來是一個非常簡單的 datagrid 的實作,然而這 非常低效。每個 <code>&lt;DatagridCell&gt;</code> 調用會渲染至少兩到三個元件。正如你在初次界面截圖裡看到的,這個表有 7 列,11 行,即 7x11x3 = 231 個元件會重新渲染。僅僅是 <code>currentSort</code> 的改變時,這簡直是浪費時間。雖然在虛拟 DOM 沒有更新的情況下,React 不會更新真實DOM,所有元件的處理也會耗費 500ms。

為了避免無用的表體渲染,第一步就是把它 抽取 出來:

通過抽取表體邏輯,我建立了新的 <code>&lt;DatagridBody&gt;</code> 元件:

抽取表體對性能上毫無影響,但它反映了一條優化之路。龐大的,通用的元件優化起來有難度。小的,單一職責的元件更容易處理。

以上述 <code>&lt;DatagridBody&gt;</code> 元件為例,除非 props 改變,否則 body 就不應該重繪。

是以元件應該如下:

小提示:相比手工實作 <code>shouldComponentUpdate()</code> 方法,我可以繼承 React 的 <code>PureComponent</code> 而不是 <code>Component</code>。這個元件會用嚴格對等(<code>===</code>)對比所有的 props,并且僅當 任一 props 變更時重繪。但是我知道在例子的上下文中 <code>resource</code> 和<code>children</code> 不會變更,是以無需檢查他們的對等性。

有了這一優化,點選表頭後,<code>&lt;Datagrid&gt;</code> 元件的重繪會跳過表體及其全部 231 個元件。這會将 500ms 的更新時間減少到 60ms。網絡性能提高超過 400ms!

React 的慢與快:優化 React 應用實戰

小提示:别被火焰圖的寬度騙了,比前一個火焰圖而言,它放大了。這幅火焰圖顯示的性能絕對是最好的!

<code>shouldComponentUpdate</code> 優化在圖中去掉了許多凹坑,并減少了整體渲染時間。我會用同樣的方法避免更多的重繪(例如:避免重繪 sidebar,操作按鈕,沒有變化的表頭和頁碼)。一個小時的工作之後, 點選表頭的列後,整個頁面的渲染時間僅僅是 100ms。那相當快了 - 即使仍然存在優化空間。

添加一個 <code>shouldComponentUpdate</code> 方法也許似乎很麻煩,但如果你真的在乎性能,你所寫的大多數元件都應該加上。

别哪裡都加上 <code>shouldComponentUpdate</code> - 在簡單元件上執行 <code>shouldComponentUpdate</code> 方法有時比僅渲染元件要耗時。也别在應用的早期使用 - 這将過早地進行優化。但随着應用的壯大,你會發現元件上的性能瓶頸,此時才添加<code>shouldComponentUpdate</code> 邏輯保持快速地運作。

我不是很滿意之前在 <code>&lt;DatagridBody&gt;</code> 上的改造:由于使用了 <code>shouldComponentUpdate</code>,我不得不改造成簡單的基于類的函數式元件。這增加了許多行代碼,每一行代碼都要耗費精力 - 去寫,調試和維護。

這段代碼與上述的初始實作僅有的差異是:我導出了 <code>pure(DatagridBody)</code> 而非 <code>DatagridBody</code>。<code>pure</code> 就像<code>PureComponent</code>,但是沒有額外的類模闆。

當使用 <code>recompose</code> 的 <code>shouldUpdate()</code> 而不是 <code>pure()</code> 的時候,我甚至可以更加具體,隻瞄準我知道可能改變的 props:

<code>checkPropsChange</code> 是純函數,我甚至可以導出做單元測試。

recompose 庫提供了更多 HOC 的性能優化方案,例如 <code>onlyUpdateForKeys()</code>,這個方法所做的檢查,與我自己寫的<code>checkPropsChange</code> 那類檢查完全相同。

強烈推薦 recompose 庫,除了能優化性能,它能幫助你以函數和可測的方式抽取資料擷取邏輯,HOC 組合和進行 props 操作。

并且,當心 Redux 用嚴格模式對比 props。因為 Redux 将 state 綁定到元件的 props 上,如果你修改 state 上的一個對象,Redux 的 props 對比會錯過它。這也是為什麼你必須在 reducer 中用 不可變性原則

舉個栗子,在 admin-on-rest 中,點選表頭 dispatch 一個 <code>SET_SORT</code> action。監聽這個 action 的 reducer 必須 替換 state 中的 object,而不是 更新 他們。

還是這個 reducer,當 Redux 用 '===' 檢查到變化時,它發現 state 對象的不同,然後重繪 datagrid。但是我們修改 state 的話,Redux 将會忽略 state 的改變并錯誤地跳過重繪:

為了防止(Redux 中)無用的繪制 connected 元件,你必須確定 <code>mapStateToProps</code> 方法每次調用不會傳回新的對象。

以 admin-on-rest 中的 <code>&lt;List&gt;</code> 元件為例。它用以下代碼從 state 中為目前 resource 擷取一系列記錄(如:文章,評論等):

state 包含了一個數組,是以前擷取的記錄,以 resource 做索引。舉例,<code>state.admin.posts.data</code> 包含了一系列文章:

<code>mapStateToProps</code> 方法篩選 state 對象,隻傳回在 list 中展示的部分。如下所示:

問題是每次 <code>mapStateToProps</code> 執行,它會傳回一個新的對象,即使底層對象沒有被改變。結果,<code>&lt;List&gt;</code> 元件每次都會重繪,即使隻有 state 的一部分改變了 - date 或 ids 改變造成 id 改變。

現在 <code>&lt;List&gt;</code> 元件僅在 state 的子集改變時重繪。

作為重組問題,reselect selector 是純函數,易于測試群組合。它是為 Redux connected 元件編寫 selector 的最佳方式。

當你的元件變得更 “純” 時,你開始檢測導緻無用重繪壞模式。最常見的是 JSX 中對象字面量的使用,我更喜歡稱之為 "臭名昭著的 {{"。請允許我舉例說明:

每次 <code>&lt;Datagrid&gt;</code> 元件重繪,<code>&lt;MyTableComponent&gt;</code> 元件的 <code>style</code> 屬性都會得到一個新值。是以即使 <code>&lt;MyTableComponent&gt;</code>是純的,每次 <code>&lt;Datagrid&gt;</code> 重繪時它也會跟着重繪。事實上,每次把對象字面量當做屬性值傳遞到子元件時,你就打破了純函數。解法很簡單:

這看起來很基礎,但是我見過太多次這個錯誤,因而生成了檢測臭名昭著的 <code>{{</code> 的敏銳直覺。我把他們一律替換成常量。

另一個常用來劫持純函數的 suspect 是 <code>React.cloneElement()</code>。如果你把 prop 值作為第二參數傳入方法,每次渲染就會生成一個帶新 props 的新 clone 元件。

盡管 <code>&lt;CreateButton&gt;</code> 是純函數,但每次 <code>&lt;Toolbar&gt;</code> 繪制它也會繪制。那是因為 material-ui 的 <code>&lt;CardActions&gt;</code> 添加了一個特殊 style,為了使第一個子節點适應 margin - 它用了一個對象字面量來做這件事。是以 <code>&lt;CreateButton&gt;</code> 每次都收到不同的<code>style</code> 屬性。我用 recompose 的 <code>onlyUpdateForKeys()</code> HOC 解決了這個問題。

還有許多可以使 React 應用更快的方法(使用 keys、懶加載重路由、<code>react-addons-perf</code> 包、使用 ServiceWorkers 緩存應用狀态、使用同構等等),但正确實作 <code>shouldComponentUpdate</code> 是第一步 - 也是最有用的。

React 預設是不快的,但是無論是什麼規模的應用,它都提供了許多工具來加速。這也許是違反直覺的,尤其自從許多架構提供了 React 的替代品,它們聲稱比 React 快 n 倍。但 React 把開發者的體驗放在了性能之前。這也是為什麼用 React 開發大型應用是個愉快的體驗,沒有驚吓,隻有不變的實作速度。

隻要記住,每隔一段時間 profile 你的應用,讓出一些時間在必要的地方添加一些 <code>pure()</code> 調用。别一開始就做優化,别花費過多時間在每個元件的過度優化上 - 除非你是在移動端。記住在不同裝置進行測試,讓使用者對應用的響應式有良好印象。

<b>原文釋出時間為:2017年4月10日</b>

<b>本文來自雲栖社群合作夥伴掘金,了解相關資訊可以關注掘金網站。</b>

繼續閱讀