Web 現代應用程序架構下的性能優化,漸進式的極致藝術
前言
本文是 Rendering on the Web: Performance Implications of Application Architecture (Google I/O ’19) [1] 這篇谷歌工程師帶來的現代應用架構體系下的優化相關演講的總結,演講介紹了以下優化手段:
-
預渲染
-
同構渲染
-
流式渲染
-
漸進式注水(非常精彩)
應用架構體系
當我們討論「應用架構」的時候,可以理解為通過以下幾個部分組合來構建網站。
Component model
組件模型。Rendering and loading
渲染和加載。Routing and transitions
路由和過渡。Data/state management
數據、狀態的管理。
性能指標
在分析頁面渲染性能之前,先了解一下幾個比較重要的指標,方便下文理解:
-
FP
: First Paint,是 Paint Timing API 的一部分,是頁面導航與瀏覽器將該網頁的第一個像素渲染到屏幕上所用的中間時,渲染是任何與輸入網頁導航前的屏幕上的內容不同的內容。 -
FCP
: First Contentful Paint,首次有內容的渲染是當瀏覽器渲染 DOM 第一塊內容,第一次回饋用戶頁面正在載入。 -
TTI
: Time to interactive 第一次可交互時間,此時用戶可以真正的觸發 DOM 元素的事件,和頁面進行交互。 -
FID
: First Input Delay 第一輸入延遲測量用戶首次與您的站點交互時的時間(即,當他們單擊鏈接,點擊按鈕或使用自定義的 JavaScript 驅動控件時)到瀏覽器實際能夠的時間回應這種互動。 -
TTFB
: Time to First Byte 首字節時間,顧名思義,是指從客戶端開始和服務端交互到服務端開始向客戶端瀏覽器傳輸數據的時間(包括 DNS、socket 連接和請求響應時間),是能夠反映服務端響應速度的重要指標。
如果你還不太熟悉這些指標也沒關系,接下來的內容中,會結合實際用例分析這些指標。
渲染開銷 The cost of rendering
客戶端渲染 Client-side rendering
從服務端獲取 HTML、CSS、JavaScript 都是需要成本的,以一個 CSR(客戶端渲染)的網站為例,客戶端渲染的網站依賴框架庫(bundle)、應用程序(app)來進行初始化渲染,假設它有 1MB 的 JavaScript Bundle 代碼,那么只有當這一大段的代碼加載并執行完成以后,用戶才能看到頁面。
它的結構一般如下:
分析一下它的流程:
-
用戶輸入網址進入網站,拉取 HTML 資源。
- HTML 資源中發現 script 標簽加載的 bundle 再一次發起請求拉取 bundle。此時也是性能統計指標中的
FP
完成。
在這個階段,頁面基本上是沒什么意義的,當然你也可以放置一些靜態的骨架屏或者加載提示,來友好的提示用戶。
- JavaScript bundle 下載并執行完畢,此時頁面才真正渲染出有意義的內容。對應
FCP
完成。
當框架對 DOM 節點添加各類事件綁定后,用戶才真正可以和頁面交互,此時也對應 TTI
完成。
它的 缺點 在于,直到整個 JavaScript 依賴執行完成之前,用戶都看不到什么有意義的內容。
服務端同構渲染 SSR with Hydration
基于以上客戶端渲染的缺點以及用戶對于 CSR 應用交互更加豐富的需求,于是誕生了集 SSR 和 CSR 的 性能、SEO、數據獲取 的優點與一身的「 同構渲染 」,簡單點說,就是:
-
第一次請求,在服務端就利用框架提供的服務端渲染能力,直接原地請求數據,生成包含完整內容的 html 頁面,用戶不需要等待框架的 js 加載就可以看到內容。
-
等到頁面渲染后,再利用框架提供的 Hydration(注水)能力,讓服務端返回的“干癟”的 HTML 注冊事件等等,變的豐富起來,擁有了各種事件后,就和傳統 CSR 一樣擁有了豐富多彩的客戶端交互。
在同構應用中,只要 HTML 頁面返回,用戶就可以看到豐富多彩的頁面:
而 JavaScript 加載完畢后,用戶就可以和這些內容進行交互(比如點擊放大、跳轉頁面等等……)
代碼對比
典型的 CSR React 應用的代碼是這樣的:
而 SSR 的代碼則需要服務端的配合,
先由服務端通過 ReactDOMServer.renderToString
在服務端把組件給序列化成 html 字符串,返回給前端:
前端通過 hydrate
注水,使得功能交互變的完整:
Vue 的 SSR 也是同理:
同構的缺陷
至此看來,難道同構應用就是完美的嗎?當然不是,其實普通的同構應用只是提升了 FCP 也就是用戶看到內容的速度,但是卻還是要等到框架代碼下載完成, hydrate
注水完畢等一系列過程執行完畢以后才能真正的 可交互 。
并且對于 FID
也就是 First Input Delay 第一輸入延遲這個指標來說,由于 SSR 快速渲染出內容,更容易讓用戶誤以為頁面已經是可交互狀態,反而會使「用戶第一次點擊 - 瀏覽器響應事件」 這個時間變得更久。
因此,同構應用很可能變成一把「雙刃劍」。
下面我們來討論一些方案。
Pre-rendering 預渲染。
對于不經常發生變化的內容來說,使用預渲染是一種很好的辦法,它在代碼構建時就通過框架能力生成好靜態的 HTML 頁面,而不是像同構應用那樣在用戶請求頁面時再生成,這讓它可以幾乎立刻返回頁面。
當然它也有很大的限制:
-
只適用于靜態頁面。
-
需要提前列舉出需要預渲染的 URLs。
流式渲染 Streaming
流式渲染可以讓服務端對大塊的內容分片發送,使得客戶端不需要完整的接收到 HTML,而是接受到第一部分時就開始渲染,這大大提升了 TTFB
首字節時間。
在 React 中,可以通過 renderToNodeStream
來使用流式渲染:
漸進式注水 Progressive Hydration
我們知道 hydrate
的過程需要遍歷整顆 React 節點樹來添加事件,這在頁面很大的情況下耗費的時間一定是很長的,我們能否先只對關鍵的部分,比如視圖中可見的部分,進行「注水」,讓這部分先一步可以進行交互?
想象一下它的特點:
-
組件級別的漸進式注水。
-
服務端依舊整頁渲染。
-
頁面可以根據優先級來分片“啟動”組件。
通過一張動圖來直觀的感受一下普通注水(左)和漸進式注水(右)的區別:
可以看到用戶第一次可以交互的時間大大的提前了。
光說不做假把式,我們看看用 React 完成這個功能的代碼,首先我們需要準備一個組件 Hydrator
用來實現當某個組件 進入視圖范圍以后 再進行注水。
首先來看看應用的整體結構:
- let load = () => import('./stream');
- let Hydrator = ClientHydrator;
- if (typeof window === 'undefined') {
- Hydrator = ServerHydrator;
- load = () => require('./stream');
- }
- export default function App() {
- return (
- <div id="app">
- <Header />
- <Intro />
- <Hydrator load={load} />
- </div>
- );
- }
根據客戶端和服務端的環境區分使用不同的 Hydrator
,在服務端就直接返回普通的 html 文本:
- function interopDefault(mod) {
- return (mod && mod.default) || mod;
- }
- export function ServerHydrator({ load, ...props }) {
- const Child = interopDefault(load());
- return (
- <section>
- <Child {...props} />
- </section>
- );
- }
而客戶端,則需要實現漸進式注水的關鍵部分:
- export class Hydrator extends React.Component {
- render() {
- return (
- <section
- ref={c => (this.root = c)}
- dangerouslySetInnerHTML={{ __html: '' }}
- suppressHydrationWarning
- />
- );
- }
- }
首先 render 部分,利用 dangerouslySetInnerHTML
來使得這部分初始化為空的 html 文本,并且由于 server 端肯定還是和往常一樣全量渲染內容,而客戶端由于初始化需要先不做任何處理,會導致 React 內部對于服務端內容和客戶端內容的「一致性檢測」失敗。
而利用 dangerouslySetInnerHTML
的特性,會讓 React 不再進一步 hydrate
遍歷 children
而是直接沿用服務端渲染返回的 HTML,保證在注水前渲染的樣式也是 OK 的。
再利用 suppressHydrationWarning
取消 React 對于內容一致性檢測失敗的警告。
- export class Hydrator extends React.Component {
- componentDidMount() {
- new IntersectionObserver(async ([entry], obs) => {
- if (!entry.isIntersecting) return;
- obs.unobserve(this.root);
- const { load, ...props } = this.props;
- const Child = interopDefault(await load());
- ReactDOM.hydrate(<Child {...props} />, this.root);
- }).observe(this.root);
- }
- render() {
- return (
- <section
- ref={c => (this.root = c)}
- dangerouslySetInnerHTML={{ __html: '' }}
- suppressHydrationWarning
- />
- );
- }
- }
接下來,組件在客戶端初始化的時候,利用 IntersectionObserver
監控組件元素是否進入視圖,一旦進入視圖了,才會動態的去 import
組件,并且利用 ReactDOM.hydrate
來真正的進行注水。
此時不光注水是動態化的,包括組件代碼的下載都會在組件進入視圖時才發生,真正做到了「按需加載」。
動圖中紫色動畫出現,就說明漸進式 hydrate
完成了。
對比一下全量注水和漸進式注水的性能會發現首次可交互的時間被大大提前了:
當然,我們了解原理就發現,不光可以通過監聽組件進入視圖來 hydrate
,甚至可以通過 hover
、 click
等時機來觸發,根據業務需求的不同而靈活調整吧。
可以訪問圖片中的網址獲取你喜歡的框架在這方面的相關文章:
總結
本文通過總結了 Rendering on the Web: Performance Implications of Application Architecture (Google I/O ’19) [2] 這段 Google 團隊的精彩演講,來介紹了現代應用架構體系中的優化手段,包括:
-
預渲染
-
同構渲染
-
流式渲染
-
漸進式注水
在不同的業務場景下選擇對應的優化手段,是一名優秀的前端工程師必備的技能,相信看完這篇文章的你一定有所收獲。
完整 demo 地址:
https://github.com/GoogleChromeLabs/progressive-rendering-frameworks-samples