當頁面渲染趕上邊緣計算

背景

經過優化頁面性能,提高用戶的體驗,一直是咱們追求的目標。咱們能夠經過瀏覽器緩存、預加載、預渲染等各類方案,來提高頁面的訪問性能和體驗。但在實際業務場景中,有一類頁面一直是性能優化的老大難,那就是首跳頁面。即用戶是第一次訪問網站的場景。
對於 web 頁面來講,首跳場景(例如 SEO、付費引流)的性能廣泛比二跳場景下要差。緣由有多種,主要是首跳用戶在鏈接複用,和本地資源緩存利用方面,有很大的劣勢。首跳場景下,不少在端上的優化手段(預加載,預執行,預渲染等)沒法實施。
在客戶端緩存能力沒法利用的狀況下,利用 cdn 距離用戶近的特性,多是一個性能優化的方向。接下來將介紹幾種常見的性能優化方案,並引出咱們提出的邊緣渲染方案。javascript

思路

思路1 - SSR

爲了性能優化考慮,咱們通常都會經過服務端渲染(SSR) ,將首屏動態內容直接服務端輸出。
css


這種方式的優勢時一次 html 返回便可包含頁面主體內容,不須要瀏覽器二次請求接口後再用 js 渲染。但這種方式的缺點也比較明顯,對於距離服務端遠,或者服務端處理時間較長的場景,用戶會看到較長時間的白屏。並且即便 html 返回完成了,用戶並不會當即看到內容,頁面還須要加載前置的 js,css 等資源後,才能看到內容。SSR 模式下的渲染時空圖以下:

思路2 - CSR + CDN

爲了減小白屏時間,考慮利用 CDN 的邊緣緩存能力,能夠把頁面 html 直接緩存在 cdn 節點上。但對於大部分場景來講,頁面的主體內容都是動態,或者個性化的,把所有 html 內容緩存在 cdn 上對於業務影響較大,頗有少場景能接受。那麼換個思路,只把 html 靜態部分緩存在 cdn 上呢?其實這個思路也是一個很常見的操做,即把 html 的靜態框架部分緩存在 cdn 上,讓用戶能快速看到部份內容,而後再在客戶端發起異步請求,獲取動態內容而且渲染(CSR)。CSR + CDN 模式下的渲染時空圖以下:
html


這種方式的優勢是頁面靜態框架緩存在 cdn 上,用戶能夠快速看到頁面框架內容,減小白屏等待焦慮。缺點是完整的頁面內容須要再執行 js ,拉取異步接口回來後再進行渲染。最終有意義的動態內容展現出來的時間,比 SSR 更晚。

思路3 - ESI

CSR + CDN 的方式,很好地解決了白屏時間問題,但帶來了動態內容展現的延時。之因此有這個問題,是由於咱們把頁面的動態內容和靜態內容分割到了兩個階段中,而且是串行的,並且串行過程當中還穿插了 js 的下載和執行。有什麼辦法把動態內容和靜態內容在 CDN 上整合起來呢?前端

ESI(Edge Side Include) 給了咱們一個很好的思路啓發,ESI 最初也是 CDN 服務商們提出的規範,可經過 html 標籤里加特定的動態標籤,可以讓頁面的靜態內容緩存在 cdn 上,動態內容能夠自由組裝。ESI 的渲染時空圖以下:
java


這個方案看起來很美好,能夠把靜態的部分緩存在 CDN 上了,動態部分在用戶請求時會動態請求和拼接。但最關鍵的問題在於,ESI 模式下,最終返回給用戶的首字節,仍是要等到全部動態內容在 CDN 上都獲取和拼接完成。也就是並無減小白屏時間,只是減小了 CDN 和服務器之間內容傳輸的體積,帶來的性能優化收益很小。最終效果上與 SSR 區別不大。

雖然 ESI 的效果不符合咱們預期,但給了咱們很好的思考方向。若是能把 ESI 改形成可先返回靜態內容,動態內容在 CDN 節點獲取到以後,再返回給頁面,就能夠保證白屏時間短而且動態內容返回不推遲。若是要實現相似於流式 ESI 的效果,要求在 CDN 上能對請求進行細粒度的操做,以及流式的返回。CDN 節點上支持這麼複雜的操做嗎?答案是確定的:邊緣計算。目前一些 CDN 服務商已提供完善的邊緣計算能力(cloudfare已經支持,alicdn 也已有內測版本支持,並即將對外開放),咱們能夠在 CDN 上作相似於瀏覽器的 service worker 的操做,可對請求和響應作靈活的編程。react

基於邊緣計算的能力,咱們有了一種新的選擇:邊緣流式渲染方案。方案詳情以下nginx

方案 - 邊緣流式渲染(ESR)

渲染流程

方案的核心思想是,藉助邊緣計算的能力,將靜態內容與動態內容以流式的方式,前後返回給用戶。cdn 節點相比於 server,距離用戶更近,有着更短的網絡延時。在 cdn 節點上,將可緩存的頁面靜態部分,先快速返回給用戶,同時在 cdn 節點上發起動態部份內容請求,並將動態內容在靜態部分的響應流後,繼續返回給用戶。最終頁面渲染的時空圖以下:
web

從上圖能夠看出,cdn 邊緣節點能夠很快地返回首字節和頁面靜態部份內容,而後動態內容由 cdn 發起向 server 起並流式返回給用戶。方案有如下特色:redis

  1. 首屏 ttfb 會很短,靜態內容(例如頁面 Header 、基本結構、骨骼圖)能夠很快看到
  2. 動態內容是由 cdn 發起,相比於傳統瀏覽器渲染,發起時間更早,且不依賴瀏覽器上下載和執行 js。理論上,最終 reponse 完結時間,與直接訪問服務器獲取完整動態頁面時間一致。
  3. 在靜態內容返回後,已經能夠開始部分 html 的解析,以及 js, css 的下載和執行。把一些阻塞頁面的操做提早進行,等完整動態內容流式返回後,能夠更快地展現動態內容。
  4. 邊緣節點與服務端之間的網絡,相比於客戶端與服務端之間的網絡,更有優化空間。例如經過動態加速,以及 edge 與 server 之間的鏈接複用,能爲動態請求減小 tcp 建連和網絡傳輸開銷。以作到最終動態內容的返回時間,比 client 直接訪問 server 更快

demo 對比

目前在 alicdn 上對主搜頁面作了一個 demo (edge-routine.m.alibaba.com/)(由於 demo 頁面可能會頻繁), 下面是在不一樣網絡(經過 charles 的 network throttle 配置限速)狀況下,與原始頁面的加載對比:編程

  1. 不限速(wifi):

  1. 限速 4G

  1. 限速 3g

從上面結果能夠看出,在網速越慢的狀況下,經過 cdn 流式渲染的最終主要元素出來的時間比原始 ssr 的方式出來得越早。這與實際推論也符合,由於網絡越慢,靜態資源加載時間越慢,對應的瀏覽器提早加載靜態資源帶來的效果也越明顯。另外,無論在什麼網絡狀況下,cdn 流式渲染方式的白屏時間要短不少。

總體架構

架構圖

邊緣流式渲染

1. 模板

模板就是一個相似於包含 ESI 區塊的語法,基於模板,會將須要動態請求的內容提取出來,把能夠靜態返回的內容分離出來並緩存起來。因此模板本質上定義了頁面動態內容和靜態內容。

在流式渲染過程當中,會從上到下解析頁面模板,若是是靜態內容,直接返回給用戶,若是遇到動態內容,會執行動態內容的 fetch 邏輯。整個過程當中可能有靜態和動態內容交替出現。

設計有如下幾種類型的模板。

  • 第一種:原始 HTML

這種模板對現有業務的侵入性最小,只須要在現有的 SSR 頁面內容里加上必定的標籤,便可把頁面中動態部分申明出來:

<html>
  <head>
  	<link rel="stylesheet" type="text/css" href="index.css">
  	<script src="index.js"></script>
    <meta name="esr-version" content="0.0.1"/>
  </head>
  <body>
  	<div>staic content....</div>
    
    <script type="esr/snippet/start" esr-id="111" content="SLICE"></script>
    <div>
    	dynamic content1....  
    </div>
    <script type="esr/snippet/end"></script>
    
  	<div>staic content....</div>
    
    <script type="esr/snippet/start" esr-id="222" content="https://test.alibaba.com/snippet/222"></script>
    <div id="222">
    	dynamic content2....  
    </div>
    <script type="esr/snippet/end"></script>
  </body>
</html>
複製代碼
  • 第二種:靜態模板(暫時沒有關聯的實際場景)

這咱模板須要單獨把模板發到 cdn 上(將來若是渲染層接入了 FASS 網關和 SSR ,在這塊能夠和他們共用模板內容,而且在工做流中發佈模板時自動同步到 cdn 上一份,同時清空 cdn 上緩存)。動態的內容有兩種渲染方式。一種是利用後端 SSR 出來的動態 html 片段,另外一種是後端提供動態數據,由邊緣節進行動態html片段渲染。

使用 SSR 動態 html 片段的好處是,不須要在邊緣上作 html 模板渲染,而且不須要開發者寫兩套模板邏輯。缺點是須要後端有 SSR 能力,而且動態內容傳輸體積較大。

使用邊緣節點渲染動態 html 內容的好處是,後端只須要提供動態數據,不須要 SSR 能力(但前端要有 CSR 的能力作降級兜底),而且傳輸的動態內容體積小。切點是邊緣節點上沒法流式透傳動態內容,須要等完整下載到邊緣節點上,處理後再返回給用戶。

<html>
  <head>
  	<link rel="stylesheet" type="text/css" href="index.css">
  	<script src="index.js"></script>
  </head>
  <body>
    <div>staic content....</div>
    
    <script type="esr/block" esr-id="111" content="https://test.alibaba.com/snippet/111"></script>
    
    <div>staic content....</div>
    
    <script type="esr/template" esr-id="222" content="https://test.alibaba.com/api/data"> <div> {$data.name} </div> </script>
  </body>
</html>
複製代碼

2. 靜態內容展示

靜態內容來自於模板。對於不一樣模板類型,獲取靜態內容的方式不同。對於 「原始 HTML」 類型的模板,靜態內容會從首次動態請求返回的完整 HTML 中,根據 html 註釋標記提取出來,並存儲到 edge 緩存上。對於 「靜態模板」,會經過拉取 CDN 的的模板文件 ,並存儲到 edge 緩存上。靜態內容有緩存過時時間和版本號。

模板一開始的靜態內容會在響應時直接返回給用戶。後續的靜態內容(例如 html 和 body 的閉合標籤)有兩種方式:
a. 一種是等待動態內容返回後,再寫到響應流中。這種方式對 SEO 比較友好,但缺點是動態內容會阻塞住後續靜態內容,而且若是有多個動態內容區塊的話,沒法實現先返回的動態模板先展現,只能依次展現.
b. 另外一種方式是先把靜態內容徹底返回,而後動態內容以類 bigpipe 的方式,經過腳本把內容插入到對應的坑位。這種方式的優勢是靜態內容能夠一開始就完整展現,且多個動態內容能夠先到先展現。缺點是對 SEO 不友好(由於動態內容是能進 js 插進去的)

3. 動態內容

動態內容是在渲染過程當中,解析到須要動態獲取的區域,會在 edge 上發起動態內容請求。動態內容支持以動態加速的形式到達服務端(源站)。連續節點與後端的動態的內容交互,分爲三種方式:
a. 第一種是後端動態內容返回的是全量的頁面,須要經過註釋標記來從內容中提取。這種方式的優勢是對現有業務侵入較小,缺點是動態內容傳輸體積大,而且須要下載完整 html 後再截取動態內容;
b. 第二種是後端動態內容只返回動態區塊的內容,這種方式的優勢是能夠將動態響應流式返回給用戶,缺點時須要頁面單獨對外提供一個只返回動態區塊內容的 url。
c. 第三種是後端動態內容只返回數據,配合靜態模板中的動態渲染模板,在邊緣節點上渲染出動態 html 後返回給用戶。優勢是與後端傳輸數據量小,且不須要後端有 SSR 能力。缺點是須要開發者多維護一套模板邏輯,而且在邊緣節點上作複雜的模板渲染可能會有 cpu 開銷和限制。

用戶和邊緣節點的動態內容交互,分爲兩種形式:
a. 瀑布流式(對應路由配置裏的 WATER_FALL ): 動態內容以瀑布流的形式依次返回。雖然在邊緣節點上多個動態內容加載的操做是並行的,但對於用戶來講,會從上到下依次展現頁面內容。這種方式優勢是對 SEO 友好,而且不影響頁面模塊的加載順序。缺點是多個動態模塊時,沒法看到總體頁面的框架,首個動態塊的內容會阻塞後續動態塊內容的展現,且頁面底部的 js css 資源沒法提早加載和執行。
b. 嵌入式(對應路由配置裏的 ASYNC_INSERT ):靜態內容一次性所有返回,其中動態部份內容會先佔一些坑位。後續動態內容會以 innerHTML 的形式,插入到先前佔的坑中。這種方式優勢是頁面底部的 js css 資源沒法提早加載和執行,而且頁面能夠先看到一個全貌。缺點是對 SEO 不友好,且頁面模塊的執行順序會根據動態塊返回速度有所變化,須要在瀏覽器端頁面邏輯裏作一些判斷和兼容。

邊緣路由

路由配置:
g.alicdn.com/edgerender/… (只是一個設想的 url,是一個發佈到靜態 cdn 上的 json 資源)

{
  version: '0.0.1' // 配置版本號
  origin: 'us-proxy.alibaba.com',
  host: 'edge.alibaba.com'
	pages: [
    {
    	pageName: 'seo', // 頁面名稱標識
      match: '/abc/efg/.*', // 頁面 path 匹配正則字符串
      renderConf: { // 渲染配置
        renderType: 'ESR', // 邊緣渲染
        templateType: 'FULL_HTML', // 模板類型:將 SSR 出的完整 html 做爲模板
        dynamicMode: 'WATER_FALL|ASYNC_INSERT', // 動態內容 append 返回方式:瀑布流返回|異步填坑(innerHTML)
        templateUrl: '' // 模板 url
      }
    },
    {
    	pageName: 'seo',
      match: '/abc/efg/.*',
      renderConf: { 
        renderType: 'ESR', 
        templateType: 'STATIC', // 靜態模板,可經過 cdn url 獲取
        dynamicMode: 'WATER_FALL|ASYNC_INSERT', // 動態內容 append 返回方式:瀑布流返回|異步填坑(innerHTML)
        templateUrl: 'https://g.alicdn.com/@g/xxx.html'
      }
    },
    {
    	pageName: 'jump',
      match: '/jump/.*',
      renderConf: {
        renderType: 'REDIRECT_302', // 302 跳轉
        rewriteUrl: 'https://jump'
      }
    },
    {
    	pageName: 'proxy',
      match: '/proxy/.*',
      renderConf: {
        renderType: 'PROXY_PASS', // 301 跳轉
        rewriteUrl: 'https://proxypassurl'
      }
    }
  ]
}
複製代碼

路由能夠認爲是邊緣計算的一個入口,只有在路由配置中的頁面,纔會走對應的渲染流程。不然頁面會直接走回源,獲取頁面完整內容。上面的 json 是目前設計的路由配置文件。配置文件最終會在一個靜態資源的方式,走覆蓋式發佈發到 assets cdn 上。同時,爲了支持配置發佈灰度,線上會存在灰度版本和全量版本的兩個配置,在路由代碼裏配置固定比例,加載灰度或者全量版本的配置。

目前在路由裏設計了三種渲染模式,分別是流式渲染、重定向和反向代理。重定向和反向代理的配置比較簡單,與 nginx 配置相似,只須要提目標 url 便可。

穩定性

影響範圍控制

  1. CDN 開關:域名按區域、按比例切流,同時可隨時從 cdn 上把流量切回統一接入
  2. 邊緣計算 SCOPE 開關:cdn 上配置邊緣計算覆蓋路徑,控制邊緣計算只運行在部分路徑下
  3. 邊緣計算路由開關:邊緣計算中經過讀取路由配置,控制只有部分頁面走流式渲染,不然請求直接走動態加速獲取完整頁面內容

異常處理

  1. dns 開關,如出現 cdn 嚴重問題,直接 dns 回切到統一接入
  2. 若是邊緣計算基礎功能出現異常,在 cdn 配置平臺上關閉全部路徑的邊緣計算,走默認的動態加速
  3. 若是在進了邊緣渲染,在沒有返回任何響應內容給客戶端前,就出現了錯誤,捕獲錯誤並降級到獲取完整頁面內容
  4. 若是進了邊緣渲染,已經返回了靜態部分的響應給客戶端,而後在邊緣節點了加載動態內容出了問題(超時、http 錯誤碼、與靜態內容版本號不匹配),返回一個 location.reload()  的 script 標籤,並結束響應,讓頁面強制刷新。刷新時可帶上 bypass 邊緣計算的 query 參數以保證刷新時不走邊緣渲染

灰度

  1. 邊緣計算代碼灰度
    a. 自己平臺支持灰度發佈邊緣計算代碼
  2. 路由配置灰度
    a. 在邊緣計算代碼裏,根據固定比例,加載灰度版本和正式版本的兩個配置 url。灰度發佈時只發布灰度配置,全量發佈時發佈全量配置。發佈的同時清空 cdn 緩存
  3. 頁面內容灰度
    a. 給灰度頁面一個特殊的模板版本號,遇到這個版本號的話,就不走邊緣渲染。

平滑發佈

先後端分離的發模式下,有一個廣泛存在的問題:平滑發佈。當頁面的靜態資源(js, css )的發佈,不是與後端一塊兒發佈時,可能引發後端返回的 HTML 內容與前端的 js ,css 內容不匹配的問題。若是二者之間的不匹配沒作兼容處理,可能會出現樣式錯亂或者 document 選擇器找不到元素的問題。

解決平滑發佈的一種方式是,在作先後端同時變動的需求時,在代碼上作兼容。這樣前後發佈就不影響頁面可用性。

另外一種方式是經過版本號。在後端頁面上手動配置版本號。當有不兼容發佈時,先發前端資源,而後後端手動修改版號,保證只有發佈成功的後端機器, HTML 裏引用的纔是新版本的靜態資源。

平滑發佈的問題其實在分批發布和 Beta 發佈的場景一直存在。只是在 ESR 的場景,咱們把靜態部分緩存在 cdn 上,會使先後端不一致的可能性更大。爲了解決這個問題,須要對應業務的開發者進行發佈時的風險識別。若是已經作了兼容,能夠不用作特殊處理。但若是沒有兼容,須要在修改頁面模板的版本號,新版本的動態內容,在遇到版本號不匹配的靜態內容時,會放棄本次流式渲染,保證頁面不出動態內容和靜態內容的兼容問題。

邊緣 cdn 服務商

目前各大 cdn 服務商對邊緣計算的支持狀況以下:

  1. alicdn
    a. 支持類 service worker  環境的邊緣計算,功能知足需求
    b. 海外節點目前還有限,部分區域性能可與akamai 對標甚至超過,但有些域名性能因節點少的緣由仍是比 akamai 稍差。
  2. akamai
    a. 只支持簡單的請求改寫計算,不知足邊緣渲染的需求
    b. ESI 能夠組裝動態和靜態內容,但不支持流式,動態內容會阻塞首屏
    c. 海外節點多,在一些地區下相比於 alicdn 有性能優點
  3. cloudfare
    a. 支持類 service worker  環境的邊緣計算,功能知足需求
    b. 沒有使用經驗,若是要用的話可能流程比較複雜

須要考慮的一些細節問題

  1. 若是走了動靜分離的流式渲染方式,http header 會隨着靜態部分快速返回給用戶,一方面 aplus 腳本後面的動態參數可能會被固化下來,另外一方面若是動態內容有返回 set-cookie 的 header 的話,沒法直接傳達給瀏覽器。因此若是場景裏有強依賴 aplus 動態參數,或者有重要的 set-cookie 操做的話,須要注意。目前一個方案是頁面上再經過一個同域名的異步接口去觸發想要的 set-cookie 邏輯。(或者容許的話,根據動態內容返回的 set-cookie header ,讓 js 來寫 cookie)
  2. 動態頁面 titile 和 meta 標籤屬性是變化的的問題 - 在動態內容獲取後,能夠經過往頁面中寫入 js 來從新設置這些屬性,來解決
  3. 對於 seo 頁面,若是採用動態內容插入的形式(包括 title 和 meta 標籤後續 js 寫入),可能對爬蟲不友好。能夠經過專門識別爬蟲 ua ,在邊緣節點上直接返回 ssr 完成頁面內容來解決

方案進度

目前經過 demo,已經驗證了方案的可行性。正在阿里巴巴國際站上的實際業務場景作實驗。將來將會分享更完善和豐富的方案(好比直接在邊緣節點上進行 react 組件渲染)和實際線上的運行效果。

參考

  1. cloudfare edge worker
  2. 2016 - the year of web streams
  3. ESI
  4. Async Fragments: Rediscovering Progressive HTML Rendering with Marko
  5. The Lost Art of Progressive HTML Rendering
相關文章
相關標籤/搜索