Facebook 前端技術棧重構分享

英文:Ashley Watkins, Royi Hagigi  譯文:張克軍

https://www.yuque.com/docs/share/6aee9dd5-da3f-462b-b4bd-caec0ec6f60e

當我們考慮如何構建一個新的網絡應用—一個爲現代瀏覽器設計的、具有用戶對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',
  },
});
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變量(暗夜模式)

在舊網站上,我們曾經嘗試通過在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的繪製性能。通過內聯,不會出現圖標閃爍。

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 "骨架 "來即時反饋頁面會是什麼樣子。這個骨架需要最少的資源,但如果代碼被打成一個包,我們就無法提前渲染,所以我們需要根據頁面顯示的順序將代碼拆分成包。然而,如果簡單地這樣幹(即使用在渲染過程中獲取的動態導入),我們可能會傷害到性能,而不是有利於性能。這就是我們對“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層並不影響屏幕上的像素,所以它並不是真正的渲染,最終的刷圖完成時間更早。最重要的是,加載屏幕能夠更早地渲染。

只有在需要的時候才加載的試驗驅動(experiment-driven)的依賴項

我們經常需要渲染兩個相同的UI的變體,例如在A/B測試中經常需要渲染兩個相同的UI。最簡單的方法是下載兩個版本,但這意味着下載的代碼可能永遠不會被執行。一個稍微好一點的方法是在渲染時動態導入,但這可能會很慢。

相反,爲了保持我們的 "儘可能少,儘可能早 "的口號,我們構建了一個聲明式的API,可以提前提醒我們這些決定,並將其編碼到我們的依賴圖中。當頁面正在加載時,服務器能夠檢查試驗,並只向下發送所需版本的代碼。

const Composer = 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。它正在逐步推出,很快就會對大家開放。

專注分享當下最實用的前端技術。關注前端達人,與達人一起學習進步!

長按關注"前端達人"

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章