效能優化可以說是衡量一個react程式設計師的水平重要標準。
在學習react之初的時候,由於對react不夠了解,因此寫的專案雖然功能都實現了,但是效能優化方面的考慮卻做得很少,因此回過頭髮現以前自己以前寫的react程式碼確實有點糟糕。
為了提高自己的react水平,閒暇之餘就把以前的老專案拿出來分析優化,看看都有哪些問題,以及如何優化。這裡就以我以前做過的一個《投資日曆》為例做一次優化記錄。
專案線上地址:https://www.itiger.com/activity/forapp/finance-calendar
優化工具timeline/performance基礎使用教程:
https://developers.google.com/web/tools/chrome-devtools/evaluate-performance/timeline-tool?hl=zh-cn
chrome在版本57還是58的時候,將Timeline更名為performance
該專案主要的主要難點與效能瓶頸在於日曆的左右滑動與切換。由於需求定製程度非常高,沒有合適的第三方日曆外掛,所以就自己實現了一個。支援週日歷與月日曆的切換,支援左右滑動切換日期。
滑動效果僅支援移動端
問題出現在公司一款老的android測試機,發現動畫效果非常卡頓。因此有了優化的必要。
利用工具定位問題
首先利用performance工具的的錄製功能錄製一段操作過程。
點選左上角的黑色原點開始錄製。錄製過程中,多次滑動週日歷即可。然後大約5~10秒點選stop按鈕停止錄製。
錄製結果如圖。
從上圖中我們可以發現以下問題:
1、 窗格中出現了紅幀。出現紅幀表示頁面已經超負荷,會出現卡頓,響應緩慢等現象。
2、 大量的黃色區域,黃色區域越大,表示JavaScript的執行過程中的壓力也越大。
3、 高額的記憶體佔用,以及不正常的波動曲線(藍色)。詳細資訊可以在上圖中的JS Heap
中檢視。26.6 ~ 71.6M
。
我們可以在Main中觀察到當前時刻的函式呼叫棧詳情。當出現紅幀,選中紅幀區域,Main區域發現變化,變為當前選擇時段的函式呼叫棧詳情。我們會發現函式呼叫棧最上層有一個紅色三角形。點選會在下面的Summary裡發現對應的資訊以及警告。如下圖中的
Warning: Recuring handler took 86.69 ms
。
4、 層級很高的函式呼叫棧。檢視紅色區域的函式呼叫棧,我們會發現大量的react元件方法被重複呼叫。
一步一步開始優化
從上面的分析就可以簡單看出,雖然實現了非常複雜的功能,看上去很厲害的樣子,其實內部非常糟糕。幾乎可以作為react用法的反面教材了。
優化分析1
在上面的函式呼叫棧中,我們發現有一個方法出現的次數非常多,那就是receiveComponent
。因此可以預想到某個元件裡肯定使用了receiveComponent
相關的生命週期的方法。檢查程式碼,確實發現了幾處componentWillReceiveProps
的使用。
1 2 3 4 5 6 |
<span class="hljs-comment">// 每一次更新狀態都會重新整理一次,導致了大量的計算</span> <span class="hljs-selector-tag">componentWillReceiveProps</span>(nextProps) { <span class="hljs-selector-tag">this</span><span class="hljs-selector-class">.setState</span>({ <span class="hljs-attribute">navProcess</span>: getNavigation(nextProps.currentData) }) } |
剛開始學習react時可能會認為生命週期是一個學習難點,我們不知道什麼情況下去使用它們。慢慢的隨著經驗的增加,才發現,生命週期方法是萬萬不能輕易使用的。特別是與props/state改變,與元件重新渲染相關的幾個生命週期,如componentWillReceiveProps
, shouldComponentUpdate
,componentWillUpdate
等。這個實際案例告訴我們,他們的使用,會造成高額的效能消耗。所以不到萬不得已,不要輕易使用他們。
曾經看到過一篇英文博文,分析的是寧願多幾次render,也不要使用shouldComponentUpdate來優化程式碼。但是文章地址找不到,如果有其他看過的朋友請在評論裡留言分享一下,感謝
而只有componentDidMount
是非常常用的。
上面幾行簡單的程式碼,卻暴露了一個非常恐怖的問題。一個是使用了生命週期componentWillReceiveProps
。而另一個則是在props改變的同時,還修改了元件的state。我們知道當props在父級被改變時會造成元件的重新渲染,而元件內部的state的改變同樣也會造成元件的重新渲染,因此這幾句簡單的程式碼,讓元件的渲染無形中發生了很多次。
因此優化的方向就朝這兩個方向努力。首先不能使用componentWillReceiveProps
,其次我發現navProcess
其實可以在父級元件中計算,並通過props傳遞下來。所以優化後的程式碼如下:
1 2 3 4 5 6 7 8 9 10 |
function Index(props) { const { currentD, currentM, selectD, setDate, loading, error, process, navProcess } = props; return ( <div className="main"> <Calendar selectDate={selectD} curDate={currentD} curMonth={currentM} setDate={setDate} /> { loading ? null : error ? <ErrorMessage queryData={process.bind(null, selectD)} /> : <Classification navProcess={navProcess} selectDate={selectD} /> } {loading ? <Loading isLoading={ loading } /> : null} </div> ) } |
意外的驚喜是發現該元件最終優化成為了一個無狀態元件,輕裝上陣,完美。
這樣優化之後,重新渲染的發生少了好幾倍,執行壓力自然減少很多。因此當滑動週日歷時已經不會有紅幀發生了。但是月日曆由於DOM節點更多,仍然存在問題,因此核心的問題還不在這裡。我們還得繼續觀察。
優化分析2
在函式呼叫棧中我們可以很明顯的看到ani方法。而這個方法是我自己寫的運動實現。因此我得重點關注它的實現中是不是存在什麼問題。仔細瀏覽一遍,果然有問題。
發現在ani方法的回撥中,呼叫了2次setDate方法。
1 2 3 |
// 導致頂層高階元件多一次渲染,下層多很多次渲染 setDate(newCur, 0); setDate({ year: newCur.year, month: newCur.month }, 1) |
該setDate方法是在父級中定義用來修改父級state的方法。他的每一次呼叫都會引發由上自下的重新渲染,因此多次呼叫的代價是非常大的。所以我將要面臨的優化就是想辦法將這兩次呼叫合併為一次。
先看看優化以前setDate方法的定義是如何實現的。我想要通過不同的number來修改不同的state屬性。但是沒有考慮如果需要修改多個呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
setDate = (date, number) => { if (number == 0) { this.setState({ currentD: date, currentM: { year: date.year, month: date.month } }) } if (number == 1) { this.setState({ currentM: date }) } if (number == 2) { _date = date; _month = { year: date.year, month: date.month }; this.setState({ currentD: _date, currentM: _month, selectD: _date }) this.process(date); } } |
修改該方法為,傳遞一個物件字面量進去進行修改
1 2 3 4 5 6 7 8 9 10 11 12 |
setDate = (options) => { const state = { ...this.state, ...options }; if (options.selectD) { _date = options.selectD; _month = { year: _date.year, month: _date.month } state.currentD = _date; state.currentM = _month; this.process(_date, state); } else { this.setState(state); } } |
該方法有兩處優化,第一處優化是傳入的引數調整,想要修改那一個就直接傳入,用法類似setState。第二處優化是在this.process
方法中只呼叫一次this.setState
,總之這樣處理的目的都是統一的,當想要資料修改時只發生一次渲染。而之前的方法會導致3次甚至多次渲染。這樣優化之後,效能自然會提升很多。
優化分析3
但是優化並沒有結束,因為再錄製一段檢視,仍然會發現紅幀出現。
進一步檢視Calendar元件,發現每一次滑動切換,都會發生4次渲染。肯定有問題。
我的目的是最多發生兩次無法避免的渲染。多餘的肯定是因為程式碼的問題導致的冗餘渲染。因此繼續檢視程式碼。
發現在遞迴呼叫ani方法時,this.timer
並沒有被及時取消。
1 2 3 4 5 6 7 8 |
// 我的目的是每一次遞迴會呼叫一次requestAnimationFrame與cancelAnimationFrame // 但是這樣寫只會在遞迴結束時呼叫一次cancelAnimationFrame if (offset == duration) { callback && callback(); cancelAnimationFrame(this.timer); } else { this.timer = requestAnimationFrame(ani); } |
因此修改如下:
1 2 3 4 5 6 7 8 9 |
ani = () => { .... if (offset == duration) { callback && callback(); } else { this.timer = requestAnimationFrame(ani); } cancelAnimationFrame(this.timer); } |
這樣優化之後,發現記憶體佔用下降一些,但是紅幀仍然存在。看來計算量並沒有下降。繼續優化。
優化分析4
發現Calendar元件中,根據props中的curDate,curMonth計算而來的weekInfo與monthInfo被寫在了該元件的state中。由於state中資料的變化都會導致重新渲染,而我發現在程式碼中有多處對他們進行修改。
1 2 3 4 5 6 7 8 9 10 11 |
componentDidMount() { const { curDate, curMonth } = this.props this.setState({ weekInfo: calendar.get3WeekInfo(curDate), monthInfo: calendar.get3MonthInfo(curMonth) }) this.setMessageType(curDate, 0); this.setMessageType(curMonth, 1); } |
其實這種根據props中的引數計算而來的資料是萬萬不能寫在state中的,因為props資料的變化也會導致元件重新整理重新渲染,因此一個資料變化就會導致不可控制的多次渲染。這個時候更好的方式是直接在render中計算。因此優化如下:
1 2 3 4 5 |
ender() { ... let info = type == 0 ? c.get3WeekInfo(curDate) : c.get3MonthInfo(curMonth); ... } |
優化結果如下圖:
與第一張圖對比,我們發現,運動過程中出現的紅幀沒有了。二是窗格中黃色區域大量減少,表示js的計算量減少很多。三是記憶體佔用大幅降低,從最高的71M減少到了33M。記憶體的增長也更加平滑。
後續的優化大致目的都是一樣。不再贅述。
總結一下:
- 儘量避免生命週期方法的使用,特別是與狀態更新相關的生命週期,使用時一定要慎重。
- 能通過props重新渲染元件,就不要在額外新增state來增加渲染壓力。
- 一切的優化方向就是在實現功能的前提下減少重新渲染的發生。
這其中涉及到的技巧就需要大家在實戰中慢慢掌握了。