Web渲染那些事兒
作為開發者,經常需要面對影響整個應用架構的決策。而Web開發者的核心決策之一,就是應用邏輯與渲染工作的實現,應處於架構中的什麼位置(譯註:客戶端 or 伺服器?)。現在有很多不同構建網站的方法,因此這些決策變得愈加困難。
我們對這一領域的理解,來自於我們過去幾年在 Chrome 工作中,與大型網站的交流。從廣義上講,我們鼓勵開發人員考慮通過一種稱為 rehydration 的方式,進行伺服器渲染或靜態渲染。
為了更好地理解在做出決定時所選擇的架構,我們需要對每種方法有充分的理解,並且在談到它們時使用一致的術語。
術語
渲染
- SSR:伺服器渲染(Server-Side Rendering) ——在伺服器上將客戶端或通用(universal)應用程式渲染成HTML。
- CSR:客戶端渲染(Client-Side Rendering) ——在瀏覽器中渲染App,通常使用DOM。
- Rehydration :在客戶端上“啟動” JavaScript 檢視,複用伺服器渲染的HTML DOM樹和資料。(譯註:利用伺服器返回HTML中的JS資料,重新渲染頁面的技術,詳見 知乎討論 ,其中《三體》的部分很形象~)
- 預渲染(Prerendering) :在構建時執行客戶端應用程式,以將其初始狀態捕獲為靜態HTML。
效能
- TTFB :首位元組時間(Time to First Byte)——從點選連結 到 接收第一個位元組內容 之間的時間。
- FP :首次繪製(First Pain)——第一次有畫素對使用者可見的時間。
- FCP :首次內容繪製(First Contentful Paint)——請求內容(文章正文等)變得可見的時間。
- TTI :可互動時間(Time To Interactive)——頁面變為可互動的時間(事件繫結等)。
伺服器渲染(Server Rendering)
伺服器渲染,指在伺服器中生成整個頁面的HTML,以此響應請求的技術。這樣做避免了在客戶端上進行資料獲取的額外往返(round-trips)和模板處理,因為這些工作在瀏覽器獲得響應之前,已由伺服器處理了。
伺服器渲染通常會得到快速的 首次繪製 (FP)和 首次內容繪製 (FCP)。在伺服器上執行頁面邏輯和渲染,可以避免向客戶端傳送大量 JavaScript,有助於實現快速的 可互動時間 (TTI)。這之所以行得通,因為伺服器渲染的本質,只是向用戶瀏覽器傳送文字和連結。這種方法適用於廣泛的裝置和網路,並能觸發一些有趣的瀏覽器優化,比如流文件解析。
使用伺服器渲染,使用者不再需要在客戶端上等待 CPU 相關的 JavaScript 處理後,然後才能訪問站點。即使 第三方JS 無法避免,使用伺服器渲染來減少自己的 JS成本 ,也能提供更多的效能“ 預算 ”。但是,這種方法有一個主要缺點:在伺服器上生成頁面有一定耗時,可能會導致較慢的首位元組時間(TTFB)。
伺服器渲染是否滿足應用程式,很大程度上取決於構建目標的體驗型別。關於伺服器渲染與客戶端渲染的正確應用存在長期爭論,但重要的是我們可以選擇對某些頁面使用伺服器渲染,而對其餘頁面不使用。一些網站已成功採用混合渲染技術: Netflix 伺服器渲染其相對靜態的落地頁面,同時為互動繁重的頁面 預拉取 JS,為這些重客戶端頁面提供更快的載入能力。
許多現代框架、庫和架構,使得在客戶端和伺服器上渲染相同的應用程式成為可能。這些技術可用於伺服器渲染,但是要注意,在伺服器和客戶端上進行渲染的架構,都是各框架自家的解決方案,具有不同的效能特點和權衡。React 使用者可以使用 renderToString() 或在其上構建的解決方案如 Next.js ,用於伺服器渲染;Vue 使用者可以檢視 Vue 的 伺服器渲染指南 或 Nuxt ;Angular 有 Universal 。大部分流行的解決方案採用某種 hydration 的形態,因此在選擇工具之前要注意使用的方法。
靜態渲染(Static Rendering)
靜態渲染 在構建時進行,並提供快速的 FP、FCP 和 TTI——假設客戶端JS的體積得當。與伺服器渲染不同,它還致力於實現始終如一的快速首位元組時間(TTFB),因為頁面的 HTML 不必動態生成。通常,靜態渲染意味著提前為每個 URL 生成單獨的 HTML 檔案。通過預先生成 HTML 響應,可以將靜態渲染部署到多個 CDN 以利用邊緣快取。(譯註:也就是“頁面靜態化”)
靜態渲染的解決方案選擇很多,像 Gatsby 這樣的工具旨在讓開發人員感覺他們的應用程式是動態渲染的,而不是構建過程生成的。 Jekyl 和 Metalsmith 提供更多模板驅動的方法,更加符合它們的靜態特質。
靜態渲染的一個缺點是必須為每個可能的 URL 生成單獨的 HTML 檔案。 如果無法提前預測這些 URL 的內容,或者對於具有大量不同頁面的網站,這可能具有挑戰性甚至是不可行的。
React 使用者可能熟悉 Gatsby 、 Next.js 靜態匯出 或 Navi ——它們都可以方便使用元件。但是,瞭解靜態渲染和預渲染之間的區別非常重要:靜態渲染頁面是無需執行太多客戶端 JS 就可互動的,預渲染則改進了單頁面應用的 FP 或 FCP,由於是單頁面應用,所以必須等待客戶端啟動過程,以使頁面真正具有互動性。(譯註:簡單的說靜態渲染不依賴客戶端JS,適用於靜態頁面,而預渲染則依賴JS,更多是為了富應用的初始介面加速)
如果不確定選擇靜態渲染還是預渲染方案,請嘗試此測試:禁用JavaScript並載入建立的網頁。對於靜態渲染的頁面,大多數功能在未啟用JavaScript下仍然正常運作。而對於預渲染頁面,一些基本功能(如連結)能正常展現,但頁面其餘部分無法正常展現。
另一個有效的測試是使用 Chrome DevTools 減慢網路速度,並觀察在頁面變為可互動之前已下載了多少 JavaScript。預渲染通常需要更多的 JavaScript 來實現互動,並且這些 JS 往往比靜態渲染使用的 漸進增強 方法更復雜。
伺服器渲染 vs 靜態渲染
伺服器渲染並不是銀彈——它的動態特性帶來 顯著的計算成本 。許多伺服器渲染解決方案會有耗時,導致延遲的 TTFB 或成倍的資料傳輸(例如,客戶端 JS 所需的內聯狀態)。在 React 中,renderToString() 可能很慢,因為它是同步和單執行緒的。伺服器渲染“正確”的姿勢,可能涉及查詢或構建 元件快取 方案、記憶體消耗管理、應用 記憶化 技術以及許多其他方面。同一個應用程式通常需要多次處理/重建——一次在客戶端中,一次在伺服器中。因此伺服器渲染可以使某些東西更快地顯示出來,但並不意味著可以減少工作量。
伺服器渲染為每個 URL 按需生成 HTML,但速度可能比僅提供靜態渲染內容要慢。如果加以進行額外的工作,伺服器渲染 + HTML快取 ,可以大大減少伺服器渲染時間。伺服器渲染的優勢在於,能夠提取更多“實時”資料,並響應比靜態渲染更完整的請求集。個性化頁面就是一個不適用於靜態渲染的頁面型別代表。
在構建 PWA 時,伺服器渲染也丟擲一個有趣的問題。 整個頁面使用 Service Worker 快取,與伺服器渲染部分內容片段,哪個方案更好?
客戶端渲染(Client-Side Rendering,CSR)
客戶端渲染(CSR)意味著使用 JavaScript 直接在瀏覽器中渲染頁面。 所有邏輯、資料獲取、模板和路由都在客戶端處理,而不是伺服器上。
客戶端渲染很難在移動端做到很快。如果做好壓縮工作, 嚴格控制 JavaScript 預算 ,並在儘可能少的 RTT 中提供內容,它可以接近純伺服器渲染的效能。使用 HTTP/2 Server Push 或 <link rel = preload> 可以更快地提供關鍵指令碼和資料,這將使解析器更快地完成工作。像 PRPL 這樣的模式值得評估,以確保初始和後續導航的即時感。
客戶端渲染的主要缺點是,隨著應用程式的發展,所需的 JavaScript 數量會增加。隨著新增新的 JavaScript 庫、polyfill 和第三方程式碼,更是一發不可收拾。這些程式碼會競爭處理能力,並且通常必須在渲染頁面內容之前完成處理。構建依賴大型 JavaScript 的 CSR 應用時,應該考慮 積極的程式碼分割 ,並確保延遲載入 JavaScript——“只在需要時提供所需內容”。對於很少或沒有互動性的頁面,伺服器渲染可以作為更具擴充套件性的解決方案。
對於構建單頁應用程式的人來說,識別大多數頁面共享的UI核心部分,意味著可以應用 Application Shell 快取 技術。與 Service Worker 相結合,可以顯著提高重複訪問的感知效能。
通過 Rehydration 將伺服器渲染和 CSR 相結合
這種方法通常被稱為通用渲染或簡稱為“SSR”,它試圖通過兩者兼顧來平滑客戶端渲染和伺服器渲染之間的權衡。頁面請求交由伺服器處理,將應用程式渲染為 HTML,然後把用於渲染的 JavaScript 和資料,嵌入到生成的文件中。只要處理得當,這就像伺服器渲染一樣實現了快速的 FCP,然後通過稱為 (re)hydration 的技術,在客戶端上再次“拾取”來渲染。這是一種新穎的解決方案,但也具有一些明顯效能缺陷。
譯註:如果這裡不好理解,請先理解上面術語部分中 Rehydration 的知乎連結內容。
rehydration 後的 SSR 主要缺點,是它會對可互動時間(TTI)產生顯著的負面影響,即使它改善了首次繪製(FP)。SSR 頁面通常看起來具有欺騙性的載入完成和可互動性,但在執行客戶端JS並繫結事件處理之前,頁面實際上無法響應輸入。這在移動裝置上可能持續幾秒甚至幾分鐘。
也許你自己也經歷過這種情況——在頁面看起來已經載入後的一段時間內,點選或觸控什麼都沒反應。這很快變得令人沮喪......“為什麼沒有反應? 為什麼我不能滾動?“
一個 Rehydration 問題:應用的雙重成本
由於JS特性,Rehydration 問題往往比延遲互動更糟糕。為了使客戶端 JavaScript 能夠不用重新請求伺服器,就能準確地獲取伺服器返回的用於呈現其 HTML 的所有資料,當前的 SSR 解決方案通常將UI的資料響應序列化, 以 Script 標籤形式存放在 HTML 中。結果是生成的 HTML 文件包含大量重複片段:
正如你所看到的,伺服器除了返回應用程式 UI 以響應頁面請求,還返回了用於組成該 UI 的源資料,以及生成相同 UI 的實現程式碼,即刻在客戶端上執行。只有在 bundle.js 完成載入和執行後,頁面才會變為可互動。
從使用 Rehydration SSR 站點收集的效能資料顯示,這種用法應極力避免。歸根結底,原因歸結為使用者體驗:很容易讓使用者處於“不明所以”的狀態。
Rehydration SSR 也不是沒有希望。在短期內,僅將 SSR 用於高度可快取的內容,可以減少 TTFB 延遲,從而達到與預渲染類似的結果。
流式伺服器渲染和漸進式 Rehydration
伺服器渲染在過去幾年中發展迅猛。
流式伺服器渲染 能以 chunk 形式傳送 HTML,瀏覽器可以在接收時逐塊渲染。這促成了快速的 First Paint 和 First Contentful Paint,因為 HTML 標籤更快地到達使用者側。在 React 中,流在 renderToNodeStream() 中非同步處理,相比於同步的 renderToString,伺服器的壓力也會更小。
漸進式 Rehydration 也值得關注,React 一直在 探索 。使用這種方法,伺服器渲染後的頁面各部分,隨著時間推移被“啟動”,而不是通常一次初始化整個應用程式的做法。這可以減少頁面可互動所需的 JavaScript 量,因為可以延遲頁面低優先順序部分,以防止阻塞主執行緒。它還可以幫助避免最常見的 SSR Rehydration 陷阱:伺服器渲染的DOM樹被破壞後立即重建——通常是因為客戶端初始同步渲染所需的資料還沒準備好,比如還在等待 Promise 的解析。
部分 Rehydration
部分 Rehydration 已被證明難以實現。該方法是漸進式 Rehydration 概念的擴充套件,通過分析漸進式 Rehydration 的各個部分(元件/檢視/樹),識別出那些不具互動性的部分。對於每個基本靜態的部分,相應的 JavaScript 程式碼會被轉換為惰性引用和裝飾功能,將其客戶端佔用空間減少到接近於零。部分 Rehydration 方案伴隨著自身的問題和妥協。它為快取帶來了一些有趣的挑戰,我們無法假設伺服器渲染的惰性部分 HTML,在頁面完整載入前是可用的。
三方同構渲染(Trisomorphic Rendering)
如果可以使用 service worker ,“trisomorphic”渲染也很有意思。該技術是指,利用流式伺服器渲染初始頁面,等 Service Worker 載入後,接管 HTML 的渲染工作。這可以使快取的元件和模板保持最新,並啟用 SPA 式的導航以在同一會話中渲染新檢視。當可以在伺服器、客戶端頁面和 Service Worker 之間共享相同模板和路由程式碼時,此方法最有效。
SEO 考慮
在選擇渲染策略時,團隊通常會考慮 SEO 的影響。為了讓爬蟲能夠輕鬆獲得“完整頁面”,伺服器渲染是不二的選擇。雖然爬蟲 可能會理解 JavaScript ,但是在渲染方式上的 侷限性 需要注意。如果你的應用非常重 JavaScript,最近的 動態渲染 方案也是個值得考慮的選擇。
如果有疑問, Mobile-Friendly Test 工具對於測試你選擇的方法是否符合預期,非常有用。它展示了 Google 爬蟲渲染頁面的預覽、序列化的 HTML 內容(執行 JavaScript 後),以及渲染過程中發生的錯誤。
總結
在決定渲染方式時,需要測量和理解真正的瓶頸在哪裡。靜態渲染或伺服器渲染在多數情況都比較適用,尤其是可互動性對JS依賴較低的場景。下面是一張便捷的資訊圖,顯示了伺服器到客戶端的技術頻譜: