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

blank

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

螞蟻保險體驗與社區技術組招高級前端開發工程師/前端專家。有興趣的同學聯繫我[email protected]

開篇

之前的工作經歷有幸分別在知乎和美圖主導前端同構渲染的相關架構,給我最直觀的感受,這是前端渲染最為複雜的一種方案,也是為了追求極致的用戶體驗不得不去做的一種嘗試,雖然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 為類比,下圖表明其實施思路:

blank

通用代碼

由於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 毫秒。

What do you think?

Written by marketer

blank

給2019前端的5個建議

blank

NodeJS express框架核心原理全揭秘