導讀:Facebook做為一個全球最大的社交平台,有着巨大的通路量,前端的性能優化至關重要,請檢視細節。
當我們考慮如何建構一個新的網絡應用—一個為現代浏覽器設計的、具有使用者對Facebook(我們已知的)所有期望的功能,我們現有的技術棧無法支援我們所需要的類似于桌面應用的感覺和性能。
完全重寫是非常罕見的,但在這種情況下,由于過去十年來Web技術發生了很多變化,我們知道這是我們實作性能和未來可持續發展目标的唯一途徑。今天,我們就分享一下我們在重構Facebook.com時的經驗教訓,使用React(一種用于建構使用者界面的聲明式JavaScript庫)和Relay(React的GraphQL用戶端)來重構Facebook.com。
1. 開始
我們希望Facebook.com能夠快速啟動,快速響應,并提供高度互動的體驗。雖然服務端驅動(server-driven)的應用程式可以提供快速啟動時間,但我們不相信它能像用戶端驅動(client-driven)的應用程式那樣具有互動性和愉悅性。然而,我們相信我們可以建構一個用戶端驅動的應用程式,并能提供具有競争力的快速啟動時間。
但是從頭開始做一個用戶端優先的APP,這帶來了一系列新的問題。我們需要快速重建網站,同時解決速度和其他使用者體驗問題,而且在未來幾年内能可持續的發展。在整個過程中,我們圍繞着兩個技術口号開展工作:
- 盡可能少,盡可能早。隻提供所需要的資源,而且能在需要的時候及時送達。
- 服務于使用者體驗的工程體驗。我們開發的最終目标是為了我們的使用者。當思考使用者體驗的挑戰時,我們需要引導工程師預設做正确的事情來适配體驗需求。
我們應用這些原則來改進網站的四個要素:CSS、JavaScript、資料和路由。
2. 反思CSS,解鎖新功能
首先,我們通過改變編寫和建構樣式的方式,将首頁上的CSS減少了80%。在新網站上,我們寫的CSS與在浏覽器上看到的CSS不同。當我們将CSS-like的JavaScript群組件寫在一起時,建構工具會将這些樣式分割成單獨的優化包。是以,新網站的CSS數量減少了,支援暗模式和動态字型大小以實作可通路性,并改善了圖檔的渲染性能,同時讓工程師們開發更容易。
原子化的CSS,減少首頁80%的CSS
在我們的舊網站上加載首頁時,加載了超過400KB的壓縮CSS(2MB未壓縮),但實際上隻有10%的CSS被用于初始渲染。我們一開始并沒有使用那麼多的CSS,隻是随着時間的推移而增加,很少做删減。之是以會出現這種情況,部分原因是每一個新功能都意味要添加新的CSS。
我們通過在建構時生成原子化CSS來解決這個問題。原子化CSS有一個對數增長曲線,因為它與唯一的樣式聲明的數量成正比,而不是與我們編寫的樣式和功能的數量成正比。這使得我們可以将整個網站中生成的原子型CSS合并到一個單一的、小的、共享的樣式中。結果是新首頁CSS下載下傳量不到老網站的20%。
協同定位樣式(Colocating styles)減少未使用的CSS,使其更容易維護
CSS随着時間的推移而增長的另一個原因是我們很難識别各種CSS規則是否還在使用。Atomic CSS有助于緩解這一點的性能影響,但獨特的樣式仍然會增加不必要的位元組,而且我們的源代碼中未使用的CSS會增加工程開銷。現在,我們将我們的樣式與我們的元件寫在一起,這樣就可以将它們串聯起來删除,并且隻在建構時将它們分割成單獨的包。
我們還解決了另一個問題,CSS的優先級取決于順序,當使用自動打包時,這一點尤其難以管理,因為自動打包會随着時間的推移而改變。以前,一個檔案中的變化可能會在作者沒有意識到的情況下破壞另一個檔案中的樣式。相反,我們現在用一種熟悉的文法來編寫樣式,它的靈感來自于React Native風格的API。我們保證樣式以穩定的順序應用,而且不支援CSS後裔選擇器。
改變字型大小以提高無障礙性
在今天的許多網站上,人們會通過使用浏覽器的縮放功能放大文字。這可能會不小心觸發平闆電腦或移動端布局,或者改變不需要放大的東西,比如圖檔。
通過使用rems,我們可以遵守使用者指定的預設值,并且能夠提供對自定義字型大小的控制,而不需要修改CSS。然而,設計通常是使用CSS像素值建立的。手動轉換為rems會增加工程開銷和潛在的bug,是以我們的建構工具自動完成這個轉換。
建構時處理的例子
源代碼
const styles = stylex.create({
emphasis: {
fontWeight: 'bold',
},
text: {
fontSize: '16px',
fontWeight: 'normal',
},
});
functionMyComponent(props) {
return<span className={styles('text', props.isEmphasized && 'emphasis')} />;
}
生成的CSS)
.c0 { font-weight: bold; }.c1 { font-weight: normal; }.c2 { font-size: 0.9rem; }
生成的JavaScript
functionMyComponent(props) {return<span className={(props.isEmphasized ? 'c0 ': 'c1 ') + 'c2 '} />;}
用于主題設計的CSS變量(暗夜模式)
在舊網站上,我們曾經嘗試通過在body元素中添加一個類名來應用主題,然後用這個類名來覆寫現有的樣式,這些樣式有更高的優先級。這種方法有問題,它不再适用于我們新的原子化的CSS-in-JavaScript方法,是以我們改用CSS變量來進行主題切換。
CSS變量被定義在一個類下,當這個類應用到DOM元素上時,它的值會被應用到它的DOM子樹中的樣式。這讓我們可以将主題組合成一個單一的樣式表,這意味着切換不同的主題不需要重新加載頁面,不同的頁面可以有不同的主題而不需要下載下傳額外的CSS,不同的産品可以在同一個頁面上并排使用不同的主題。
.light-theme {--card-bg: #eee;}.dark-theme {--card-bg: #111;}.card { background-color: var(--card-bg);}
在JavaScript中使用SVG,實作快速、單一渲染的性能
為了防止圖示在其他内容之後出現閃爍,我們使用 React 将 SVG 内聯到 HTML 中,而不是将 SVG 以img的方式顯示。因為這些SVG現在是有效的JavaScript,是以它們可以和周圍的元件一起實作幹淨的單次渲染。我們發現,在加載JavaScript的同時加載這些SVG的好處大于SVG的繪制性能。通過内聯,不會出現圖示閃爍。
functionMyIcon(props) {return(<svg {...props} className={styles({/*...*/})}><path d=M17.5 ... 25.479Z/></svg>);}
3. JavaScript通過Code-splitting提高性能
代碼大小是一個基于JavaScript的單頁面應用最大的擔憂之一,因為它對頁面加載性能影響很大。我們知道,如果我們想讓Facebook.com的用戶端React app有用戶端的效果,就需要解決這個問題。我們引入了幾個新的API,這些API的工作原理與我們 盡可能少,盡可能早的口号一緻。
遞增的代碼加載,在需要的時候提供需要的東西(what we need, when we need it)
在等待頁面加載的時候,我們的目标是通過渲染頁面的UI 骨架 來即時回報頁面會是什麼樣子。這個骨架需要最少的資源,但如果代碼被打成一個包,我們就無法提前渲染,是以我們需要根據頁面顯示的順序将代碼拆分成包。然而,如果簡單地這樣幹(即使用在渲染過程中擷取的動态導入),我們可能會傷害到性能,而不是有利于性能。這就是我們對“JavaScript加載層”的代碼拆分設計的基礎。我們将初始加載所需的JavaScript分成三層,使用一個聲明式的、可靜态分析的API。
第1層是顯示上層内容的首刷所需的基本布局,包括初始加載狀态的UI骨架。
第一層代碼加載和渲染後的頁面
importModuleAfrom'ModuleA';
第2層包括了所有需要的JavaScript,以完全呈現所有的折疊内容。第2層之後,螢幕上的任何内容都不應該因為代碼加載而發生視覺上的變化。
第2層代碼加載和渲染後的頁面
importForDisplay ModuleBDeferredfrom'ModuleB';
一旦遇到一個importForDisplay,它和它的依賴關系就會被移到第2層。傳回一個基于promise包裝的子產品,以便在子產品加載後通路它
第2層需要完整的互動。如果有人在第2層代碼加載和渲染後點選菜單,即使菜單的内容還沒有準備好渲染,也會立即得到回報。
第3層包含顯示後才需要的、不影響目前螢幕展示的所有東西,包括log代碼和訂閱實時更新資料的代碼。
importForAfterDisplay ModuleCDeferredfrom'ModuleC';
// ...
function onClick(e) {
ModuleCDeferred.onReady(ModuleC=> {
ModuleC.log('Click happened! ', e);
});
}
一旦遇到importForAfterDisplay,它和它的依賴關系就會被移到第3層。傳回一個基于promise包裝的子產品,以便在子產品加載後通路它。
一個500KB的JavaScript頁面,在第1層可以變成50KB,第2層可以變成150KB,第3層可以變成300KB。以這種方式分割代碼,使我們能夠通過減少需要下載下傳的代碼量來達到每一個裡程碑,進而提高了從第一次繪制到視覺完成的時間。因為第3層并不影響螢幕上的像素,是以它并不是真正的渲染,最終的刷圖完成時間更早。最重要的是,加載螢幕能夠更早地渲染。
隻有在需要的時候才加載的試驗驅動(experiment-driven)的依賴項
我們經常需要渲染兩個相同的UI的變體,例如在A/B測試中經常需要渲染兩個相同的UI。最簡單的方法是下載下傳兩個版本,但這意味着下載下傳的代碼可能永遠不會被執行。一個稍微好一點的方法是在渲染時動态導入,但這可能會很慢。
相反,為了保持我們的 盡可能少,盡可能早 的口号,我們建構了一個聲明式的API,可以提前提醒我們這些決定,并将其編碼到我們的依賴圖中。當頁面正在加載時,伺服器能夠檢查試驗,并隻向下發送所需版本的代碼。
constComposer= importCond('NewComposerExperiment', {true: 'NewComposer',false: 'OldComposer',});
我們将每個文章類型所需的依賴關系作為查詢的一部分來表達
更贊的是,PhotoComponent 本身就把它需要的照片附件類型的資料精确地描述為片段,這意味我們甚至可以把查詢邏輯拆分出來。
使用JavaScript預算來防止代碼蠕變
分層和條件依賴關系可以幫助我們傳遞每個階段所需的代碼,但我們還需要確定每個層的規模随着時間的推移保持在可控範圍内。為了管理這個問題,我們引入了每個産品的JavaScript預算。
我們根據性能目标、技術限制、産品考慮制定預算。同時根據産品邊界和團隊邊界配置設定頁面級預算,并根據産品邊界和團隊邊界進行細分。共享基礎設施(Shared infra)被添加到一個精心篩選的清單中,并給出了自己的預算。共享基礎設施會計入所有頁面的預算,但其中的子產品是免費提供給産品團隊使用的。對于延遲加載、有條件加載或互動時加載的代碼也有預算。
我們為過程的每一步建立了相關的工具:
- 依賴關系圖工具讓我們更容易了解位元組來自哪裡,并識别出減少代碼大小的機會。
- 合并請求上的大小監控會顯示大小回歸 / 改進,并觸發可定制的警報。
- 通過互動式圖表顯示曆史大小以及修訂之間的變化情況。
- 通過Dashboard幫助我們了解目前的大小與預算的關系。
盡早實作資料擷取(data-fetching)的現代化
作為這次重寫的一部分,我們對網站上的資料擷取的基礎設施進行了現代化改造。雖然舊網站的一些功能使用 Relay 和 GraphQL 進行資料采集,但大部分資料擷取都是作為伺服器端 PHP 渲染的一部分。在新網站上,我們能夠與我們的移動應用标準化,并確定所有的資料擷取都通過GraphQL進行。由于Relay和GraphQL已經為我們處理了 盡可能少的 工作,我們隻需要做一些改變,以支援盡早獲得我們所需要的資料。
初始請求預加載資料,以提高啟動效率
許多Web應用程式需要等到所有的JavaScript被下載下傳并執行後才從伺服器上擷取資料。有了Relay,我們可以靜态地知道頁面需要什麼資料。這意味着,一旦我們的伺服器收到頁面的請求,它就可以立即開始準備必要的資料,并與所需的代碼并行下載下傳。當頁面可用時,我們會将這些資料與頁面一起流轉,這樣用戶端就可以避免額外的往返次數,更快地呈現最終的頁面内容。
為減少往返次數和提高互動性的流資料
注:流資料具有四個特點:資料實時到達;資料到達次序獨立,不受應用系統所控制;資料規模宏大且不能預知其最大值;資料一經處理,除非特意儲存,否則不能被再次取出處理,或者再次提取資料代價昂貴。(來自網上的解釋)
在最初加載Facebook.com時,有些内容可能會被隐藏或呈現在視口之外。例如,大多數螢幕上可以容納一到兩個News Feed文章,但我們不知道事先會容納多少個。此外,使用者很有可能會滾動,在連載往返的過程中,逐一抓取每個故事需要時間。另一方面,我們在一次查詢中擷取的故事越多,查詢的速度就越慢,這就導緻查詢時間越長,即使是第一個故事,也需要更長的視覺完成(Visually Complete)時間。
注:視覺完成時間是指網頁可見區域内的所有元素都被100%加載。
為了解決這個問題,我們使用了一個内部的GraphQL擴充—@stream,将Feed連接配接流向用戶端,用于初始加載和後續滾動時的分頁。這使得我們可以在每一個feed故事準備好後,隻需進行一次查詢操作,就可以将每一個feed故事逐一發送。
fragment HomepageData on User{ newsFeed(first: 10) { edges @stream}...AdditionalData}
推遲暫不需要的資料
不同部分的查詢時間是不同的,例如,在檢視個人資料時,擷取一個人的姓名資料和照片相對來說比較快,但擷取他們的Timeline内容則需要較長的時間。
為了在一次查詢中擷取這兩種類型的資料,我們使用@defer,當響應的不同部分準備好後就可以将其變成流資料。這讓我們能夠盡快用初始資料渲染大部分的UI,并為其餘部分渲染加載狀态。有了React Suspense就更容易了,因為我們可以顯式地設計加載狀态,以確定流暢的、自上而下的頁面加載體驗。
fragment ProfileData on User{ name profile_picture { ... }...AdditionalData@defer}
5. 定義路由圖加快導航速度
快速導航是單頁應用的一個重要功能。當導航到一個新的路徑時,我們需要從伺服器上擷取各種代碼和資料來渲染目的頁面。為了減少加載新頁面時需要的網絡往返次數,用戶端需要提前知道每條路線需要哪些資源。我們将其稱為路由圖,每個條目稱為路由定義。
盡早獲得路由定義
對于Facebook來說,這個路由圖太大了,無法一次性發送全部的。相反,我們在會話期間,随着新連結的呈現,動态地将路由定義添加到路由圖中。路由圖和路由器存在應用的最頂端,允許結合目前應用和路由器的狀态來驅動應用級的狀态決策,例如基于目前路由的頂部導航欄或聊天标簽的行為。
盡早預擷取資源
用戶端應用程式通常要等到React渲染一個頁面後才會下載下傳該頁面所需的代碼和資料。通常情況下使用React.lazy或類似的東西實作。由于這可能會使頁面導航速度變慢,是以我們反而會在連結被點選之前就開始請求一些必要的資源。
為了提供更流暢的體驗,我們使用React Suspense轉場來繼續渲染上一個路由,直到下一個路由完全渲染完畢或暫停到下一個頁面的UI骨架的 “友好 “的加載狀态。這樣做會減少很多幹擾,而且它模仿了标準的浏覽器行為。
代碼和資料并行下載下傳
在新網站上我們做了很多懶加載代碼,但如果我們懶加載一個路由的代碼,而這個路由的資料抓取代碼就在這個路由的代碼裡面,最後就會出現串行加載的情況。
傳統 的React / Relay app,加上懶加載的路由,結果會是兩次往返
為了解決這個問題,我們想出了EntryPoints,它是包裹代碼分割點并将輸入轉化為查詢的檔案。這些檔案非常小,對于任何可以到達的代碼拆分點都會提前下載下傳。
代碼和資料是并行提取的,讓我們可以在一次網絡請求往返中下載下傳這些
GraphQL查詢仍然與視圖寫在一起,但EntryPoint封裝了何時需要該查詢以及如何将輸入轉化為正确的變量。應用程式使用這些 EntryPoints 來自動決定何時請求,確定預設情況下正确的發生。這有一個額外的好處,那就是建立一個單一的JavaScript函數,它包含了App中任何給定點的所有資料擷取需求,可以用于前面讨論的伺服器預加載。
我們在這裡讨論的許多變化并不是Facebook特有的。這些概念和模式可以應用到任何架構或庫的用戶端應用程式中。通過标準化我們的技術棧,我們已經能夠重新思考如何以一種執行力強、可持續的方式引入人們想要的功能--即使是在工程和産品規模的營運過程中也是如此。
工程體驗的改善和使用者體驗的改善必須齊頭并進,不能把性能和可通路性看作是對輸出功能的額外負擔。通過優秀的API、工具和自動化,我們可以幫助工程師們更快地推進工作,并同時釋出更好的、更高性能的代碼。為提高新的Facebook.com的性能所做的工作非常廣泛,我們預計很快會分享更多關于這項工作的資訊。要檢視重新設計的内容,請通路facebook.com。它正在逐漸推出,很快就會對大家開放。
作者:張克軍