![]() |
VOOZH | about |
本文旨在幫助讀者了解,在全球規模最大的React.js PWA之一——Twitter Lite當中,是如何消除各類常規與罕見之性能瓶頸的。
想要構建一款性能出色的Web應用程式,我們需要投入大量技術周期以檢測時間浪費點、了解其發生原因並嘗試各類解決方案。遺憾的是,這種做飯往往無法快速解決問題。性能無疑是一項永恆的命題,技術人員永遠徘徊在觀察與測量當中,卻幾乎永遠找不到最優解。不過利用Twitter Lite,我們已經在眾多層面內取得了細小但卻極具價值的改進:從初始加載時間到React組件渲染(防止二次渲染),再到圖像加載以及更多層面。儘管大多數變更本身並不顯著,但其相加所帶來的最終結果是,我們得以構建起一款規模極大且速度極快的漸進式Web應用程式。
閱讀說明如果你剛剛開始投身Web應用程式性能提升的測量工作,我強烈建議您首先了解如何讀取火焰圖信息。
後文中的各個章節皆包含有截取自Chrome開發者工具內的時間線記錄。為了更易於理解,我們在每項示例中強調了哪些信息代表情況不利,而哪些代表情況正常。
這裡需要特別就時間線與火焰圖進行說明:由於我們需要對大量行動裝置進行觀察,因此通常只在模擬環境中記錄CPU速度僅為五分之一且使用3G網絡連接的情況。這些條件不僅更為現實,同時亦更易於暴露性能問題。在使用React v15.4.0的組件配置時,甚至會對運行配置加以進一步壓縮。桌面性能時間線中的實際值將遠高於我們在本文中列舉的示例值。
一、面向瀏覽器進行優化
1. 使用基於路由的代碼拆分機制
Webpack雖然極為強大,但卻難於學習。我們也曾經遭遇到CommonsChunkPlugin問題,且很難弄清其與我們部分循環代碼依賴性的對接方式。考慮到這一點,我們最終只保留了3個JavaScript資源文件,且總計略大於1 MB(gzip傳輸格式則為420 KB)。
在站點運行過程中,加載數個甚至單一大型JavaScript文件都可能給移動用戶的網站瀏覽與交互帶來巨大性能瓶頸。除了各大型腳本在傳輸過程中需要消耗更多網絡資源及傳輸時長之外,瀏覽器的解析工作量也將因此有所提升。
在經過多次爭論之後,我們最終得以利用路由機制將常規區域拆分成多個獨立塊(如下所示)。
const plugins = [最後,我們在收件箱中收到了這樣一份代碼審查結論:
添加了細粒度、基於路由的代碼拆分機制。應用整體的初始化速度與HomeTimeline渲染速度皆有所改善,且目前的應用被拆分為40個獨立塊,並根據會話長度進行時間配額均攤。- Nicolas Gallagher
如圖所示,時間線由代碼拆分前狀態(圖1)轉化為之後狀態(圖2)。
圖1
圖2
我們的初始設置(圖1)需要5秒種才能完成主捆綁包的加載,但在利用路由機制與常規區塊對代碼進行拆分後(圖2),加載時間降低到了3秒(模擬3G網絡環境下)。
這一突出性能提升在此前的就得到了關注,但單憑這一項變更,即令谷歌Lighthouse Web應用審計工具的運行速度出現巨大變化:
圖3
我們還通過運行谷歌Lighthouse Web應用審計工具了解此前(圖3 Before)與此後(圖3 After)的性能差異。
2.避免使用可能造成跳幀的函數
在對我們無限滾動時間線進行多次疊代的過程中,我們嘗試使用多種不同方法以計算滾動位置及方向,旨在確定是否有必要要求API顯示更多推文內容。就在不久之前,我們還在使用react-waypoint,且獲得了不錯的效果。然而為了將性能水平提升至新的高度,其作為我們應用程式的主要底層組件之一仍無法在速度上滿足要求。
Waypoints的工作方式為計算大量不同元素的高度、寬度與位置,從而確定用戶的當前滾動位置、每次操作之間的相隔距離以及具體指向哪個方向。這些信息雖然確實有用,但由於需要在每一次滾動事件時進行處理,因此會帶來相應成本——即此類計算會導致跳幀問題,且發生頻率極高。
但在解決問題之前,我們首先需要理解開發者工具所給出的「跳幀」結論究竟是什麼含義。
目前大多數設備會每秒對屏幕顯示內容進行60次刷新。如果其中運行有動畫或者過渡效果,抑或用戶進行頁面滾動操作,則瀏覽器需要匹配設備的刷新率並提供一張新的圖像——或者稱為幀——以作為每次屏幕刷新的顯示內容。
其中每一幀的持續時間約為略高於16毫秒(即1秒的六十分之一,約為16.66毫秒)。不過在實際場景中,瀏覽器仍有其它管理任務需要處理,因此整個刷新內容的生成時間約在10毫秒左右。如果無法滿足這一條件,則幀顯示速率將有所下降,導致屏幕上的內容出現跳動。這種現象通常被稱為跳幀,且會給用戶的體驗造成負面影響。—?Paul Lewis 著於《渲染性能》
隨著時間的推移,我們開發出一種新的無限滾動組件,並將其命名為VirtualScroller。利用這款新組件,我們能夠確切了解特定時段的特定時間軸中哪部分推文片段需要進行渲染,從而避免為了呈現視覺效果而進行需要占用大量資源的計算任務。
圖4
圖5
雖然看起來問題並不嚴重,但之前(圖4)進行滾動時,我們由於需要計算多個元素的高度而引發了渲染跳幀問題。之後(圖5),我們不僅徹底擺脫了跳幀,亦減少了卡頓並提升了時間軸滾動速度。
通過避免調用那些可能引發不必要跳幀的函數,推文的時間軸滾動變得更為無縫,這意味著我們能夠提供更為豐富且幾乎與原生應用無異的使用體驗。更值得一提的是,這項變更還給時間軸的滾動順滑度帶來提升。這再次證明,每一項小改進都將積累起來並最終實現理想性能表現。
3.使用更小圖像
為了在Twitter Lite上率先使用較低傳輸帶寬資源,我們配合多個團隊對CDN上的可用圖像進行了更新與尺寸調整。事實證明,通過降低圖像尺寸,我們得以顯著降低所需要渲染的實際工作量(包括規模與質量),並發現此舉不僅能夠降低傳輸帶寬占用率,同時亦能夠提升瀏覽器的性能表現——特別是在對包含大量圖像的推文時間軸進行滾動操作時。
為了核實小尺寸圖像給性能帶來的確切提升,我們對Chrome開發者工具中的Raster時間線進行了觀察。在對圖像尺寸進行瘦身之前,解碼單一圖像的時間一般為300毫秒甚至更長,具體如以下時間線記錄圖左側所示。這一過程發生在圖像內容下載完成之後,且需要經過處理,圖像才能在頁面中得到正確顯示。
當滾動頁面並希望符合每秒60幀渲染標準要求時,我們希望儘可能將每幀顯示內容的渲染時間控制在16.667毫秒以內。通過計算,這意味著我們需要近18幀才能將單一圖像渲染完成並顯示在視圖內,效果顯然不夠理想。另一項需要注意的時間指標在於,大家可以看到,Maine時間線會持續受到阻斷,直到對應圖像完成解碼(如空白區所示)。這意味著這正是我們要找的性能瓶頸!
圖6
圖7
較大圖像(圖6)將在18幀周期內阻礙主線程的運行,而較小圖像(圖7)則僅需要1幀左右。
現在我們已經對圖像尺寸進行了削減(圖6),而尺寸最大的圖像如今僅需要1幀周期即可完成解碼。
二、優化React
1.使用shouldComponentUpdate方法
對React應用程式進行性能優化的一種常見作法在於使用shouldComponentUpdate方法。我們一直在儘可能使用這一方法,但有時效果並不盡如人意。
圖8
贊第一條推文會導致其本身以及其下的整個Conversation進行重新渲染!
下面我們來看一個始終保持更新的組件救命:當在主時間線中點擊心形圖標以贊一條推文時,當前屏幕上的全部Conversation組件都將進行重新渲染。在動畫示例當中,大家可以看到綠色的高亮框體,這是因為我們的操作導致當前推文之下的整個Conversation組件皆進行更新,而瀏覽器需要對其進行重新填充。
以下為對這一操作進行概括的兩幅火焰圖。在未使用shouldComponentUpdate方法(圖9)時,我們可以看到整體樹狀結構皆進行了更新與重新渲染,而效果僅為對屏幕上的心形圖標進行著色。而在添加了shouldComponentUpdate方法(圖10)之後,我們無需更新整個樹狀結構,從而通過避免運行不必要進程而節約了十分之一秒處理時間。
圖9
圖10
之前(圖9),在贊某條非相關推文時,整個Conversations皆進行更新及重新渲染。而在添加該邏輯(圖10)之後,可以看到該組件及其各子元素不再浪費不必要的CPU周期。點擊或點觸進行放大。
2.將不必要任務推遲至componentDidMount之後
這一變更似乎非常簡單,但在開發Twitter Lite這類大型應用程式時卻很容易被忽略。
我們發現,我們的原有代碼中存在大量立足componentWillMount React生命周期方法進行高資源占用量計算分析的情況。每一次此類計算都會給其它組件的渲染造成妨礙。這裡20毫秒,那裡90毫秒,最終的性能拖累將非常沉重。最初,我們曾嘗試將實現進行渲染的每條推文進行記錄,並將結果寫入至componentWillMount中的數據分析服務中,而後才對其進行實際渲染(如下圖左側時間線所示)。
圖11
圖12
通過將非必要代碼路徑由componentWillMount推遲至componentDidMount,我們得以節約了大量當前屏幕內的推文渲染時長。點擊或點觸進行放大。
通過將計算與網絡調用轉移至React組件的componentDidMount方法中,我們得以解除對主線程的效率妨礙,同時減少了對各組件進行渲染時的意外跳幀狀況(圖12所示)。
3.避免使用dangerouslySetInnerHTML
在Twitter Lite當中,我們選擇使用SVG圖標,因為其極具可移植性且是最為理想的可擴展選項。遺憾的是,在舊有React版本當中,大部分SVG屬性在立足組件進行元素創建時並不受支持。因此,在最初開始編寫這款應用程式時,我們被迫通過dangerouslySetInnerHTML以將SVG圖標作為React組件進行使用。
舉例來說,我們的原始HeartIcon如下所示:
const HeartIcon = (props) => React.createElement('svg', {這裡需要強調一點,我們不僅不鼓勵使用dangerouslySetInnerHTML,更重要的是,事實證明其正是導致一系列掛載與渲染緩慢問題的源頭。
圖13
圖14
之前(圖13),可以看到掛載4個SVG圖標需要約20毫秒,而之後(圖14)則僅需要8毫秒。點擊或點觸進行放大。
通過對以上火焰圖進行分析,我們的原始代碼(圖13)顯示其在低配置設備上需要20毫秒方可完成推文底部4個SVG圖標的掛載操作。雖然就本身而言時耗並不誇張,但考慮到大量推文滾動操作情況,我們意識到這會造成巨大的時間浪費。
由於React v15對大部分SVG屬性提供支持,因此我們開始嘗試並希望了解不再使用dangerouslySetInnerHTML會帶來怎樣的效果。通過檢查升級版本的火焰圖(圖14),我們得以將每組圖標的掛載與渲染時間平均縮短60%!
現在,我們的SVG圖標屬於簡單的無狀態組件,且不再使用「dangerous」函數,且掛載速度平均提升60%。具體如下:
| const HeartIcon = (props = {}) => ( ); |
4.在掛載及卸載大量組件時推遲渲染
在低配置設備當中,我們注意到自己的主導航欄可能需要相當長的時間才能夠完成對多項點觸操作的正確響應,這會導致用戶誤以為第一次點觸未能奏效並進行反覆嘗試。
通過圖15可以看到,我們的Home圖標耗時近2秒才完成更新並對點觸操作作出響應:
圖15
如果不對渲染進行推遲,則導航欄需要較長耗時才能開始響應。
請別誤會,這絕不是由於運行GIF所造成的幀率緩慢。事實上,其速度確實令人無法忍受,但此次Home屏幕中的全部數據都已經加載完成——那麼,為什麼仍需要長長時間才能將全部內容正確顯示出來?
事實證明,大型組件樹狀結構(例如推文時間軸)的掛載與卸載在React中會消耗大量計算資源。
作為最簡單的要求,我們希望解決這一導航欄無法響應用戶輸入操作的狀況。因此,我們創建了一個小型HigherOrderCompoent組件:
import hoistStatics from 'hoist-non-react-statics';/**
* 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 ? : null;
}
}
return hoistStatics(DeferredRenderWrapper, WrappedComponent);
}
我們的HigherOrderComponent由 Katie Sievert編寫。
在被應用於我們的HomeTimeline之後,我們發現導航欄能夠實現近即時響應,這極大提高了應用程式的整體使用感受。
| const DeferredTimeline = deferComponentRender(HomeTimeline); render; |
圖16
在推遲渲染之後,導航欄能夠實現立即響應。
三、優化Redux
1.避免頻繁進行狀態存儲
儘管組件控制往往被作為理想的實踐方案,但事實證明控制輸入內容會導致每一次按鍵皆造成更新與重新渲染。
這一點在主頻高達3 GHz的台式計算機上並不是問題,但對於CPU性能較為有限的小型行動裝置而言,用戶將在輸入時遭遇明顯的延遲——特別是在對輸出內容中的大量字符進行刪除時。
為了保留當前所輸入的推文值並計算剩餘可輸入字符數,我們使用一項受控組件並在每次按鍵時將輸入內容中的當前值傳遞至我們的Redux狀態內。
圖17為一款典型的Android 5設備,每次按鍵帶來的變更都會導致約200毫秒的延遲。如果用戶輸入速度很快,則應用的實際運行狀態將非常糟糕。事實上,用戶經常報告稱其字符插入點會到處亂竄並導致輸入內容陷入混亂。
圖17
圖18
在使用與不使用Redux兩種情況下,對每次按鍵的更新速度進行對比。點擊或點觸進行放大。
通過阻止每次按鍵後將草稿推文狀態傳遞至主Redux狀態並將其保留在React組件的本地狀態內,我們得以將延遲水平降低超過50%(圖18)。
2.將批量操作合併為單一調度
在Twitter Lie當中,我們利用redux配合react-redux以將組件確保各組件能夠訂閱數據狀態變更。我們還對數據進行了優化,即利用Normalizr與combineReducers將其拆分為單一大型存儲內容中的多個獨立區間。這一切最終有效避免了數據重複並確保我們的存儲量保持在較低水平。然而,每一次獲取到新數據,我們都需要調度多項操作以將此新數據添加至適合的存儲庫內。
考慮到react-redux的工作方式,這意味著每項調度操作都將導致我們的連接組件(被稱為Containers,即容器)需要重新計算變更並可能需要進行重新渲染。
儘管我們使用了一款定製化中間件,但仍存在其它大量中間件可供選擇。大家可以按照需求從中挑選或者編寫您自己的定製中間件。
判斷批量操作收益的最佳方式在於使用Chrome React Perf擴展。在初始加載時,我們在後台中對未讀取DM進行預緩存及計算。在此過程中,我們會向其中添加大量功能實體(包括會話、用戶、消息條目等)。在未進行批量調度前(圖19),大家可以看到每一組件的渲染次數(約16次)約為使用批量調度後(圖20,約8次)的2倍。
圖19
圖20
利用Chrome React Perf擴展對批量調度前(圖19)與批量調度後(圖20)的Redux渲染次數進行比較。點擊或點觸進行放大。
四、Service Workers
儘管目前Service Workers尚未得到全部瀏覽器的支持,但其已經成為Twitter Lite中極具價值的組成部分。在使用Service Workers的情況下,我們能夠利用其推送通知並預緩存應用程式資產。遺憾的是,由於其尚屬於一種新興技術,因此我們還需要進行深入研究以了解其性能特性。
1.預緩存資源
與大多數產品一樣,Twitter Lite的開發工作還遠未完成。我們正在積極對其進行拓展、添加新功能、修復bug並提升其運行速度。這意味著我們需要頻繁部署新的JavaScript資產版本。
遺憾的是,這可能會給該應用程式的用戶帶來困擾,迫使其重新下載大量腳本文件以查看推文內容。
在支持Service Workers的瀏覽器當中,我們得以確保各工作程序在後台中以自動化方式更新、下載並緩存各變更文件,從而以不影響用戶的方式完成升級。
那麼這一切能夠給用戶帶來怎樣的收益?具體來講,其能夠以幾乎即時方式完成後續應用版本加載。
未啟用ServiceWorker預緩存(圖21)與啟用預緩存(圖22)情況下的網絡資產加載時間。點擊或點觸進行放大。
圖21
圖22
如大家所見(圖21),在未啟用ServiceWorker預緩存機制的情況下,當前視圖中的每一項資產都需要從網絡處加載並返回至應用程式處。在良好的3G網絡環境下,這一加載過程仍需要約6秒方可結束。然而在啟用ServiceWorker的預緩存機制後(圖22),同樣的3G網絡可在1.5秒以內完成頁面加載——性能提升高達75%!
2。推遲ServiceWorker註冊
在大多數應用程式當中,我們能夠安全地將ServiceWorker立即註冊至加載頁面當中:
然而考慮到我們需要向瀏覽器發送大量數據以渲染出完整的頁面內容,因此在Twitter Lite中這一切往往無法實現。我們可能無法快速發送充足的數據,或者您所登陸的頁面並不支持對來自伺服器的數據進行預填充。由於這一點外加其它一些限制,我們需要在初始頁面加載後立即生成部分API請求。
一般來講,這種作法並不會帶來負面影響。然而如果目標瀏覽器尚未安裝當前版本的ServiceWorker,我們則需要要求其安裝——這會帶來用於對多項JS、CSS以及圖像資產進行預緩存的約50項請求。
當我們簡單對ServiceWoker進行立即註冊時,可以看到瀏覽器內會立即進行網絡連接,且直接到達我們的並發請求數量上限。
圖23
圖24
請注意,在立即對Service Worker進行註冊時,其會阻礙全部其它網絡請求(圖23)。推遲Service Worker註冊(圖24)允許我們對頁面加載內容進行初始化,從而在並發請求上限之內完成必要的網絡請求。點擊或點觸進行放大。
通過將ServiceWorker註冊推遲至其它API請求、CSS與圖像資產加載完成之後,我們能夠保證頁面完成渲染並具備響應能力,具體如截圖所示(圖24)。
五、本文小結
總體而言,本文只列出了我們在Twitter Lite當中所實現的部分改進。未來我們還將在Twitter Lite中作出更多嘗試,並繼續分享我們在期間發現的問題以及克服困難的具體方法。欲了解更多與我們當前開發進度與React及PWA分析結論的信息,請關注我(https://mobile.twitter.com/paularmstrong)及 Twitter Lite(https://mobile.twitter.com/paularmstrong/lists/twitter-lite/members)開發團隊。