阿里@張克軍翻譯
當我們考慮如何構建一個新的網絡應用—一個為現代瀏覽器設計的、具有用戶對Facebook(我們已知的)所有期望的功能,我們現有的技術棧無法支持我們所需要的類似于桌面應用的感覺和性能。完全重寫是非常罕見的,但在這種情況下,由于過去十年來Web技術發生了很多變化,我們知道這是我們實現性能和未來可持續發展目標的唯一途徑。今天,我們就分享一下我們在重構Facebook.com時的經驗教訓,使用React(一種用于構建用戶界面的聲明式JavaScript庫)和Relay(React的GraphQL客戶端)來重構Facebook.com。
我們希望Facebook.com能夠快速啟動,快速響應,并提供高度互動的體驗。雖然服務端驅動(server-driven)的應用程序可以提供快速啟動時間,但我們不相信它能像客戶端驅動(client-driven)的應用程序那樣具有互動性和愉悅性。然而,我們相信我們可以構建一個客戶端驅動的應用程序,并能提供具有競爭力的快速啟動時間。
但是從頭開始做一個客戶端優先的APP,這帶來了一系列新的問題。我們需要快速重建網站,同時解決速度和其他用戶體驗問題,而且在未來幾年內能可持續的發展。在整個過程中,我們圍繞著兩個技術口號開展工作:
我們應用這些原則來改進網站的四個要素:CSS、JavaScript、數據和路由。
首先,我們通過改變編寫和構建樣式的方式,將主頁上的CSS減少了80%。在新網站上,我們寫的CSS與在瀏覽器上看到的CSS不同。當我們將CSS-like的JavaScript和組件寫在一起時,構建工具會將這些樣式分割成單獨的優化包。因此,新網站的CSS數量減少了,支持暗模式和動態字體大小以實現可訪問性,并改善了圖片的渲染性能,同時讓工程師們開發更容易。
在我們的舊網站上加載主頁時,加載了超過400KB的壓縮CSS(2MB未壓縮),但實際上只有10%的CSS被用于初始渲染。我們一開始并沒有使用那么多的CSS,只是隨著時間的推移而增加,很少做刪減。之所以會出現這種情況,部分原因是每一個新功能都意味要添加新的CSS。
我們通過在構建時生成原子化CSS來解決這個問題。原子化CSS有一個對數增長曲線,因為它與唯一的樣式聲明的數量成正比,而不是與我們編寫的樣式和功能的數量成正比。這使得我們可以將整個網站中生成的原子型CSS合并到一個單一的、小的、共享的樣式中。結果是新主頁CSS下載量不到老網站的20%。
CSS隨著時間的推移而增長的另一個原因是我們很難識別各種CSS規則是否還在使用。Atomic CSS有助于緩解這一點的性能影響,但獨特的樣式仍然會增加不必要的字節,而且我們的源代碼中未使用的CSS會增加工程開銷?,F在,我們將我們的樣式與我們的組件寫在一起,這樣就可以將它們串聯起來刪除,并且只在構建時將它們分割成單獨的包。
我們還解決了另一個問題,CSS的優先級取決于順序,當使用自動打包時,這一點尤其難以管理,因為自動打包會隨著時間的推移而改變。以前,一個文件中的變化可能會在作者沒有意識到的情況下破壞另一個文件中的樣式。相反,我們現在用一種熟悉的語法來編寫樣式,它的靈感來自于React Native風格的API。我們保證樣式以穩定的順序應用,而且不支持CSS后裔選擇器。
在今天的許多網站上,人們會通過使用瀏覽器的縮放功能放大文字。這可能會不小心觸發平板電腦或移動端布局,或者改變不需要放大的東西,比如圖片。
通過使用rems,我們可以遵守用戶指定的默認值,并且能夠提供對自定義字體大小的控制,而不需要修改CSS。然而,設計通常是使用CSS像素值創建的。手動轉換為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 '} />;
}
在舊網站上,我們曾經嘗試通過在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);
}
為了防止圖標在其他內容之后出現閃爍,我們使用 React 將 SVG 內聯到 HTML 中,而不是將 SVG 以img的方式顯示。因為這些SVG現在是有效的JavaScript,所以它們可以和周圍的組件一起實現干凈的單次渲染。我們發現,在加載JavaScript的同時加載這些SVG的好處大于SVG的繪制性能。通過內聯,不會出現圖標閃爍。
function MyIcon(props) {
return (
<svg {...props} className={styles({/*...*/})}>
<path d="M17.5 ... 25.479Z" />
</svg>
);
}
代碼大小是一個基于JavaScript的單頁面應用最大的擔憂之一,因為它對頁面加載性能影響很大。我們知道,如果我們想讓Facebook.com的客戶端React app有客戶端的效果,就需要解決這個問題。我們引入了幾個新的API,這些API的工作原理與我們 "盡可能少,盡可能早"的口號一致。
在等待頁面加載的時候,我們的目標是通過渲染頁面的UI "骨架 "來即時反饋頁面會是什么樣子。這個骨架需要最少的資源,但如果代碼被打成一個包,我們就無法提前渲染,所以我們需要根據頁面顯示的順序將代碼拆分成包。然而,如果簡單地這樣干(即使用在渲染過程中獲取的動態導入),我們可能會傷害到性能,而不是有利于性能。這就是我們對“JavaScript加載層”的代碼拆分設計的基礎。我們將初始加載所需的JavaScript分成三層,使用一個聲明式的、可靜態分析的API。
第1層是顯示上層內容的首刷所需的基本布局,包括初始加載狀態的UI骨架。
第一層代碼加載和渲染后的頁面
import ModuleA from 'ModuleA';
第2層包括了所有需要的JavaScript,以完全呈現所有的折疊內容。第2層之后,屏幕上的任何內容都不應該因為代碼加載而發生視覺上的變化。
第2層代碼加載和渲染后的頁面
importForDisplay ModuleBDeferred from 'ModuleB';
一旦遇到一個importForDisplay,它和它的依賴關系就會被移到第2層。返回一個基于promise包裝的模塊,以便在模塊加載后訪問它
第2層需要完整的交互。如果有人在第2層代碼加載和渲染后點擊菜單,即使菜單的內容還沒有準備好渲染,也會立即得到反饋。第3層包含顯示后才需要的、不影響當前屏幕展示的所有東西,包括log代碼和訂閱實時更新數據的代碼。
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層并不影響屏幕上的像素,所以它并不是真正的渲染,最終的刷圖完成時間更早。最重要的是,加載屏幕能夠更早地渲染。
我們經常需要渲染兩個相同的UI的變體,例如在A/B測試中經常需要渲染兩個相同的UI。最簡單的方法是下載兩個版本,但這意味著下載的代碼可能永遠不會被執行。一個稍微好一點的方法是在渲染時動態導入,但這可能會很慢。
相反,為了保持我們的 "盡可能少,盡可能早 "的口號,我們構建了一個聲明式的API,可以提前提醒我們這些決定,并將其編碼到我們的依賴圖中。當頁面正在加載時,服務器能夠檢查試驗,并只向下發送所需版本的代碼。
const Composer = importCond('NewComposerExperiment', {
true: 'NewComposer',
false: 'OldComposer',
});
我們將每個帖子類型所需的依賴關系作為查詢的一部分來表達
更贊的是,PhotoComponent 本身就把它需要的照片附件類型的數據精確地描述為片段,這意味我們甚至可以把查詢邏輯拆分出來。
分層和條件依賴關系可以幫助我們交付每個階段所需的代碼,但我們還需要確保每個層的規模隨著時間的推移保持在可控范圍內。為了管理這個問題,我們引入了每個產品的JavaScript預算。
我們根據性能目標、技術約束、產品考慮制定預算。同時根據產品邊界和團隊邊界分配頁面級預算,并根據產品邊界和團隊邊界進行細分。共享基礎設施(Shared infra)被添加到一個精心篩選的列表中,并給出了自己的預算。共享基礎設施會計入所有頁面的預算,但其中的模塊是免費提供給產品團隊使用的。對于延遲加載、有條件加載或交互時加載的代碼也有預算。
我們為過程的每一步創建了相關的工具:
作為這次重寫的一部分,我們對網站上的數據獲取的基礎設施進行了現代化改造。雖然舊網站的一些功能使用 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
}
快速導航是單頁應用的一個重要功能。當導航到一個新的路徑時,我們需要從服務器上獲取各種代碼和數據來渲染目的頁面。為了減少加載新頁面時需要的網絡往返次數,客戶端需要提前知道每條路線需要哪些資源。我們將其稱為路由圖,每個條目稱為路由定義。
對于Facebook來說,這個路由圖太大了,無法一次性發送全部的。相反,我們在會話期間,隨著新鏈接的呈現,動態地將路由定義添加到路由圖中。路由圖和路由器存在應用的最頂端,允許結合當前應用和路由器的狀態來驅動應用級的狀態決策,例如基于當前路由的頂部導航欄或聊天標簽的行為。
客戶端應用程序通常要等到React渲染一個頁面后才會下載該頁面所需的代碼和數據。通常情況下使用React.lazy或類似的東西實現。由于這可能會使頁面導航速度變慢,所以我們反而會在鏈接被點擊之前就開始請求一些必要的資源。
為了提供更流暢的體驗,我們使用React Suspense轉場來繼續渲染上一個路由,直到下一個路由完全渲染完畢或暫停到下一個頁面的UI骨架的 “友好 “的加載狀態。這樣做會減少很多干擾,而且它模仿了標準的瀏覽器行為。
在新網站上我們做了很多懶加載代碼,但如果我們懶加載一個路由的代碼,而這個路由的數據抓取代碼就在這個路由的代碼里面,最后就會出現串行加載的情況。
"傳統 "的React / Relay app,加上懶加載的路由,結果會是兩次往返為了解決這個問題,我們想出了EntryPoints,它是包裹代碼分割點并將輸入轉化為查詢的文件。這些文件非常小,對于任何可以到達的代碼拆分點都會提前下載。
代碼和數據是并行提取的,讓我們可以在一次網絡請求往返中下載這些
GraphQL查詢仍然與視圖寫在一起,但EntryPoint封裝了何時需要該查詢以及如何將輸入轉化為正確的變量。應用程序使用這些 EntryPoints 來自動決定何時請求,確保默認情況下正確的發生。這有一個額外的好處,那就是創建一個單一的JavaScript函數,它包含了App中任何給定點的所有數據獲取需求,可以用于前面討論的服務器預加載。
我們在這里討論的許多變化并不是Facebook特有的。這些概念和模式可以應用到任何框架或庫的客戶端應用程序中。通過標準化我們的技術棧,我們已經能夠重新思考如何以一種執行力強、可持續的方式引入人們想要的功能--即使是在工程和產品規模的運營過程中也是如此。
工程體驗的改善和用戶體驗的改善必須齊頭并進,不能把性能和可訪問性看作是對輸出功能的額外負擔。通過優秀的API、工具和自動化,我們可以幫助工程師們更快地推進工作,并同時發布更好的、更高性能的代碼。為提高新的Facebook.com的性能所做的工作非常廣泛,我們預計很快會分享更多關于這項工作的信息。要查看重新設計的內容,請訪問facebook.com。它正在逐步推出,很快就會對大家開放。
最多閱讀