摘要:在下一篇文章中我們會繼續詳細討論如何使用依賴表來進行 SSR 的 按需加載 的動態化,同時對 sis-ssr 和目前傳統實現的 SSR 服務進行相關壓力測試並分析相關的測試結果,從而清楚此種方式在單機性能上的邊界。在之後的 optimize 階段我們需要大量的在依賴對象中進行跳轉和改寫,對樹形結構展平,性能會更好,同時更容易達成這一目標。

簡述

在前端還未系統化之時的刀耕火種時代,已經有非常多的生成頁面的工具,其可視化的方式極大的賦能了非技術人員,加快了業務的迭代速度。如今,隨着前端技術的發展和複雜化,我們看到越來越多以組件爲基礎的頁面級可視化生成工具已經出現在了各大領域。

依靠 Vue / React 等 UI 組件框架和逐漸易用化的 Webpack 等編譯工具的出現,編寫一套符合自己業務需求的前端可視化頁面生成工具並不複雜。但大部分的頁面可視化系統大都以完全純前端思維來打造,對其論述也僅僅是大體的做法和原理剖析,並沒有以前端工程化的角度來闡述其中的困難與細節。

在 《深入淺出動態化 SSR 服務》系列文章中,我們將以較爲深入的前端工程化角度來講述如何打造一款動態化且支持 SSR 的頁面可視化系統。希望大家能以此爲參考,更深入的思考前端工程化的實踐,並能在業務當中有相關的提升。

本系列文章分爲三個部分:

  1. 開發工具篇: 當前篇我們會較爲深入和系統地講述 sis 的編寫過程,其中涉及到技術選型考量,編譯前端相關的知識。同時我們會適當講述不直接採用 Vue CLI 等工具鏈的原因及其相關的不足點,更多的內容在《SSR 服務篇》中我們還會通過測試結果進行更深入分析和講述。
  2. SSR 服務篇:在這一篇中我們會探討當前 SSR 服務性能和穩定性相關的問題,在壓力測試工具以及 Node 相關 Profile 幫助下優化我們的 SSR 服務,達到單機服務高穩定性、高性能、動態化的要求。
  3. 架構篇:在這一篇中我們會講述此頁面可視化系統的架構層面的一些思考和實踐,從更全局和整體的角度來探討如何保證系統的高穩定和高性能化要求。

在此,我非常感謝百度的 FEX 團隊產出的 FIS 工具 ,其中非常多關於前端工程化的思考都被融入到了 sis 以及 sis-ssr 中。同時我也非常感謝與我亦師亦友的 FIS 作者 / 前端工程化先行人 - 張雲龍 從全民直播至今對我整體技術方向的指引和多維度方法論的培養。

項目背景

如今市面上的頁面可視化系統主要以純前端技術爲主,並沒有對結果頁面進行 SSR 同構。究其原因可以總結爲:純前端技術的整體系統實現較爲容易,對於其產出結果只需要由 CDN 來進行加速,即可完成抵抗單頁面大流量的需求。我們可以看到目前市面上類似的運營 / 營銷產品可視化系統大體都是如此。

但是 SSR 同構服務的引入是有其積極意義的,其核心的兩點在於:

  1. SEO 友好
  2. 首屏渲染的加速

對於很多站點而言,其在 SEO 上的需求與大型廠商的系統有很大的不同:有非常多站點的產品頁面仍然需要 Google 等搜索引擎進行收錄。這點我們可以從開放的 robots.txt 文件看到:

複製代碼

User-agent:Googlebot
Crawl-delay:1
Allow:/vp/products/
Allow:/vm/products/
Disallow:/*.css$
Disallow: /*.js$
{1}

在這裏可能有部分讀者會問:“Google 不是可以對 SPA 頁面進行收錄麼?”,其實不然,我們在 Google 的相關解釋中可以找到這樣一句話:

Note: that as of now, only can index synchronous JavaScript applications just fine.

因此,當我們的頁面如果存在異步請求然後再進行頁面渲染的話,Google 仍然是無法進行有效收錄的。那麼基於 Headless 的預渲染服務(Prerendering)又會如何呢?由於 Headless 需要啓動完整的瀏覽器核心進行渲染,因此對於服務端性能是極大的消耗(儘管可以創造 Headless 的對象池進行重用,但渲染時性能仍然有較大的服務器端消耗)。針對較少的營銷頁面還可以,但是在需要整站 SSR 這個場景下,這並不能發揮很好的作用。

其次,特別是對於電商類產品而言,根據 Amazon 的頁面加載延遲與收入關係的實踐數據(每增加 100ms 網站加載延遲將導致收入下降 1%),我們需要非常強力的支持進行首屏渲染的加速,降低內容到達時間 (time-to-content),保證更好的用戶體驗和更高的用戶留存。

與此同時,我們也應該支持非常靈活的頁面可視化搭建平臺來應付大量的日常化運營需求,並且也應該具有開放的能力和通用性,能很好的支撐公司其他團隊的業務。在此背景下,我們稍加總結就能清晰的得到這套系統需要達成的目標,即:

  • 組件化及可視化
  • 能夠進行 SSR 服務的渲染
  • 動態化,測試及發佈不涉及核心的 SSR 渲染服務
  • 足夠靈活,其他團隊能很好的接入及使用
  • 能夠保障服務安全,並做到業務隔離

技術選型

對於 SSR 服務而言,存在兩種思路體系可以選擇:

  1. HandleBars 等以純字符串渲染引擎爲主的思路
  2. Vue / React 等現代 UI 框架以 Virtual DOM 爲主的思路

HandleBars 等字符串渲染引擎爲主的思路優點在於:性能。但是對於現代的前端開發來說,難以地很好的利用 NPM 生態,對開發不是很友好。而以 Vue / React 等現代 UI 框架的 Virtual DOM 的思路,能夠很好的利用生態,但是缺點也是相當明顯的,即:由於編譯階段會產生非常多的 Virtual DOM 對象,因此在渲染性能和內存佔用相比 HandleBars 等字符串渲染引擎的思路而言並不佔優。

當然,在我們技術選型時,我們一般首先以 開發友好 爲準則進行,畢竟效率即是一切。因此 Vue / React 的方案是我們理所當然會去選擇的。但是, Vue / React 等現代 UI 框架的所有出發點總歸是以純前端爲主,後端渲染爲輔的思路在做支持,因此其所包含的工具鏈( Vue CLI 等)支持並不能很好的滿足我們自身的需求,舉個例子:

團隊 A 和團隊 B 互不干涉的分別基於此係統開發兩個項目 C1 與 C2,此時,C1 和 C2 項目都引用相同版本的諸如 Vue、Lodash 等公共依賴。現將 C1 與 C2 進行相關的同構打包,在不做編譯工具調整的情況下,會產出 D1 和 D1 兩個發佈包。

我們可以看到,在這個例子之下,D1 和 D2 必定會包含相同的公共依賴。這對於開放且動態化的 SSR 服務而言是極度不友好的,因爲代碼包體越大,無關代碼越多,那麼服務本地初始化的 IO、CPU 和內存佔用成本勢必會隨之增加,這點我們在之後的《SSR 服務篇》可以更深入地分析得到。其次,對於瀏覽器端的而言,因爲靜態資源的加載時間被增加了,也會增加更多的用戶操作響應時間。考慮這樣一個場景:

D1 發佈結果包含了 100 個組件,但是對於某一生成頁面而言,只需要對 2 個組件進行重複渲染。

在這個場景中,不管是服務端還是瀏覽器端,我們都需要浪費大量的 IO、CPU 和內存在剩下的 98 個組件代碼之中。當然,在這些場景裏面,類似 Webpack 之類的編譯工具仍然可以通過開發者標註 Dynamic Load 的方式來進行按需加載,但這也造成了非常大的開發負擔,我們希望整個過程是編譯工具自動完成的。因此,直接使用 Vue / React 等現代 UI 框架的現有工具鏈是完全無法滿足我們的系統要求的。我們需要對現有工具鏈進行替代或改進。

總而言之,最後我們確定的技術選型爲:

Vue
ElementUI
sis

需要注意的是, 整個系統設計上實際與 UI 庫 / 框架是無關的 ,但我建議我們仍然需要在開發及生產期間固定你的技術選型,以此來避免因爲無技術選型造成的項目不可維護及混亂,可以把這個看做是一個內部強制的約定。當然接下來的內容我們都會以 Vue 來進行討論,如果有通用渲染服務的需求,可以在此基礎上進行參考。

同時對於頁面可視化系統來說,開發應該只是關注於組件的開發,而較少考慮外部系統的邏輯,因此我們一般採用如下的項目目錄結構:

在這個基礎上,我們所有最終的編譯關注點應是在 components 目錄,這是我們編譯的目標目錄。而其他文件主要是用作本地開發時所用,在最終的結果中並不引入。

資源的加載分析

在上面我們已經分析過,直接使用 Vue CLI 等工具鏈並不能很好的滿足當前系統的需求,我們需要更靈活的 按需加載 的編譯支持,並且希望這個支持是不需要開發干涉的。反觀現有的編譯工具而言,其更多是在部署之前進行相關的代碼靜態分析並整體打包。如果我們需要更靈活的 按需加載 ,那麼唯一的方式是在運行時能夠獲得當前頁面所需要的組件代碼然後整合後運行,如圖:

從圖中的邏輯我們可以看到,當頁面僅需要 ComponentA 組件時,我們僅需要查詢表中的 ComponentA 及其依賴的加載地址從而返回,然後由瀏覽器動態的完成整個加載,即可達成我們的要求。在整個過程中並不需要拉取 ComponentB 的代碼,從而減少了加載和代碼運行的耗時。

那麼我們如何拿到這個依賴關係呢?實際上對於現代的編譯工具而言,在進行編譯時期就已經產出了對應的依賴關係了。我們只需要對其進行一些加工即可滿足我們的需求。在編寫 sis 的過程中,我會選擇了 Parcel 來充當這一角色,其原因在於:

Parcel
Parcel
Parcel

Parcel 相較於 Webpack 而言,在輕、重度使用上都會更勝一籌。

其中最主要的原因是我比較喜歡的 Parcel代碼即配置 而不是 Webpack配置優先 的原則。當然你也可以使用 Webpack 拿到相關的信息進行二次加工,其做法並無太大差異。

我們拿一個簡單的 A.vue 代碼舉例,代碼如下:

使用 Parcel 拿到相關的資源依賴關係十分簡單,其代碼如下:

其中 assets 是一個 Bundler 對象,其結構經過必要簡化僅保留我們關心的數據後,大致如下(如需要更詳細和準確的結構信息,請參考 Parcel 文檔):

現在我們已經拿到了對應的依賴關係,接下來需要的工作就是將此樹形結構的依賴轉換成我們所期望的樣子:

實際上,對於 sis 而言,其處理過程包含 5 個階段,其分別是:

Parcel
AMD

在這裏我們僅介紹 5 個階段中的 4 個,而不對耦合具體業務需求的 checker 進行介紹。同時我們也主要以介紹思路爲主,而簡化了大部分的實現,實際上 sis 處理了非常多繁瑣的 Corner Case 來保證編譯的正確性。

簡化資源結構

首先,爲了調試的簡便性,我們先將 Parcel 嵌套的 Bundler 對象簡化爲 JSON 數據,其代碼如下:

經過此函數的處理,我們將 Parcel 的 Bundler 對象嵌套簡化成了 JSON 數據,其結果爲:

需要注意的是,這一步並不是必須的,如果爲了編譯時的性能,我們可以直接針對嵌套的 Bundler 對象進行後續處理。

展平資源結構

爲什麼需要將樹形結構進行展平?其中的原因很簡單的,就是爲了後續更容易分析。在之後的 optimize 階段我們需要大量的在依賴對象中進行跳轉和改寫,對樹形結構展平,性能會更好,同時更容易達成這一目標。

將樹形結構進行展平的代碼很容易,代碼如下:

此處 getVersion 函數作用是獲取到當前依賴的版本號,其根據文件所在路徑向上查找最近的 package.json 文件中的 version 字段獲得。而 md5 函數是將對應的 generated 字段進行 JSON.stringifymd5 ,然後返回一個 7 位長度的字符串。

通過此函數我們可以將對應的樹形結構成功進行展平,其結果如下:

一切準備就緒,我們可以進入 sis 最有樂趣的 optimize 階段了!

依賴的合併優化

回顧我們上面的講述,爲了保證可視化系統 按需加載 ,我們依靠 Parcel 輸出了依賴的 JSON。從示例上我們可以看到,如果需要加載 A.vue ,那麼我們只需要掃描整個對象,拿到 A.vuelodashBuffer 等代碼的訪問路徑就行了。這看起來相當完美,但在實際應用中,這仍然遠遠不夠。

我們知道對於瀏覽器來說,其靜態資源的併發請求是存在限制的,在日常開發中我們並不會有這麼簡單的依賴關係。假如我們將 ElementUI 引入到 A 組件中然後編譯,我們會發現,整個 JSON 文件有 230 多項依賴信息,即使我們在 A 組件中僅僅添加入一個 ElementUIButton 組件,其所需要動態加載的文件數量就高達 80 多個!這顯然不是我們想要的結果。針對這個問題我們會很自然地想到 合併 ,現在的問題便會轉化爲:“我們如何知道哪些模塊需要被合併呢?”。

假設我們有一個較爲複雜的依賴關係,如圖所示:

其中包含循環引用(G-F-E-G)以及自引用(E-E),那麼我們怎麼確定該合併哪些模塊呢?當然,可能有讀者會說:“ 你這個圖錯了,實際開發中不可能存在循環引用和自引用的!”。但事實上這在靜態分析中是經常會發生的事情。

例如,當我們針對上面的簡單示例使用 Parcel 編譯後我們會發現, Buffer 模塊竟然自引用了自己!究其原因是因爲 Parcel 爲了統一 Browser 和 Node 端的代碼而添加了 polyfill 代碼。在編譯時 Parcel 發現 lodash 引用的 Buffer 庫中有這麼一代碼時:

就幫我們引入了 Bufferpolyfill 模塊,從而造成了自引用。而至於循環引用,當我們編寫了類似的如下代碼:

不難發現,此中存在循環依賴爲:C-B-A-C,但當我們使用 Node 執行此代碼後會發現其能很正確的進行執行並輸出:

從靜態分析的角度來說,類似的代碼在我們的依賴分析中很容易就會形成自引用與循環引用。針對以上問題,我們首先需要破壞依賴表中的自引用和循環引用(至於爲什麼這樣做能夠仍然保證正確的運行,就留給讀者自行思考了)。其代碼如下:

執行後,我們就進一步得到了如下的依賴關係:

根據上圖稍加分析我們可以知道,在這裏的依賴關係中,F 節點實際上可以與 G 節點合併生成 G+F 節點,完成一次合併。其合併的條件爲 F 的 入度爲 1 ,更簡單的說法是,F 的父親節點唯一。因此我們可以得到第一次合併的結果:

按照此規律我們依次進行合併,其過程如下:

  1. 將 G+F 節點與 C 節點合併(G+F 節點入度爲 1)

  1. 將 G+F+C 節點與 E 節點合併(E 節點入度爲 1)

  1. 將 G+F+C+D 節點與 E 節點合併,合併結束。

通過這個算法,我們可以將引用數爲 1 的模塊儘可能合併,以此減少靜態資源加載所需的請求數。最終我們的 JSON 將會形成如下的形式:

其中我們對於每個合併的單節點新增了 pkg 字段,同時也形成了一個 __pkg0 節點記錄了所有引用的模塊。我們再次對相同的 ElementUI 項目進行測試,發現其依賴項從 230 項減少到了 51 項,大部分模塊都被正確的合併了。

但與此同時我們也發現還有少部分模塊因爲被多個組件公用,從而被獨立劃分出來。當然此類文件也有很多顯著的特點,例如:文件體積不大(大部分集中在 1K 以下),並且大部分都是非常基礎的功能代碼實現(如 merge/filter/map 等邏輯處理)。針對這些小文件,我們仍然可以在分析過程中再次進行合併。但在實際的 sis 實現中並沒有這麼做,而是選擇使用 Combo 服務幫助我們完成了這部分功能,相關內容我們在《架構篇》會講述。

給資源加上模塊系統

Parcel 提供給我們的數據中,其 generated 包含了已經過 babel 等編譯過的具體代碼,其結構大致如下:

針對這些轉移的代碼,我們需要給其加上 AMD 的相關模塊外層代碼,例如:

同時,我們可以自行編寫一個 40 行的 AMD 實現來完成模塊的定義執行(當然你也可以使用開源的實現),其大體實現如下:

至此,所有模塊內容的分析和改寫工作就已完成,我們只需要根據 JSON 依賴表將各項代碼輸出到文件就完成了整個開發工具的工作。與此同時,JSON 依賴表也應被同時輸出,供我們之後的服務端和瀏覽器端進行運行時分析並提供加載依據。

更多功能

sis 作爲一個開發工具,除了進行最終編譯功能外,還應該增加一些方便開發的功能,如圖:

這些功能除了加快初始化項目的創建外,還沉澱了一些項目的 最佳實踐 以便幫助新人能夠更快的融入到項目開發之中。

總結與期待

在本篇中,我們詳細講述了項目的背景及對應的目標,並分析瞭如何依靠依賴表的方式優化對應的加載性能以及達到 SSR 目標中的動態化。當然整個過程我們主要是以講述思想爲主,拋開了非常多繁瑣複雜的相關工作細節。

在下一篇文章中我們會繼續詳細討論如何使用依賴表來進行 SSR 的 按需加載 的動態化,同時對 sis-ssr 和目前傳統實現的 SSR 服務進行相關壓力測試並分析相關的測試結果,從而清楚此種方式在單機性能上的邊界。當然我們也對 sis-ssr 在可靠性上的提升做更多的介紹。敬請期待 《深入淺出動態化 SSR 服務(二) :SSR 服務篇》。

相關文章