前端同構渲染的思考與實踐

螞蟻保險體驗技術發表於2019-03-08

開篇

前端同構渲染的相關架構,給我最直觀的感受,這是前端渲染最為複雜的一種方案,也是為了追求極致的使用者體驗不得不去做的一種嘗試,雖然 Node.js 的引入賦能了傳統前端領域、SEO 優化也不再是個問題,但很明顯,這些只是副產品。

問題

上帝為了我們開了一扇窗,同時也會為我們關上一扇門。

我們所知的傳統型 SPA,單頁面應用,貼近使用者端越近,互動越複雜,它的弊端就越明顯,在我們享受 JavaScirpt 給我們帶來的無重新整理體驗和元件化帶來的開發效率的同時,『白屏』這個隨著 SPA 各種優點隨之而來的缺點被遺忘,我們擁有菊花方案在 JavaScript 沒有將 DOM 構建好之前蒙層,擁有白屏監控方案將真實使用者資料上報改進,但並沒有觸碰到白屏問題的本質,那就是『DOM 的構建者是 JavaScript,而非原生的瀏覽器』。

<html>
  <head><title /></head>
  <body>
  	<div id="root"></div>
    <script src="render.js"></script>
  </body>
</html>
複製程式碼

如上程式碼,在 SPA 架構中,伺服器端直接給出形如這樣的 HTML,瀏覽器在渲染 body#root 這個節點完成之後,頁面的繪製區域其實還是空的,直到 render.js 構建好真實的 DOM 結構之後再 append 到 #root上去。此時,首屏展示出來時,必然是 render.js 通過網路請求完畢,然後加上 JavaScript 執行完成之後的。

讓我們回到最初的那個前端時代,那時候 JavaScript 還沒有那麼強大,我們的伺服器端全部吐出 HTML 給前端,我們使用 jQuery 解決使用者的互動,這種方式雖有很多弊病,但不可否認的是擁有理論上最低白屏時間。

<html>
	<head><title /></head>
  <body>
  	<div id="root">
    	<div class="header">
        <img src="logo.png" />
      </div>
      <div calss="content">
        <div class="shopitem">
        </div>
      </div>
    </div>
  </body>
</html>
複製程式碼

如上程式碼,在直出的伺服器渲染中,瀏覽器直接拿到最終的 HTML,瀏覽器通過解析 HTML 之後將 DOM 元素生成而進行渲染。所以相比於 SPA,伺服器端渲染從直觀上看:

  • 轉化 HTML 到 DOM,瀏覽器原生會比 JavaScript 生成 DOM 的時間短
  • 省去了 SPA 中 JavaScript 的請求與編譯時間

**

解決

Node.js 的出現極大程度的給傳統前端賦予了更大的能量,前端的分離也從前期的物理檔案的區分轉變為職責上的區分,前端開發者從頁面仔的噩夢中解脫出來,最重要的是,JavaScript 能在伺服器端執行了。在享受這些紅利的同時,我們就會不自覺的設想一種方案,它擁有 SPA 的大部分優點,卻解決了它大部分的缺點,那就是伺服器端輸出 HTML,然後由客戶端複用該 HTML,繼續 SPA 模式,這樣豈不是既解決了白屏和 SEO 問題,又繼承了無重新整理的使用者體驗和開發的元件化嘛。

嗯,如果這樣的話,就會有個一致性的問題。我們必須在瀏覽器端複用伺服器端輸出的 HTML 才能避免多套程式碼的適配,而傳統的模板渲染是可行的,只要選擇一套同時支援瀏覽器和 Node.js 的模板引擎就能搞定。我們寫好模板, 在 Node.js 準備好資料,然後將資料灌入模板產出 HTML,輸出到瀏覽器之後由客戶端 JavaScript 承載互動,搞定。

軟體開發中遇到的所有問題,都可以通過增加一層抽象而得以解決

思路到了這裡,我們就會發現,『模板』其實是一種抽象層,雖然底層的 HTML 只能跑在瀏覽器端,但是頂層的模板卻能通過模板引擎同時跑在瀏覽器和伺服器端,此為垂直方向,在水平方向上,模板將資料和結構解耦,將資料灌入結構,這種灌入,實際是一錘子買賣,管生不管養。

隨著時間的推進,元件化的大潮來了,其核心概念 Virtual DOM 依其宣告式和高效能讓前端開發者大呼爽爽爽,但究其本質,就是為了解決頻繁操作 DOM 而在 HTML 之上做的一層抽象,與模板不同的是,它將資料與結構產生互動,有代表的要數 Facebook 方使用的單項資料流和 Vue 方使用的 MVVM 資料流,大道至簡,我們觀察函式 UI = F(data), 其中 UI 為最終產出前端介面,data 為資料,F 則為模板結構或者 Virtual DOM,模板的方式是 F 只執行一遍,而元件方式則為每次 data 改變都會再執行一遍

所以理論上,無論是模板方式還是元件方式,前後端同構的方案都呼之欲出,我們在 Node.js 端獲取資料 ,執行 F 函式,得到 HTML輸出給瀏覽器,瀏覽器 JavaScript 複用 HTML,繼續執行 F 函式,等到資料變化,繼續執行 F 函式,互動也得到解決,完美~~~

實施

但由於元件化大勢所趨,下文將略去模板方案,我們以 Vue 為類比,下圖表明其實施思路:

前端同構渲染的思考與實踐

通用程式碼

由於 F 同時需要在瀏覽器端和伺服器端執行,所以對於整個 Vue App,我們需要同時支援兩端,也就是通用程式碼。所以我們需要將 SPA 架構的程式碼進行改造:

  • 分為兩個入口,分為服務端和客戶端,只引入通用程式碼,然後在不同的環境裡呼叫各自的渲染函式。當然,在客戶端 ReactDOM.render 會生成 DOM 結構,而伺服器端通過 ReactServer.renderToString 將生成 HTML,需要由 HTTP Server 推給前端,各入口處解決特異的環境問題;
  • 通用程式碼中不可在不判定執行環境的情況下引用 DOM、呼叫 window、document 這些瀏覽器特異和引用 global process 這些伺服器端特異的操作,這往往是引起 Node.js 服務出問題的根本原因;
  • 為了相容兩端,在選擇庫時,需要也同時需要支援兩端,比如 axios,lodash 等;
  • React 和 Vue 都有生命週期,需要區分哪些生命週期是在瀏覽器中執行,哪些會在伺服器端執行,或者是同時執行,如使用 Redux 或者 Vuex 等庫,最好在元件上引入 asyncData 鉤子進行資料請求,同時供兩端使用;
  • 判定不同的執行環境可以通過注入 process.env.EXEC_ENV 來解決,形如:
if (process.env.EXEC_ENV === 'client') {
  window.addEventListener(...);
}

if (process.env.EXEC_ENV === 'server') {
}
複製程式碼

構建與執行

  • 在使用 webpack 進行構建時,需要將公共 App 部分打包出來,形成公共程式碼,由伺服器端引入執行,而客戶端可以引用打包好的公共程式碼,再用 webpack 引入之後進行特異處理即可;
  • 需要引入 Node.js 中間層,負責請求資料,提供渲染能力,提供 HTTP 服務,由於 HTML 模板需要在服務端引入,CDN 檔案需要自行處理;
  • 至於 babel 的使用,可以在瀏覽器中通用處理,服務端只解決特殊語法,如 jsx,vue template;

新世界

至此,白屏問題問題看起來是解決了,通過把 JavaScript 的渲染邏輯放到 Node.js 端進行,我們加快了首屏出現的時間,但是聯想到 Node.js 對前端的賦能,我們或許可以做的更多。

再議首屏

讓我們把視角移動的更細緻一些,關注『從伺服器端輸出 HTML』這一部分,其隱藏的含義是我們需要把 App 渲染的所有 HTML 都輸出給前端,其實不然,舉個栗子:

比如在移動端有一個頁面,它有大約 10 屏的高度,如果我們在伺服器端全部輸出 10 屏其實是有點浪費的,我們可以只輸出首屏需要的,從而降低 render 執行時間從而降低 TTFB 時間,讓頁面更快的到達使用者眼前。實踐中,一般情況是輸出大概快兩屏的樣子,就能處理所以機型的高度問題,剩下的 8 屏,在瀏覽器端繼續渲染,漸進產出內容,使用者無感知。

資源控制

得益於 Node.js 輸出 HTML 的另一層含義,就是我們可以直接在首次接觸就能感知到客戶端,也就有了足夠的靈活性,再舉個栗子:

有個針對安卓平臺和 iOS 平臺不同的指令碼只要載入,如果在 SPA 情況下,只有等 JavaScript 執行時我們判定 navigation.userAgent 來獲知先在是哪個平臺,然後在 appendChild 一個 script 到 body,但如果服務端能首次接觸就能感知,我們可以在服務端直接拿到 HTTP 請求中的 userAgent 判定平臺,根據標識在模板中處理,很顯然,這樣很穩。

另外,如果有一些特別複雜的計算,服務端可以有更多的辦法將資料更快的處理,以避免繁忙無比的瀏覽器接手。

快取控制

一般的業務場景下,我們需要在 Node.js 中通過內網將資料獲取到,然後通過 render 函式渲染出 HTML(一般需要將資料附帶給 HTML 輸出以便重複利用),這個時候我們可以通過頁面訪問地址和生成的 HTML 字串做快取策略,在快取(一般選擇 redis 等方案)之後,下次直接將同樣的頁面直接輸出到前端,可大幅提高渲染效能

但這種方案也有很多限制,因為要考慮頁面地址、多平臺下、賬戶是否登入,頁面是否需要改動等情況:

  • 頁面地址緯度,在不同的地址下,HTML 輸出不一致,所以 URL 可作為 key 的元素之一;
  • 未登入態,頁面可以直接快取,如需判定平臺特異,需在 Node.js 端進行處理;
  • 已登入態,如果已快取某一個已登入使用者的 HTML,需要將跟登入相關的元件抹去重新換掉,或者直接給予未登入態頁面,在客戶端進行變更。

挑戰

同構渲染看似美好,但其相對傳統 SPA 確有著更多挑戰:

Node.js

伺服器端渲染相對應傳統的 Node.js 應用,renderToString 函式不僅 CPU 密集,而且不同的元件對機器資源的要求不盡相同,這就更需要 Node.js 指標的監控、日誌的記錄、錯誤的收集、崩潰機制的完善。這裡額外的關鍵的指標是 renderToString 的時間,它反應了 Node.js 渲染所使用的時間,如果加入快取機制,就需要統計命中率等等。

程式碼質量

關於寫通用程式碼,要求比 SPA 架構對開發者提出了更高的要求,我們需要小心再小心,因為萬一搞錯,將導致很難排查的記憶體洩露和 CPU 飆升,並且一旦出了問題,就像要修理天上跑的飛機一樣,非常困難。還記得有一次在類似 componentWillMount 寫了一些跟瀏覽器相關的程式碼導致的記憶體飆升,還有一次 JSON.stringify 一個大物件導致的 CPU 飆升,不堪回首。這方面 alinode 做的很好,確實可以滿足這種飛機場景。

結語

為了效率, 前端們付出了艱辛的努力,無論是工程上我們千方百計的製造工具,還是元件化的引入,我們解決的是開發的效率,而無論是 Virtual DOM 的引入解決頻繁操作的 DOM,還是用了提升使用者體驗而使用的 SPA 架構,我們解決的是使用者的使用效率,是前端的效能。而同構渲染也是這樣一種方案,它引入了 Node.js 的複雜度,要求我們寫出限制更多的程式碼,其根本目的還是為了讓使用者更快更早的看到頁面,那怕是 50 毫秒,那怕是 10 毫秒。


關於我們

我們是螞蟻保險體驗技術團隊,來自螞蟻金服保險事業群。我們是一個年輕的團隊(沒有歷史技術棧包袱),目前平均年齡92年(去除一個最高分8x年-團隊leader,去除一個最低分97年-實習小老弟)。我們支援了阿里集團幾乎所有的保險業務。18年我們產出的相互寶轟動保險界,19年我們更有多個重量級專案籌備動員中。現伴隨著事業群的高速發展,團隊也在迅速擴張,歡迎各位前端高手加入我們~

我們希望你是:技術上基礎紮實、某領域深入(Node/互動營銷/資料視覺化等);學習上善於沉澱、持續學習;性格上樂觀開朗、活潑外向。

如有興趣加入我們,歡迎傳送簡歷至郵箱:luguang.ylg@antfin.com


本文作者:螞蟻保險-體驗技術組-月影

掘金地址:楊柳岸醬

相關文章