Facebook 前端技术栈重构分享
英文:Ashley Watkins, Royi Hagigi ?譯文:張克軍
https://www.yuque.com/docs/share/6aee9dd5-da3f-462b-b4bd-caec0ec6f60e
當我們考慮如何構建一個新的網(wǎng)絡應用—一個為現(xiàn)代瀏覽器設計的、具有用戶對Facebook(我們已知的)所有期望的功能,我們現(xiàn)有的技術棧無法支持我們所需要的類似于桌面應用的感覺和性能。完全重寫是非常罕見的,但在這種情況下,由于過去十年來Web技術發(fā)生了很多變化,我們知道這是我們實現(xiàn)性能和未來可持續(xù)發(fā)展目標的唯一途徑。今天,我們就分享一下我們在重構Facebook.com時的經(jīng)驗教訓,使用React(一種用于構建用戶界面的聲明式JavaScript庫)和Relay(React的GraphQL客戶端)來重構Facebook.com。
1. 開始
我們希望Facebook.com能夠快速啟動,快速響應,并提供高度互動的體驗。雖然服務端驅動(server-driven)的應用程序可以提供快速啟動時間,但我們不相信它能像客戶端驅動(client-driven)的應用程序那樣具有互動性和愉悅性。然而,我們相信我們可以構建一個客戶端驅動的應用程序,并能提供具有競爭力的快速啟動時間。
但是從頭開始做一個客戶端優(yōu)先的APP,這帶來了一系列新的問題。我們需要快速重建網(wǎng)站,同時解決速度和其他用戶體驗問題,而且在未來幾年內(nèi)能可持續(xù)的發(fā)展。在整個過程中,我們圍繞著兩個技術口號開展工作:
盡可能少,盡可能早。只提供所需要的資源,而且能在需要的時候及時送達。
服務于用戶體驗的工程體驗。我們開發(fā)的最終目標是為了我們的用戶。當思考用戶體驗的挑戰(zhàn)時,我們需要引導工程師默認做正確的事情來適配體驗需求。
我們應用這些原則來改進網(wǎng)站的四個要素:CSS、JavaScript、數(shù)據(jù)和路由。
2. 反思CSS,解鎖新功能
首先,我們通過改變編寫和構建樣式的方式,將主頁上的CSS減少了80%。在新網(wǎng)站上,我們寫的CSS與在瀏覽器上看到的CSS不同。當我們將CSS-like的JavaScript和組件寫在一起時,構建工具會將這些樣式分割成單獨的優(yōu)化包。因此,新網(wǎng)站的CSS數(shù)量減少了,支持暗模式和動態(tài)字體大小以實現(xiàn)可訪問性,并改善了圖片的渲染性能,同時讓工程師們開發(fā)更容易。
原子化的CSS,減少主頁80%的CSS
在我們的舊網(wǎng)站上加載主頁時,加載了超過400KB的壓縮CSS(2MB未壓縮),但實際上只有10%的CSS被用于初始渲染。我們一開始并沒有使用那么多的CSS,只是隨著時間的推移而增加,很少做刪減。之所以會出現(xiàn)這種情況,部分原因是每一個新功能都意味要添加新的CSS。
我們通過在構建時生成原子化CSS來解決這個問題。原子化CSS有一個對數(shù)增長曲線,因為它與唯一的樣式聲明的數(shù)量成正比,而不是與我們編寫的樣式和功能的數(shù)量成正比。這使得我們可以將整個網(wǎng)站中生成的原子型CSS合并到一個單一的、小的、共享的樣式中。結果是新主頁CSS下載量不到老網(wǎng)站的20%。
協(xié)同定位樣式(Colocating styles)減少未使用的CSS,使其更容易維護
CSS隨著時間的推移而增長的另一個原因是我們很難識別各種CSS規(guī)則是否還在使用。Atomic CSS有助于緩解這一點的性能影響,但獨特的樣式仍然會增加不必要的字節(jié),而且我們的源代碼中未使用的CSS會增加工程開銷?,F(xiàn)在,我們將我們的樣式與我們的組件寫在一起,這樣就可以將它們串聯(lián)起來刪除,并且只在構建時將它們分割成單獨的包。
我們還解決了另一個問題,CSS的優(yōu)先級取決于順序,當使用自動打包時,這一點尤其難以管理,因為自動打包會隨著時間的推移而改變。以前,一個文件中的變化可能會在作者沒有意識到的情況下破壞另一個文件中的樣式。相反,我們現(xiàn)在用一種熟悉的語法來編寫樣式,它的靈感來自于React Native風格的API。我們保證樣式以穩(wěn)定的順序應用,而且不支持CSS后裔選擇器。
改變字體大小以提高無障礙性
在今天的許多網(wǎng)站上,人們會通過使用瀏覽器的縮放功能放大文字。這可能會不小心觸發(fā)平板電腦或移動端布局,或者改變不需要放大的東西,比如圖片。
通過使用rems,我們可以遵守用戶指定的默認值,并且能夠提供對自定義字體大小的控制,而不需要修改CSS。然而,設計通常是使用CSS像素值創(chuàng)建的。手動轉換為rems會增加工程開銷和潛在的bug,所以我們的構建工具自動完成這個轉換。
構建時處理的例子
源代碼
const styles = stylex.create({emphasis: {fontWeight: 'bold',},text: {fontSize: '16px',fontWeight: 'normal',}, }); function MyComponent(props) {return <span className={styles('text', props.isEmphasized && 'emphasis')} />; }生成的CSS)
.c0 { font-weight: bold; } .c1 { font-weight: normal; } .c2 { font-size: 0.9rem; }生成的JavaScript
function MyComponent(props) {return <span className={(props.isEmphasized ? 'c0 ' : 'c1 ') + 'c2 '} />; }用于主題設計的CSS變量(暗夜模式)
在舊網(wǎng)站上,我們曾經(jīng)嘗試通過在body元素中添加一個類名來應用主題,然后用這個類名來覆蓋現(xiàn)有的樣式,這些樣式有更高的優(yōu)先級。這種方法有問題,它不再適用于我們新的原子化的CSS-in-JavaScript方法,所以我們改用CSS變量來進行主題切換。
CSS變量被定義在一個類下,當這個類應用到DOM元素上時,它的值會被應用到它的DOM子樹中的樣式。這讓我們可以將主題組合成一個單一的樣式表,這意味著切換不同的主題不需要重新加載頁面,不同的頁面可以有不同的主題而不需要下載額外的CSS,不同的產(chǎn)品可以在同一個頁面上并排使用不同的主題。
.light-theme {--card-bg: #eee; } .dark-theme {--card-bg: #111; } .card {background-color: var(--card-bg); }在JavaScript中使用SVG,實現(xiàn)快速、單一渲染的性能
為了防止圖標在其他內(nèi)容之后出現(xiàn)閃爍,我們使用 React 將 SVG 內(nèi)聯(lián)到 HTML 中,而不是將 SVG 以img的方式顯示。因為這些SVG現(xiàn)在是有效的JavaScript,所以它們可以和周圍的組件一起實現(xiàn)干凈的單次渲染。我們發(fā)現(xiàn),在加載JavaScript的同時加載這些SVG的好處大于SVG的繪制性能。通過內(nèi)聯(lián),不會出現(xiàn)圖標閃爍。
function MyIcon(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 "骨架 "來即時反饋頁面會是什么樣子。這個骨架需要最少的資源,但如果代碼被打成一個包,我們就無法提前渲染,所以我們需要根據(jù)頁面顯示的順序將代碼拆分成包。然而,如果簡單地這樣干(即使用在渲染過程中獲取的動態(tài)導入),我們可能會傷害到性能,而不是有利于性能。這就是我們對“JavaScript加載層”的代碼拆分設計的基礎。我們將初始加載所需的JavaScript分成三層,使用一個聲明式的、可靜態(tài)分析的API。
第1層是顯示上層內(nèi)容的首刷所需的基本布局,包括初始加載狀態(tài)的UI骨架。
第一層代碼加載和渲染后的頁面import ModuleA from 'ModuleA';
第2層包括了所有需要的JavaScript,以完全呈現(xiàn)所有的折疊內(nèi)容。第2層之后,屏幕上的任何內(nèi)容都不應該因為代碼加載而發(fā)生視覺上的變化。
第2層代碼加載和渲染后的頁面importForDisplay ModuleBDeferred from 'ModuleB';
一旦遇到一個importForDisplay,它和它的依賴關系就會被移到第2層。返回一個基于promise包裝的模塊,以便在模塊加載后訪問它
第2層需要完整的交互。如果有人在第2層代碼加載和渲染后點擊菜單,即使菜單的內(nèi)容還沒有準備好渲染,也會立即得到反饋。第3層包含顯示后才需要的、不影響當前屏幕展示的所有東西,包括log代碼和訂閱實時更新數(shù)據(jù)的代碼。
importForAfterDisplay ModuleCDeferred from '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)的依賴項
我們經(jīng)常需要渲染兩個相同的UI的變體,例如在A/B測試中經(jīng)常需要渲染兩個相同的UI。最簡單的方法是下載兩個版本,但這意味著下載的代碼可能永遠不會被執(zhí)行。一個稍微好一點的方法是在渲染時動態(tài)導入,但這可能會很慢。
相反,為了保持我們的 "盡可能少,盡可能早 "的口號,我們構建了一個聲明式的API,可以提前提醒我們這些決定,并將其編碼到我們的依賴圖中。當頁面正在加載時,服務器能夠檢查試驗,并只向下發(fā)送所需版本的代碼。
const Composer = importCond('NewComposerExperiment', {true: 'NewComposer',false: 'OldComposer', });我們將每個帖子類型所需的依賴關系作為查詢的一部分來表達
更贊的是,PhotoComponent 本身就把它需要的照片附件類型的數(shù)據(jù)精確地描述為片段,這意味我們甚至可以把查詢邏輯拆分出來。
使用JavaScript預算來防止代碼蠕變
分層和條件依賴關系可以幫助我們交付每個階段所需的代碼,但我們還需要確保每個層的規(guī)模隨著時間的推移保持在可控范圍內(nèi)。為了管理這個問題,我們引入了每個產(chǎn)品的JavaScript預算。
我們根據(jù)性能目標、技術約束、產(chǎn)品考慮制定預算。同時根據(jù)產(chǎn)品邊界和團隊邊界分配頁面級預算,并根據(jù)產(chǎn)品邊界和團隊邊界進行細分。共享基礎設施(Shared infra)被添加到一個精心篩選的列表中,并給出了自己的預算。共享基礎設施會計入所有頁面的預算,但其中的模塊是免費提供給產(chǎn)品團隊使用的。對于延遲加載、有條件加載或交互時加載的代碼也有預算。
我們?yōu)檫^程的每一步創(chuàng)建了相關的工具:
依賴關系圖工具讓我們更容易理解字節(jié)來自哪里,并識別出減少代碼大小的機會。
合并請求上的大小監(jiān)控會顯示大小回歸 / 改進,并觸發(fā)可定制的警報。
通過交互式圖表顯示歷史大小以及修訂之間的變化情況。
通過Dashboard幫助我們了解當前的大小與預算的關系。
盡早實現(xiàn)數(shù)據(jù)獲取(data-fetching)的現(xiàn)代化
作為這次重寫的一部分,我們對網(wǎng)站上的數(shù)據(jù)獲取的基礎設施進行了現(xiàn)代化改造。雖然舊網(wǎng)站的一些功能使用 Relay 和 GraphQL 進行數(shù)據(jù)采集,但大部分數(shù)據(jù)獲取都是作為服務器端 PHP 渲染的一部分。在新網(wǎng)站上,我們能夠與我們的移動應用標準化,并確保所有的數(shù)據(jù)獲取都通過GraphQL進行。由于Relay和GraphQL已經(jīng)為我們處理了 "盡可能少的 "工作,我們只需要做一些改變,以支持盡早獲得我們所需要的數(shù)據(jù)。
初始請求預加載數(shù)據(jù),以提高啟動效率
許多Web應用程序需要等到所有的JavaScript被下載并執(zhí)行后才從服務器上獲取數(shù)據(jù)。有了Relay,我們可以靜態(tài)地知道頁面需要什么數(shù)據(jù)。這意味著,一旦我們的服務器收到頁面的請求,它就可以立即開始準備必要的數(shù)據(jù),并與所需的代碼并行下載。當頁面可用時,我們會將這些數(shù)據(jù)與頁面一起流轉,這樣客戶端就可以避免額外的往返次數(shù),更快地呈現(xiàn)最終的頁面內(nèi)容。
為減少往返次數(shù)和提高互動性的流數(shù)據(jù)
注:流數(shù)據(jù)具有四個特點:數(shù)據(jù)實時到達;數(shù)據(jù)到達次序獨立,不受應用系統(tǒng)所控制;數(shù)據(jù)規(guī)模宏大且不能預知其最大值;數(shù)據(jù)一經(jīng)處理,除非特意保存,否則不能被再次取出處理,或者再次提取數(shù)據(jù)代價昂貴。(來自網(wǎng)上的解釋)
在最初加載Facebook.com時,有些內(nèi)容可能會被隱藏或呈現(xiàn)在視口之外。例如,大多數(shù)屏幕上可以容納一到兩個News Feed帖子,但我們不知道事先會容納多少個。此外,用戶很有可能會滾動,在連載往返的過程中,逐一抓取每個故事需要時間。另一方面,我們在一次查詢中獲取的故事越多,查詢的速度就越慢,這就導致查詢時間越長,即使是第一個故事,也需要更長的視覺完成(Visually Complete)時間。
注:視覺完成時間是指網(wǎng)頁可見區(qū)域內(nèi)的所有元素都被100%加載。
為了解決這個問題,我們使用了一個內(nèi)部的GraphQL擴展—@stream,將Feed連接流向客戶端,用于初始加載和后續(xù)滾動時的分頁。這使得我們可以在每一個feed故事準備好后,只需進行一次查詢操作,就可以將每一個feed故事逐一發(fā)送。
fragment HomepageData on User {newsFeed(first: 10) {edges @stream}...AdditionalData }推遲暫不需要的數(shù)據(jù)
不同部分的查詢時間是不同的,例如,在查看個人資料時,獲取一個人的姓名資料和照片相對來說比較快,但獲取他們的Timeline內(nèi)容則需要較長的時間。
為了在一次查詢中獲取這兩種類型的數(shù)據(jù),我們使用@defer,當響應的不同部分準備好后就可以將其變成流數(shù)據(jù)。這讓我們能夠盡快用初始數(shù)據(jù)渲染大部分的UI,并為其余部分渲染加載狀態(tài)。有了React Suspense就更容易了,因為我們可以顯式地設計加載狀態(tài),以確保流暢的、自上而下的頁面加載體驗。
fragment ProfileData on User {nameprofile_picture { ... }...AdditionalData @defer }5. 定義路由圖加快導航速度
快速導航是單頁應用的一個重要功能。當導航到一個新的路徑時,我們需要從服務器上獲取各種代碼和數(shù)據(jù)來渲染目的頁面。為了減少加載新頁面時需要的網(wǎng)絡往返次數(shù),客戶端需要提前知道每條路線需要哪些資源。我們將其稱為路由圖,每個條目稱為路由定義。
盡早獲得路由定義
對于Facebook來說,這個路由圖太大了,無法一次性發(fā)送全部的。相反,我們在會話期間,隨著新鏈接的呈現(xiàn),動態(tài)地將路由定義添加到路由圖中。路由圖和路由器存在應用的最頂端,允許結合當前應用和路由器的狀態(tài)來驅動應用級的狀態(tài)決策,例如基于當前路由的頂部導航欄或聊天標簽的行為。
盡早預獲取資源
客戶端應用程序通常要等到React渲染一個頁面后才會下載該頁面所需的代碼和數(shù)據(jù)。通常情況下使用React.lazy或類似的東西實現(xiàn)。由于這可能會使頁面導航速度變慢,所以我們反而會在鏈接被點擊之前就開始請求一些必要的資源。
為了提供更流暢的體驗,我們使用React Suspense轉場來繼續(xù)渲染上一個路由,直到下一個路由完全渲染完畢或暫停到下一個頁面的UI骨架的 “友好 “的加載狀態(tài)。這樣做會減少很多干擾,而且它模仿了標準的瀏覽器行為。
代碼和數(shù)據(jù)并行下載
在新網(wǎng)站上我們做了很多懶加載代碼,但如果我們懶加載一個路由的代碼,而這個路由的數(shù)據(jù)抓取代碼就在這個路由的代碼里面,最后就會出現(xiàn)串行加載的情況。
"傳統(tǒng) "的React / Relay app,加上懶加載的路由,結果會是兩次往返為了解決這個問題,我們想出了EntryPoints,它是包裹代碼分割點并將輸入轉化為查詢的文件。這些文件非常小,對于任何可以到達的代碼拆分點都會提前下載。
代碼和數(shù)據(jù)是并行提取的,讓我們可以在一次網(wǎng)絡請求往返中下載這些GraphQL查詢?nèi)匀慌c視圖寫在一起,但EntryPoint封裝了何時需要該查詢以及如何將輸入轉化為正確的變量。應用程序使用這些 EntryPoints 來自動決定何時請求,確保默認情況下正確的發(fā)生。這有一個額外的好處,那就是創(chuàng)建一個單一的JavaScript函數(shù),它包含了App中任何給定點的所有數(shù)據(jù)獲取需求,可以用于前面討論的服務器預加載。
我們在這里討論的許多變化并不是Facebook特有的。這些概念和模式可以應用到任何框架或庫的客戶端應用程序中。通過標準化我們的技術棧,我們已經(jīng)能夠重新思考如何以一種執(zhí)行力強、可持續(xù)的方式引入人們想要的功能--即使是在工程和產(chǎn)品規(guī)模的運營過程中也是如此。
工程體驗的改善和用戶體驗的改善必須齊頭并進,不能把性能和可訪問性看作是對輸出功能的額外負擔。通過優(yōu)秀的API、工具和自動化,我們可以幫助工程師們更快地推進工作,并同時發(fā)布更好的、更高性能的代碼。為提高新的Facebook.com的性能所做的工作非常廣泛,我們預計很快會分享更多關于這項工作的信息。要查看重新設計的內(nèi)容,請訪問facebook.com。它正在逐步推出,很快就會對大家開放。
專注分享當下最實用的前端技術。關注前端達人,與達人一起學習進步!
長按關注"前端達人"
總結
以上是生活随笔為你收集整理的Facebook 前端技术栈重构分享的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 国外设计博客小组收集
- 下一篇: ubuntu离线安装免费版本Typora