推ter Lite以及大規模的高性能React漸進式網絡應用
推ter Lite以及大規模的高性能React漸進式網絡應用
讓我們一起來了解世界最大的React.js PWA, 推ter Lite 之中常見的和不太常見的性能瓶頸。
創建一個快速的web應用包含很多方面,包括:時間花費在什么地方,理解其發生的原因并且應用潛在的解決方案。不幸的是,從來就沒有一個快速的修復方法。性能是一個持續的問題,涉及到需要對需要提高的內容的持續觀察和檢測。在推ter Lite中,我們在很多方面進行了一些小的提升:從初始加載時間搭配React組件的渲染(以及避免再次渲染)到圖像的加載等等。大多數的變化往往是非常小的,當所有的變化疊加在一起讓我們開發出了最大的以及最快的 漸進式web應用 。
在繼續閱讀之前:
如果你才開始觀測并且提升你的web應用,我強烈推薦你 學習如何閱讀幀圖 ,如果你還不知道如何去做的話。
下面的每個章節包括例子的 Chrome里面的開發者工具timeline記錄的截圖。為了讓結果更清晰,我強調每一對例子壞的(左圖)和好的(右圖)進行對比(譯者注:因為markdown圖片顯示的問題,因此原文的左右圖在本文中是上圖和下圖)。
對于timeline和幀圖特別的一點:因為我們針對的是很多種的手機設備,我們一般都會在一個模擬的環境中記錄這些數據:比5x要慢的CPU以及3G的網絡連接。這個不僅更現實,而且還會讓問題更容易發現。
經過很多討論,我們終于通過路由將公共區域分解成獨立的塊(例子如下)。當我們收件箱收到代碼審查的通知的那一天終于來了:
const plugins = [ // 提取vendor和webpack模塊的manifest new webpack.optimiza.CommonChunkPlugin({ names: [ 'vendor', 'manifest'], minChunks: Infinity }), // 從所有的塊中提取公共模塊(不需要'name'屬性) mew webpack.optimize.CommonChunkPlugin({ async: true, children: true, minChunks: 4 }) ];
添加細粒度,基于路由的代碼分割。為了加快初始化和主頁timeline渲染,app的整體大小可能會更大,文件會在sesiion期間內按需分塊在40個代碼塊之中。-- Nicolas Gallagher
我們的原始設置(上面的圖)加載我們主要的壓縮包花費了超過5秒的時間,然而在通過路由和公共代碼塊進行代碼分割之后(下面的圖),就僅僅花費了3秒的時間(在模擬的3G網絡中)。
我們在性能優化初期專注完成了這一點,但是這一點變化對于Google的 Lighthouse 這一web應用審查工具的時候得到的結果卻有了顯著的變化:
避免函數導致的Jank
在我們 無限滾動的timeline 的眾多迭代中,我們使用不同的方式來計算你的滾動位置和方向,從而決定我們是否需要API來展示更多的Tweet。直到最近,我們使用了 react-waypoint ,這在我們項目中工作的很好。然而,為了盡可能追求我們app的主要基礎組件之一的最佳性能,他的速度還不足夠快。
Wayponints通過計算很多元素不同的高度,寬度以及位置來決定你現在的滾動位置,以及你距離終點的距離,以及你滾動的方向。所有的這些信息都是有用的,但是因為它是在在每一次滾動事件發生的,因此這是有代價的:帶來的計算會造成很多的jank。
但是首先,我們必須明白這意味著什么,如果開發者工具告訴我們這里有一個"jank"。
大多的設備屏幕每秒會刷新60次。如果有動畫或者轉換運行,或者用戶正在滾動頁面,瀏覽器需要匹配設備的刷新率,并為每個屏幕刷新添加一個新的圖片或者幀。
這些幀中每一幀花費的時間都超過16ms(1秒/60=16.66ms)。然而,實際上瀏覽器還有其他的工作,所以所有的工作需要在10ms之內完成。當你不能滿足這個要求的話,幀率就會下降,內容在屏幕上的顯示就會斷斷續續。這通常成為jank,它對用戶體驗會造成負面的影響。-
之后,我們開發了一個名為VirtualScroller的新的無限滾動的組件。有了這個新的組件,我們確切知道在任何給定時間,什么片段的Tweet被渲染到時間軸上,從而避免在視覺呈現上導致的昂貴的計算。
通過避免函數調用帶來的額外的jank,滾動Tweet的timeline看起來更加無縫,給我們一種更豐富,更原生的體驗。雖然說這可能會更好,但是這種變化對于timeline的滾動的平滑性帶來和顯著的提升。這個是一個很好的提醒,對于我們研究性能的時候,每一小點都很重要。
使用更小的圖片
我們開始推動在推ter Lite上使用較少的帶寬,通過和多個團隊合作,我們能夠在 CDN 上獲得新的更小尺寸的圖片。事實證明,通過減小圖片的大小,我們只會渲染我們需要展示的(在尺寸和質量方面),我們發現不僅可以減少帶寬的使用,而且我們能夠提升再瀏覽器中的性能,特別是在滾動帶有大量圖片Tweet的timeline的時候。
為了確定的更好的較小圖片的性能,我們可以在Chrome開發者工具里面觀察光柵timeline。在我們減小圖片尺寸之前,對于一張圖片的解碼需要300ms甚至之上,如下圖所示。這是圖片下載之后的處理時間,但是是在它在頁面展示之前。
當你滾動頁面的時候,并且你的目標是60幀/秒的渲染標準時候,我們希望在16.777ms之內盡可能塊地處理(1幀)。將一張圖片渲染到試圖就需要將近18幀,這也太多了。另外需要注意timeline的一點是:你可以看到這個主要的timeline一直都是阻塞的直到圖片完成解碼(如空白所示)。這意味著我們在這有一個相當大的性能瓶頸!
現在,在我們減少我們圖片尺寸之后,我們觀測到僅僅需要一幀就可以解碼我們最大的圖片。
優化React
使用shouldComponentUpdate方法
對于優化React應用性能一個最常見的建議就是使用 shouldComponentUpdate方法 。我們盡可能在任何時候都做到這個一點,但是有時候有些東西總是會被遺漏。
上圖中的一個組件總是會進行更新:在主屏timeline的時候點擊愛心圖標去贊一篇Tweet的時候,任何一個在屏幕上的 Conversation 組件都會重新渲染。在這個動畫例子中,你可以看到瀏覽器需要對被綠色盒子注明的地方進行重繪。因為我們針對的是Tweet下面的整個 Conversation 組件來進行更新action。
如下,你可以看到兩個這個action的幀圖。上面的沒有使用 shouldComponentUpdate ,我們可以看到的它的整個樹都被更新和重新渲染,只不過是為了改變屏幕上某個地方的愛心的顏色。在添加 shouldComponentUpdate (下圖)之后,我們阻止了整個樹進行更新并且避免了浪費0.1秒來運行不需要的處理。
避免不必要的工作直到componentDidMount
這種變化可能看起來是個人都會知道,但是在開發推ter Lite這樣的大型應用的時候,很容易忘記這種小事。
我們發現在我們代碼中的很多地方,為了對 componentWillMount React周期方法 進行分析,我們花費大量的時間計算。每一次我們做這個的時候,都會或多或少地阻塞組件的渲染.20ms或者90ms,這些時間很快就累加到一起。最初,我們嘗試在tweets被渲染之前(timeline如下)記錄哪些是在 componentWillMount 組件中被渲染到我們的數據分析服務中。
通過將計算和網絡調用移動到React組件的 componentDisMount 方法中,我們將主線程釋放出來,并且減少了在渲染組件的時候不想要的jank。
避免dangerouslySetInnerHTML
在推ter Lite中,我們使用SVG圖標,因為這對于我們來說最便捷的并且縮放性最好的.不幸的是,在老的React版本中,大多SVG屬性在利用組件創建元素的時候是不支持的。因此,當我們開始寫這個應用的時候,我們不得不使用 dangerouslySetInnerHTML 在React組件中來使用SVG圖標。
比如,我們最初的HeartIcon可能看起來是這個樣子的:
const HeartIcon = (props) => React.createElement('svg', { ...props, dangerouslySetInnerHTML: { __html: '<g><path d="M38.723 12c-7.187 0-11.16 7.306-11.723 8.131C26.437 19.306 22.504 12 15.277 12 8.791 12 3.533 18.163 3.533 24.647 3.533 39.964 21.891 55.907 27 56c5.109-.093 23.467-16.036 23.467-31.353C50.467 18.163 45.209 12 38.723 12z"></path></g>' }, viewBox: '0 0 54 72' });
不僅不鼓勵使用 dangerouslySetInnerHTML ,而且事實證明這個正是導致安裝和渲染慢的原因。
在分析上面地幀圖之后,我們最初的代碼(上面的)顯示需要20ms的時間在一個慢的設備上安裝這個action,即Tweet底部的SVG圖標。雖然這看起來差別不是很多,但是我們知道我們需要馬上渲染,所有這些都在滾動無限的tweet的timeline,我們意識到這會非常的浪費時間。
自從React v15增加了對于大多數SVG屬性的支持,我們想在前面來看一下如果我們避免使用 dangerouslySetInnnerHTML 會發生什么。看經過處理后的幀圖(下面的),我們在每一次安裝和渲染這些圖標的時候可以節約60%。
現在,我們的SVG圖標是簡單的無狀態的組件,不會使用“危險的”函數,并且安裝速度提升了60%。它們看起來是這個樣子的:
const HeartIcon = (props = {}) => ( <svg {...props} viewBox='0 0 ${width} ${height}'> <g><path d='M38.723 12c-7.187 0-11.16 7.306-11.723 8.131C26.437 19.306 22.504 12 15.277 12 8.791 12 3.533 18.163 3.533 24.647 3.533 39.964 21.891 55.907 27 56c5.109-.093 23.467-16.036 23.467-31.353C50.467 18.163 45.209 12 38.723 12z'></path></g> </svg> );
延遲渲染 當安裝以及卸載很多組件的時候
在慢的設備上,我們注意到需要很長的時間我們的主瀏覽條才可以點擊,這經常會導致我們點擊左慈,假設可能第一次點擊并沒有注冊的話。
注意下圖,可以看到主頁的圖標在點擊之后幾乎花了2秒的時間來進行更新:
不,這并不是GIF在一個低的幀率下運行。事實上就是慢。但是,所有的主頁屏幕的數據實際上已經加載了,為什么需要花費這么多的時間來顯示呢?
事實證明安裝和卸載大型組件樹(比如Tweet的timeline)在React中是非常耗時的。
至少,我們希望去除掉這種點擊導航欄之后沒有響應的感覺。為此,我們創建了一個小型的高階組件:
import hoistStatics from 'hoist-non-react-statics'; import React from 'react'; /** * Allows two animation frames to complete to allow other components to update * and re-render before mounting and rendering an expensive `WrappedComponent`. */ export default function deferComponentRender(WrappedComponent) { class DeferredRenderWrapper extends React.Component { constructor(props, context) { super(props, context); this.state = { shouldRender: false }; } componentDidMount() { window.requestAnimationFrame(() => { window.requestAnimationFrame(() => this.setState({ shouldRender: true })); }); } render() { return this.state.shouldRender ? <WrappedComponent {...this.props} /> : null; } } return hoistStatics(DeferredRenderWrapper, WrappedComponent); }
在應用到我們的主頁timeline之后,我們可以看到導航欄一個比較快的響應速度,從而帶來感官上一個大的提升。
const DeferredTimeline = deferComponentRender(HomeTimeline); render(<DeferredTimeline />);
優化Redux
避免存儲State太頻繁
盡管 controlled components 被推薦使用,但是這也意味這每一次按鍵之后都需要進行更新并且重新渲染。
盡管這對于一個3GHZ的臺式機來說并不太難,但對于CPU數量有限的小型移動設備來說影響確實很大的,尤其是從input中刪除多個字符的時候。
為了保持正在撰寫Tweet的值的時候同時計算剩余字符的數量,我們使用controlled組件,并將輸入的當前值傳遞到每個按鍵的Redux state。
如下(上面的),在一個典型的安卓5的設備上,每一次按鍵導致的更改可能會需要將近200ms的開銷。這對于快速打字的人來說是很痛苦的,這也讓我們最終陷入了一個非常糟糕的狀態,用戶經常投訴他們的字符插入會移動到各個地方,從而導致混亂的句子。
通過移除每一次按鍵下更新主Redux state的Tweet草稿state并且在本地保留Redux組件的狀態,可以將開銷減少50%。
將批量Actions合并成一個Dispatch
在推ter Lite中,我們使用 react-redux 配合 redux 來訂閱我們組件的數據狀態變化。我們通過使用 Normalizr 以及 combineReducers 將大型的store來進行分割從而對我們的數據進一步優化。這些都工作的很好,避免了數據重復并且保持我們的store足夠小。然而,每一次我們拿到新的數據的時候,我們必須分發多個action為了將它添加到合適的store中。
通過react-redux,這意味分派每個action都會導致我們連接的組件(成為容器)重新計算更改并且可能重新渲染。
盡管我們使用了一個自定義的middleware,還有其它的 批量middleware 。選擇一個適合你的,或者你自己寫一個。
說明使用批量action好處的最好方式是使用Chrome React Perf拓展。在初始加載之后,我們在后臺pre-cache并且計算圍堵的DM。當這種情況發生的時候,我們添加了很多不同的實體(會話,用戶,消息條目等等)。如果沒有批量action的時候(下面的),你可以看到我們渲染組件的時間相對于批量action時候的時間對比是~16ms對~8ms。
Service Workers
盡管Service Worker并沒有在所有的瀏覽器得到支持,但是它還是推ter Lite中非常重要的一個部分。當Service Worker被支持的時候,我們用它做推送,預先緩存應用資源以及其它。不幸的是,作為一個想當新的技術,還有很多性能提升方面的東西需要學習。
預先緩存資源
和大多數產品一樣,推ter Lite還遠遠沒有完成。我們仍然在積極地開發它,添加新特性并且修復BUG以及讓它運行得更快。這意味著我們經常需要部署新版本的JavaScript資源。
不幸的是,這對于返回應用的用戶來說是一個負擔,因為他們需要重新下載一大堆腳本文件僅僅是為了瀏覽一個Tweet。
在ServiceWorker被支持的瀏覽器中,worker能夠在你返回之前自己在后臺自動更新,下載并且緩存任何改變的文件,我們從中獲益。
因此這對用戶意味著什么?幾乎是馬上就可以加載應用,即使再在我們部署新版本之后!
在上面展示的(上面的)是沒有使用ServiceWorker預先緩存資源,當前view中每一個資源都會強制從網絡中加載如果返回應用的時候。在3G的網絡環境下差不多需要6秒的時間來完成加載。然而,當資源被ServiceWorker預先緩存之后(下面的),同樣在3G網絡環境下只需要1.5秒就可以完成頁面的加載。75%的提升!
延遲ServiceWorker注冊
在很多應用中,在頁面加載的時候立即注冊ServiceWorker是安全的:
<script> window.navigator.serviceWorker.register('/sw.js'); </script>
當我們發送盡可能多的數據到瀏覽器來渲染一個看起來比較完整的頁面,但是在推ter Lite情況不一定就是這樣的。我們可能不會發送足夠的數據,或者你打開的頁面不會支持數據從服務器全部獲取。因為這種或者那種的很多限制,我們需要在初始頁面加載之后立刻發送一些API請求。
正常情況,這不會是一個問題。然而,如果瀏覽器還沒有安裝當前版本的ServiceWorker,我們需要讓它安裝,隨之而來就是對于JS,CSS以及圖片資源預緩存的50個請求。
當我們使用簡單的方法來立即注冊ServiceWorker的時候,我們可以看到網絡連接發生在瀏覽器中,達到了并行請求的限制(上面的)。
通過延遲ServiceWorker的注冊直到我們完成了額外的API,CSS以及圖片資源的請求,我們可以讓頁面完成渲染并且是響應式的,在下面的截圖可以看到(下面的)。
總的來說,這是我們在 推ter Lite 的開發過程中的眾多性能提升的列表。當然還會有更多的事情,我們希望能夠繼續分享我們發現的問題,以及我們克服問題所做的工作。
來自:http://div.io/topic/1963