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