前言
會場是每年雙十一的主角之一,會場的使用者體驗自然也是每年最關注的點。在日趨複雜的業務需求下,如何保障我們的使用者體驗不劣化甚至能更優化是永恆的命題。
今年(2020)我們在不改變現有架構,不改變業務的前提下,在會場上使用了 SSR 技術,將秒開率提高到了新的高度(82.6%);也觀察到在使用者體驗得到優化的同時,業務指標如 UV 點選率等也有小幅的增長(視不同業務場景有不同的提升,最大可達 5%),帶來了不錯的業務價值。
本文將從服務端、前端兩個角度介紹我們在 SSR 上的方案與經驗
- 前端在解決工程化、業務效果評估上的具體實踐與方法論
- 服務端在解決前端模組程式碼於服務端執行、隔離和效能優化上的具體實踐與方法論
(更多幹貨歡迎關注【淘系技術】公眾號)
頁面體驗效能的核心指標
在正文開始前我們先介紹一下衡量的相關指標,從多年前雅虎 yslow 定義出了相對完整的體驗效能評估指標,到後來的谷歌的 Lighthouse 等新工具的出現,體驗效能的評估標準逐漸的統一且更加被大家認同。
會場的評估體系
基於 Web.Dev 以及其他的一些參考,我們定義了自己的簡化評估體系
TTFB(Time to First Byte): 第一個位元組的時間 - 從點選連結到收到第一個位元組內容的時間
FP(First Paint): 第一次繪製 - 使用者第一次看到任何畫素內容的時間
FCP(First Contentful Paint): 第一次內容繪製 - 使用者看到第一次有效內容的時間
FSP(First Screen Paint,首屏可視時間): 第一屏內容繪製 - 使用者看到第一屏內容的時間
LCP(Largest Contentful Paint): 第一次最大內容繪製 - 使用者看到最大內容的時間
TTI(Time To Interactive): 可互動時間 - 頁面變為可互動的時間(比如可響應事件等)
大體上來說 FSP 約等於 FCP 或 LCP
會場的現狀
我們的會場頁面是使用基於低程式碼方案的頁面搭建平臺產出的,一個由搭建平臺產出的會場頁面簡單而言由兩部分組成:頁面框架(layout)和樓層模組。
頁面框架有一份單獨的構建產物(即頁面的 layout html 以及基礎公共的 js、css 等 assets 資源)。每個樓層模組也有單獨的一份構建產物(模組的 js、css 等 assets 資源,基礎公共 js 的依賴版本資訊等)。
頁面框架的任務比較繁雜,負責頁面的 layout、根據頁面的搭投資料載入具體哪些樓層模組並組織分屏渲染模組。
會場原有的 CSR 渲染架構如下圖,可以分成三部分:
- 客戶端,包括手機淘寶等阿里系 App
- 文件服務,用於響應頁面的主文件 HTML 清求
- 資料服務,用於響應頁面的資料請求
原有的CSR渲染流程如下圖
針對會場的效能,除了基礎的大家都知道的前端優化手段之外,還結合客戶端能力做過很多優化方案,比較具有代表性的有兩個:
- 客戶端主文件/Assets 快取
在客戶端內,我們利用了端側提供的靜態資源快取能力,將 HTML 和基礎公共的 JS 等資源,推送下發至使用者側客戶端快取。當客戶端的 WebView 請求資源時,端側可根據規則來匹配已下發的快取包,在匹配成功後直接從本地快取中讀取對應的 HTML 和 JS 資源,而無需每次都請求網路、大大縮短了頁面的初始化時間
- 資料預載入
從使用者點選跳轉連結到頁面開始載入資料,中間還要經過客戶端動畫、WebView初始化、主文件 HTML 請求以及基礎公共 js 的載入和執行這些過渡階段,加起來有 幾百ms 的時間被浪費掉。通過客戶端提供的資料預載入能力,在使用者點選後就可以立即由 native 開始頁面的資料載入,等頁面的基礎公共 js 執行完需要使用頁面資料時,直接呼叫 jsbridge 介面即可從 native 獲取已經預先載入好的資料
在這些優化工作的基礎上會場的體驗效能已經可以達到不錯的水準。
隨著時間的推移,基於我們 CSR 渲染體系下的優化存在一些瓶頸:
- 線上上複雜網路環境下(低網速、虛假的 WiFi)、Android 中低端機上的頁面體驗還是不盡人如意,特別是模組的載入和執行時間比較長,且這部分使用者的佔比有增長趨勢
- 作為拉新的一個重要手段,外部喚起淘寶或者天貓客戶端因為需要時間來初始化一些功能元件,比如網路庫等,頁面的體驗從體感上不能追平端內的會場
- 會場是營銷活動性質的業務,頁面的復訪率相對較低,且頁面內容全面個性化。離線的 HTML 快照等使用者側快取手段會因為快取的資料過期導致出現重複渲染(開啟更慢)、頁面元素跳動(渲染閃爍、重排)等傷害體驗的問題
還有沒有優化手段呢?以一個 2020 年雙十一會場頁面,使用 PC 上的 Chrome DevTools 的 performance 離線分析結果為例,我們看一下重點的問題
可以看到頁面從 FP 到 FCP 這段過渡的時間較長且只有背景色。FCP 到 LCP 這段時間處於等待圖片載入的時間,優化空間較小,且難以衡量。
離線分析尚且如此,線上更有著複雜的網路環境/差異化的手機機型等,這樣的“背景色”時間對使用者的體驗有很大的傷害,可能會讓使用者更加容易跳失。
我們的 CSR 渲染體系依賴前端+客戶端的能力,從工作機制上已經很難再有比較大的提升。怎麼才能讓會場頁面的體驗更上一層樓呢,我們想到了服務端渲染(SSR), 針對 FP 到 FCP 這段時間進行攻堅優化。
SSR 的線下測試結果,FP 到 FCP 從 825ms -> 408ms
SSR 要怎麼做?
大的方向
SSR 本身意為服務端渲染,這個服務端可以在 任何地方 ,在 CDN 的邊緣節點、在雲上的中心機房或者就在你家的路由上。
實現一個 SSR 的 demo,熟悉的人應該都知道套路:
搞一個 Rax Server Renderer,傳入一個 Rax Component,renderToString,完事了
業界也已經有很多實踐的案例,但就像“把大象裝進冰箱裡”一樣,看似簡單的事情在雙十一所要求的複雜場景穩定性下,需要有穩妥可實施的執行方案。
如何在現有的這套模組化、成熟的渲染架構之上使用SSR呢,一開始我們往常規的思路去想,直接在文件 HTML 響應中返回服務端渲染完成的 HTML,看下來存在幾個問題:
- 改造成本高,對現有的服務端架構改動比較大(CDN 快取失效,文件服務的要求更高)
- 無法複用現有的客戶端效能優化能力,比如客戶端主文件/Assets 快取和資料預載入能力,會劣化完全可互動時間
- CDN 快取無法利用,TTFB 的時間增加,帶來了新的 “完全白屏階段”
- SSR 服務不穩定因素較多,自動降級為CSR的方案複雜,無法保證 100% 能夠降級
- 主文件 HTML 的安全防護能力較弱,難以抵禦黑產的惡意抓取
基於以上的問題,我們考慮是否還有其他的方案可以 低風險 、 低成本 地實現SSR呢?經過短暫且激烈的討論,我們設計了「資料 SSR」架構方案,分享給大家。
資料 SSR 渲染架構如下,文件服務返回的內容保持靜態化不變,資料服務新增呼叫一個獨立的 SSR FaaS 函式,因為資料裡有這張頁面包含的模組列表和模組需要的資料,SSR FaaS 函式可以直接根據這些內容動態載入模組程式碼並渲染出 HTML。
這套方案在客戶端內的場景下可以很好的將 前端 + 客戶端 + 服務端三者的能力結合到一起。
有人可能會問,為什麼這個方案會帶來效能提升呢?不就是把瀏覽器的工作移到了服務端嗎?我們舉個例子(資料僅為定性分析,不代表真實值)。在正常 CSR 渲染流程下,每段消耗的時間如下,首屏可視時間總共耗時1500ms。
在SSR渲染流程下,在「呼叫載入基礎js」之前的耗時都是一樣的,由於下面兩個原因,在服務端渲染的耗時會比客戶端低幾個數量級。
- 服務端載入模組檔案比在客戶端快很多,而且服務端模組資源的快取是公用的,只要有一次訪問,後續所有使用者的訪問都使用這份快取。
- 服務端的機器效能比使用者手機的效能高出幾個數量級,所以在服務端渲染模組的耗時很小。根據線上實際耗時統計,服務端單純渲染耗時平均 40ms 左右。
由於 HTML 被放到了資料響應中,gzip 後典型值增加 10KB 左右,相應的網路耗時會增加 30~100ms不等。最終 SSR 的渲染流程及耗時如下,可以看到 SSR 首屏的可視時間耗時為660ms,比CSR提升了800ms。
總而言之,「資料 SSR」的方案核心哲學是:將首屏內容的計算轉移到算力更強的服務端
核心問題
大方向確定了,我們再來看看 SSR 應用到生產中還存在哪些核心問題
- 如何做到 CSR/SSR 的平滑切換
- 開發者如何開發出“能 SSR”的程式碼
- 開發者面向前端編寫的程式碼在服務端執行的不可控風險
- 低程式碼搭建場景下,在服務端解決樓層模組程式碼載入的問題
- 服務端效能
- 怎麼衡量優化的價值
別急,我們一個一個的來看解法
如何做到 CSR/SSR 的平滑切換?
在我們的頁面渲染方案中,有兩個分支:
- 頁面未開啟資料SSR,則與原有的 CSR 渲染流程一樣,根據資料中的模組列表載入模組並渲染
- 頁面開啟了資料SSR並且返回的資料中有 SSR HTML,則使用 SSR 的 HTML 塞入到 root container 中,然後根據資料中的模組列表載入模組最終 hyrdate。
優點很明顯
- 風險低,能夠無縫降級到CSR,只需要判斷資料介面的響應中是否成功返回 HTML 即可。如果 SSR 失敗或者超時(未返回 HTML),通過設定合理的服務端超時時間(例如 80ms),不會影響到使用者的最終體驗
- 能夠利用端上成熟的效能優化能力,比如客戶端快取能力,資料預載入能力。有客戶端快取能力,頁面的白屏時間與原CSR一致;有了資料預載入能力,能夠在頁面載入之前就開始請求資料服務
線上上服務時,我們可以通過 HASH 分桶的方式對流量進行劃分,將線上的流量緩慢的切換到 SSR 技術方案,既能保證穩定性,同時還可以方便的進行業務效果的進一步評估。
比較好的字串轉換為數字的 HASH 方法有 DJBHash,驗證下來分桶效果較為穩定
開發者如何開發出“能 SSR”的程式碼?
很多做 SSR “demo”分享的往往會忽略一個重要點:開發者
在雙十一的場景下,我們有百+的開發者,三百+的樓層模組,如何能推動這些存量程式碼升級,降低開發者的改造適配成本是我們的一個核心方向。
我們原有的樓層模組構建產物分為 PC/H5/Weex 三個,業界通用的是針對 SSR,單獨構建一個 target 為 node 的構建產物。在實際 POC 驗證過程中,我們發現其實絕大部分的模組並不需要改造就可以直接適配 SSR,而新增構建產物會牽扯到更多的開發者,於是想找尋別的解決方案。
複用現有 Web 構建產物的一個問題是,Webpack 4 預設會注入一些 Node 環境相關變數,會導致常用的元件庫中的類似 const isNode = typeof process !== 'undefined' && process && process.env
的判斷異常。不過還好這個是可以關閉的,開發環境下其他的類似 devServer
等的注入也是可以關閉的,這給了我們一點慰藉,最終複用了 Web 的構建產物。像更新的 Webpack 5 中把 target 的差異給弱化了,也可以更好的定製,讓我們未來有了更好的社群化方向可以繼續靠攏。
解決完構建產物的問題,在本地開發階段,Rax 團隊提供了 VSCode SSR 開發外掛,整合了一些 best practice 以及 lint 規則,寫程式碼的時候就可以發現 SSR 的相關問題,及時規避和修復。
同時我們模擬真實線上的環境,在本地提供了 Webpack 的 SSR 預覽除錯外掛,直接 dev 就可以看到 SSR 的渲染結果。
針對開發者會在程式碼中直接訪問 window
、 location
等變數的場景,我們一方面開發了統一的類庫封裝呼叫抹平差異,另一方面在服務端我們也模擬了部分常用的瀏覽器宿主變數,比如 window
、 location
、 navigator
、 document
等等,再加上與 Web 共用構建產物,所以大部分模組無需改造即可在服務端執行。
接下來的模組釋出階段,我們在工程平臺上增加了釋出卡口,若在程式碼靜態檢查時發現了影響 SSR 的程式碼問題就阻止釋出並提示修復。
由於實際的業務模組量較大,為了進一步縮小改造的範圍,測試團隊聯合提供了模組的批量測試解決方案。具體的原理是構造一個待改造模組的 mock 頁面,通過比較頁面 SSR 渲染後的截圖與 CSR 渲染後的截圖是否一致,來檢測SSR 的渲染結果是否符合預期
開發者面向前端編寫的程式碼在服務端執行的不可控風險
儘管我們在開發階段通過靜態程式碼檢查等方法極力規避問題,實際上仍然存在一些針刺痛著我們的心
- 開發者把全域性變數當快取用造成記憶體洩露
- 錯誤的條件結束語句導致死迴圈
- 未知情況頁面上存在不支援 SSR 的模組
這些疑難點從 SSR 的機制上其實很難解決,需要有完善的自動降級方案避免對使用者的體驗造成影響。
在說更詳細的方案前要先感謝我們自己,前端已經提前做到了 CSR/SSR 的平滑切換,讓服務端能每天不活在恐懼裡 = =
對於機制上的問題,可以引申閱讀到之前分享過的 在 Node.js 中 ”相對可靠” 的高效執行可信三方的程式碼。我們這裡主要聚焦在如何快速止血與恢復。
FaaS 給服務端降低了非常大的運維成本,“一個函式做一件事”的設計哲學也讓 SSR 的不穩定性侷限在了一塊很小的部分,不給我們帶來額外的運維負擔。
低程式碼搭建場景下,在服務端解決樓層模組程式碼載入的問題
業界分享的一些 SSR 場景基本都是整頁或者 SPA 型別的,即 SSR 所使用的 bundle 是將整頁完整的程式碼構建後暴露出一個 Root Component,交由 Renderer 渲染的。而我們的低程式碼搭建場景,由於整個可選的模組池規模較大,頁面的樓層模組是動態選擇、排序和載入的。這在前端 CSR 情況下很方便,只要有個模組載入器就可以了,但是在服務端問題就比較複雜。
還好我們的模組規範遵守的是特殊的 CMD 規範,有顯式的依賴關係宣告,可以讓我們在獲取到頁面的樓層組織資訊之後一次性的把頁面首屏的全部 Assets 依賴關係計算出來。
在服務端載入到程式碼後,我們就可以拼裝出一個 Root Component 交給 Renderer 渲染了。
服務端效能
效能上主要是有幾個方面的問題
- 機制問題
- 程式碼問題
機制問題
由於樓層模組很多,在實際執行的過程中發現存在一些機制上的效能問題
- 程式碼的 parse 時間較長且不穩定
- 流量較低情況下難以觸發 JIT
優化方案的話比較 tricky
- 快取
vm.Script
例項,避免重複 parse - 期望一致性 HASH 或自動擴縮容(本次未實現)
巡檢的時候還觀測到存在小範圍的 RT 抖動問題,分析後定位是同步的 renderToString 呼叫在微觀上存在排隊執行的問題
在這種情況下會造成部分渲染任務的 RT 為多個排隊任務的渲染 RT 疊加,影響單個請求的 RT(但不影響吞吐量)。這種問題要求我們需要更精確的評估備容的資源。機制上有效的解法推測可以讓 renderToString
以 fiber 的方式執行,緩解微觀排隊造成的不公平的問題。
程式碼問題
效能問題的分析當然免不了 CPU Profile,拿出最愛的 alinode 進行分析,很快的可以找到熱點進行鍼對性優化。
上圖中標藍的方法為 CMD 載入器計算依賴的熱點方法,對計算結果進行快取後熱點消除,整體效能提升了 80% 》.》
怎麼衡量優化的價值
這麼多的投入當然需要完善的評價體系來進行評價,我們從體驗效能和業務收益兩個分別評估。
體驗效能
基於相容性較好的 PerformanceTiming
(將被 PerformanceNavigationTiming 替代),我們可以獲取到前端範疇下的一些關鍵的時間
- navigationStart
- firstPaint
其中 navigationStart
將會作為我們的前端起點時間所使用。在前端之外,對使用者的互動路徑而言真正的起點是在客戶端的點選跳轉時間 jumpTime
,我們也聯合客戶端進行了全鏈路埋點,將客戶端 native 的時間與前端的時間串聯了起來,納入到我們的評價體系中。
在最開始的核心指標中,我們看到有 FCP、TTI 這幾個指標。目前的 Web 實現中,還未有相容性較好的可以線上衡量的方案(線下可以使用 DevTools 或者 Lighthouse 等工具),因此我們通過其他的方式來做近似代替
線上取到的資料通過 tracker 的方式進行無取樣上報,最終我們可以通過多個維度進行分析
- 機型
- 網路條件
- 是否命中 SSR
- 是否命中其他前端優化
主要的衡量指標有
- 從使用者點選到 FCP 的時間(FCP - jumpTime)
- 從 NavigationStart 到 FCP 的時間(FCP - NavigationStart)
業務收益
這部分很忐忑,體驗的優化是否會帶來真金白銀的收益呢?我們直接通過 AA 和 AB 實驗進行業務資料的分析。
基於之前的切流分桶,我們可以通過類似 hash 值 % 10
的方式將流量分為 0~9 號十個桶,首先通過 AA 實驗驗證分桶是否均勻
統計指標舉例
這一步是保證分桶的邏輯本身不會有資料的傾斜影響置信度。接下來我們再進行 AB 實驗,逐步增加實驗桶驗證業務資料的變化。
最終的效果
搞了這麼多,得看看產出了。在這次雙十一會場中,我們切流了多個核心的頁面,拿到的第一手資料分享給大家。
小米5 驍龍 820 處理器
可以看到,在 Android 碎片化的生態的下,帶來的提升甚至超出了預期,這也給了我們未來更大的動力,將前端 + 客戶端 + 服務端的能力更有效的結合到一起,帶給使用者更好的體驗,給業務創造更大的價值。
未來的渲染架構還會更復雜嗎?
為了更好的使用者體驗,當然會了!我們可以簡單的看看短期和長期的一些事情
電商體驗指標的統一定義
長期以來,業務在使用者側的實現有 Web、Native、Hybrid 混合開發等多種選擇,每個體系都有著自己的封閉體驗衡量標準,這就造成了一些“雞同鴨講”的問題。而 Web.dev 中所定義的 FCP、LCP 通用評價體系也並不適合電商場景,能展示出核心的商品/店鋪其實對一張頁面來說就完成了它的使命。
後續我們可以將體驗指標評估標準對齊,將起點時間、繪製完成時間等在多個體系對齊概念與實現,達到互相之間可以橫向比較良性競爭的狀態。
工程上還有更多的事情要做...
在 Webpack 5 的 Release Note 中,我們可以看到 Webpack 正在弱化 target 的一些特殊處理,將 Web 描述為了 browserlike 的環境。同時還提供了自定義 browserlist 的能力,可以給予開發者更方便處理跨端的相容性問題的能力。這一變化將推動我們更快的擁抱社群,獲得更好的開發體驗。
現有的 SSR 靜態程式碼檢查方案會有一些漏網之魚,還有沒有更完善的方案能從工程上前置解決程式碼風險(效能、安全)問題也是未來的一個方向。
ServiceWorker Cache 等離線快取快照
復訪率高,變化不太大的頁面可以利用 ServiceWorker Cache 等方案,將之前的渲染結果快取下來,命中快取直接用,未命中快取 SSR。降低服務端壓力的同時可以讓體驗更好。
SSR 的效能優化與安全
現階段的 Node.js 或者說 V8,對於動態載入程式碼的情況支援並沒有特別的完善,缺失了安全相關的保護邏輯。並且從效能上來說,SSR 屬於 CPU 密集型的 workload,純非同步的優勢並不明顯,也可能需要一些特殊的解決方案來配合。
外部投放場景的覆蓋
「資料 SSR」的方案是端內的最佳方案,卻是外投場景的最劣方案。外投場景下由於使用者是在第三方 App 中開啟頁面,相應的缺失了客戶端的定製化優化能力,SSR 呼叫會造成資料服務的 RT 增加,反而推後了 FCP。
這時候古老的 HTML 直出方案又可以再撈回來了。
核心在於
- 利用 CDN 的邊緣計算能力,可以較好的做到“動靜分離”以及容災
- 使用中心化的 SSR 函式,可以將 SSR 的不穩定性與 CDN 的可靠性分離,保證近端鏈路的可靠,避免出現近端直接不可用導致的無法恢復
近端的流式方案經常被提及,但是在實際的使用中會遇到當流式輸出遇到錯誤時,使用者側無法有效容災的問題(HTML 損毀,無法補救)。通過“動靜分離”可以將頁面分為
僅將 Root Container 進行動態化,進而在享用流式輸出帶來的 TTFB 提前的好處的同時又能兼顧容災 SSR 的不穩定。和業務團隊更可以一起探討下如何將頁面更好的從業務上做到“動靜分離”,而不是僅從技術的角度出發。
總結
渲染架構的不斷改進實質上是我們在有限且變化的環境下(終端效能、複雜網路和多變業務)自發做的適應,也許有那麼一天,環境不再是問題,效能優化的課題將會消失。我們專案組有時候還開玩笑,等明年手機叒換代了,5G 100% 普及了,是不是這些優化都可以下線了?
但是!現在看理想還有點遠,在 2020 的雙十一會場我們走進了一個新的深水區,期待未來技術與業務結合能帶給廣大使用者更棒的體驗!