作者 | 霸劍、步峰

會場是每年雙十一的主角之一,會場的使用者體驗自然也是每年最關注的點。在日趨複雜的業務需求下,如何保障我們的使用者體驗不劣化甚至能更優化是永恒的命題。
今年(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 依賴關系計算出來。
/**
示例的 CMD 規範顯式依賴關系(seed)
**/
{
"packages": {
"@ali/pcom-alienv": {
"path": "//g.alicdn.com/code/npm/@ali/pcom-alienv/1.0.2/",
"version": "1.0.2"
},
"modules": {
"./index": [
"@ali/pcom-alienv/index"
]
}
}
在服務端加載到代碼後,我們就可以拼裝出一個 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 等工具),是以我們通過其他的方式來做近似代替。
CSR | SSR | |
---|---|---|
FCP | componentDidMount | innerHTML to Body |
TTI |
線上取到的資料通過 tracker 的方式進行無采樣上報,最終我們可以通過多個次元進行分析
- 機型
- 網絡條件
- 是否命中 SSR
- 是否命中其他前端優化
主要的衡量名額有
- 從使用者點選到 FCP 的時間(FCP - jumpTime)
- 從 NavigationStart 到 FCP 的時間(FCP - NavigationStart)
業務收益
這部分很忐忑,體驗的優化是否會帶來真金白銀的收益呢?我們直接通過 AA 和 AB 實驗進行業務資料的分析。
基于之前的切流分桶,我們可以通過類似 hash 值 % 10 的方式将流量分為 0~9 号十個桶,首先通過 AA 實驗驗證分桶是否均勻
桶号 | 1 | 2 | 3 | ... |
---|---|---|---|---|
PV | 100 | 101 | 99 | 98 |
UV | 20 | 21 | 22 |
統計名額舉例
這一步是保證分桶的邏輯本身不會有資料的傾斜影響置信度。接下來我們再進行 AB 實驗,逐漸增加實驗桶驗證業務資料的變化。
最終的效果
搞了這麼多,得看看産出了。在這次雙十一會場中,我們切流了多個核心的頁面,拿到的第一手資料分享給大家。
潮流女裝會場 | ||
---|---|---|
從使用者點選到 FSP 的時間小于 1s 的比例 | iOS 66%Android 28% | iOS 86% +30%Android 60% +114% |
UV 點選率提升 | +5% |
小米5 骁龍 820 處理器
未來的渲染架構還會更複雜嗎?
為了更好的使用者體驗,當然會了!我們可以簡單的看看短期和長期的一些事情。
電商體驗名額的統一定義
長期以來,業務在使用者側的實作有 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 的雙十一會場我們走進了一個新的深水區,期待未來技術與業務結合能帶給廣大使用者更棒的體驗!
🔥第十五屆 D2 前端技術論壇開放報名,速搶!
關注「Alibaba F2E」
把握阿裡巴巴前端新動向