精益求精怎麼讀
前提
為了不被噴得太慘,給標題加了這麼多的限制定語也是相當不容易的了。此文討論的是我所處的環境下對JavaScript構建的一些簡單探索,因此有相當多的前提限制。
首先,何為大型。從我們的系統來看,20 多個業務模組,近 100 個頁面組成的單頁系統,對應的業務原始碼程式碼量如下:
對應的依賴庫,除
underscore
和
moment
外均為公司內部庫,程式碼量為:
其次,所謂的“模組化”指我們使用AMD進行構建,使用符合社群AMD標準的Loader進行模組的載入。
而“PC 端單頁式商業內容管理系統”則代表著系統的不少特性:
使用是相對強制的,對使用者來說這是一項工作,而不是愛用不用的使用者產品。
商業公司通常擁有較好的網路環境,面向PC設計更使得頻寬不是一個需要著重考慮的因素。
單頁系統使得所有功能被包含在一個HTML頁面內,不存在頁間的跳轉,因此資源不以頁面為單位進行切分。
為何要構建
第一個問題是,AMD 有自然的按需載入的屬性,按需載入也是一直被提倡的一種模式。那麼,如果不進行任何的構建,讓模組自然地按需載入,是否可行?
如果看了這個圖,你還相信按需載入的話,可以停止此文的閱讀了:
簡單來說,按需載入與構建並不衝突,我們不能將所有資源最細粒度地使用按需載入進行管理,必要的構建來減少資源請求是必要的。
隨之而來的,我們會考慮標準的程式碼合併方案。相當多的站點會將所有的JavaScript合併為一個檔案,這也是最簡單粗暴有效的方案。
但是對於大型的單頁系統而言,所有JavaScript合併後生成的檔案會非常之巨大,其體積在瀏覽器單執行緒的下載模式下已經成為系統的效能瓶頸。因此我們需要一些更好的策略,讓系統的啟動效能得以最佳化。
最後,常用於業界的還有一種方案,即自動化的執行時合併。透過在伺服器端配置一個處理程式,可以執行時檢測需要檔案的依賴,進行依賴打包並響應至客戶端。
這種方案有其成本小、透明化等多方面的優勢,但在精益求精的場景下仍舊略有不足。其最大的缺點是當有2個以上模組依賴同一個模組時,被依賴模組可能會被重複打包到多份
。js
檔案中,造成不必要的網路傳輸。
當然有很多的方法解決這一問題,諸如在Session中記錄使用者已經擁有的模組,或由客戶端記錄並提供已有模組列表,來保證打包過程不會加入無用的模組。但這些方法會提升一定的開發成本,同時前後端合作才可以完成的方案往往在推進上會遇到一些小阻礙。
基於這些原因,從前端靜態化的構建入手,在構建階段實現較為最佳化的打包方案,是現階段我們採取的策略。
準則
從系統執行時來分析,對於JavaScript的構建,可以提出以下的原則。
控制請求段的數量
“請求段”是一個很模糊的概念,簡單來說,在一個頻寬足夠的環境下,我們並不看重執行時產生了多少個請求,而是看重這些請求在瀑布圖中被分為幾段。由於瀏覽器並行載入的特性,系統真正的可用時間是由段的數量和每一個段的時間來決定的。
對於常見的瀏覽器,其並行載入的請求個數為4-6個,也即一個段可以加入4-6個的請求。從分段越少越好的角度來考慮,我們規劃的系統啟動分為3個段:
載入必要的前置條件,其中最為主要的是AMD Loader。
。css
檔案可以在這個階段載入,以避免影響後面更重量級的
。js
的載入效率。
主要的JavaScript模組的載入,在此段透過對並行數的控制,期望在一個段內載入完所有必要的模組。同時從
段的用時 = 段內載入總大小 / 併發數
這一公式考慮,應儘可能將模組打包為瀏覽器可接受的最大併發數個檔案。
一些動態的資訊,如使用者登入資訊、系統常量表等,這些資訊是易變或動態的,因此從快取的角度考慮不適合進行打包。
從我們的系統來看,僅看JavaScript的資源,很明顯地分為3段進行載入(紅線分隔):
檔案大小
從經驗值來看,單個資源的大小盡量控制在未Gzip前500KB以內,過大的檔案會成為瓶頸,除非你可以很好地規劃一個HTTP請求段,使得一個大檔案載入的用時內瀏覽器會利用另一個TCP連結載入多個小檔案。
Gzip對純文字檔案的壓縮率一般在16%左右,檔案的形式和內容不會對此比率造成特別大的影響。如果使用Linux或OSX系統,可以簡單地使用以下命令來看一個檔案Gzip後的近似大小:
gzip -c {file} | wc -c
同時,由於短板效應的存在,一個段的載入時間會由這個段內最大的資源決定,因此儘量使得各資源的大小相近。
請求控制
並不是所有的檔案都需要合併在一起,也不是所有資源都可以按需載入,對於請求數量的控制並不僅僅體現在系統啟動時,也貫穿整個系統的使用流程,來提供使用者一致的效能體驗。
我對一個大型CMS系統的請求數量的總結可以概括為3點:
在導航透過一次操作可到達的頁面內,不應該產生額外的請求,即系統啟動過程中這些模組都應當就位。
在一級頁面中進行下探才可以到達的頁面,儘量控制一個頁面僅產生一個請求。考慮到單個頁面的資源不會很大,因此再行拆分並行載入反而可能因為TCP連結、網路延遲等因素有負面的效果。
對於特別大的頁面,或者頁面中一定條件下才會訪問的區域,相關資源使用按需載入的策略配置。
基於以上的這些原則,我在系統中有進一步的實踐。
實踐
檔案合併
將JavaScript檔案分為多個“啟動指令碼”,一個啟動指令碼中會包含一系列的模組,現有系統中我將啟動指令碼分割為4個,分別為:
特別大的庫單獨擁有自己的一個指令碼,比如
ECharts
之類的圖表庫。
UI控制元件庫,包含基礎UI控制元件和業務UI控制元件,合併為一個指令碼。
MVC框架、頁面基類、工具類、系統通用功能層等業務無關的邏輯合併為一個指令碼。
一級頁面的業務模組合併為一個指令碼。
需要特別注意的是,各啟動指令碼間應該保持完美的正交,即不應該有任何一個模組被重複合併到多個指令碼中。
除了啟動指令碼外,前面也有提到單一頁面應該儘可能只加載一個資源,因此頁面相關會被打包在一起,比較典型的是將Controller、Model和View合併到Controller對應的檔案中。
由於二級頁面並不能確定哪一個會被先訪問,因此各頁面打包檔案中是會存在一定的模組重複的,經典如
util
模組就會同時被列表、表單、只讀等頁使用。這會導致載入多個頁面時部分資源被重複載入,但是此類資源通常體積很小,產生的副作用在可控範圍內。
檔案載入
在檔案載入這一方向上,需要有一個特別的處理。由於AMD的依賴管理和執行時依賴分析功能,透過Loader的
require
函式載入一個模組的化,Loader會自動分析依賴並透過零碎的HTTP請求去請求相關的資源,而無視這些資源是否可能被下一個指令碼打包在一起。
用一個例項來說明,我們的依賴關係為
a -> b
以及
c -> b
,即
b
模組是一個通用模組,被兩邊所依賴。當我們將
a
和
c
分開打包為2個檔案時,
b
會出現在其中一箇中(為了實現完美正交)。假設
b
被打包在
a。js
中,那麼當
c。js
被Loader載入時,如果
a。js
還未就位,就會產生一個單獨的HTTP請求
b。js
。由於並行下載時,誰先完成是不可預知的,就有很大的可能性產生無意義的零碎請求。
解決這一問題的方法是,使用
標籤來引入這些打過包的指令碼,而不要讓Loader去做載入。由於
標籤的執行過程中並不會有依賴分析,也就不會產生額外的請求。
需要注意的是,能夠使用
標籤來實現這一功能,需要對應的Loader有延遲執行AMD模組的
factory
函式的功能,即遵循CMD規範。現有業界
RequireJS
和百度的
esl
都有這一功能,可放心使用。
硬編碼預載入
除去打包這一比較常規的步驟之上的微創新外,前端在資源載入方向上還有一個很重要的方向就是“預載入”。所謂預載入,即在使用者尚未使用某一個功能的時候預先載入相關的資源並快取,便於使用者使用時能夠快速獲得資源。預載入的探索上,有幾個指標至關重要:
載入的時機。在保證使用者需要時已經載入完畢的前提下,越晚載入越有利於系統整體的效能。
載入的精度。預載入一個使用者最終都沒有用到的資源是一種浪費的行為。
從這兩點出發,預載入可以衍生出相當多的子話題,包括:
使用者行為預測,如使用者停留在某一個列表頁面時時,根據其以往的行為進行分析,很有可能進入新建的操作。
臨近載入,如計算使用者的滑鼠軌跡來預測後續可能點選的按鈕等。
空閒載入,透過統一的請求管理,尋找網路空閒的時候來載入需要的資源,從而不影響急需的資源的載入進度。
由於在這一塊還沒有深入的探索和相關的產出,因此不再贅述。
版本管理
在構建完成後,由於HTTP快取的特性,我們希望可以達到靜態資源永久快取的前提下又可以準確地進行快取過期。在這一方向上,最為普遍的方法即使用一定的演算法為資源生成版本號,從而使得新舊資源的URL不同,來強制瀏覽器載入新的資源。
在版本號的演算法選擇中,MD5大致是當前最優的方案。但由於MD5的計算和HTML中資源連結的替換都相對成本較高,在某種情況下使用簡單的遞增或SVN Revision作為版本號都是可以考慮的。這些相關的話題都有過很深入的討論,也不作為本文詳細描述的重點。
在版本號控制快取的規劃之中,需要注意的是對指令碼的“變化頻率”進行預測,我們應當儘可能地將下次變化時間相同/相近的資源打包在一起,以避免因為一個資源變化導致大量資源要同時失去快取的場景出現。
在這種控制上,簡單的策略有2種:
透過分層設計,將系統自下而上,自基礎至業務分為多層,越接近下層(基礎)的程式碼變動理應越少,而越接近上層(業務)的程式碼則理應有更頻繁的變動。透過分層可以簡單地區分這些,上文提到的指令碼分割策略也一定程度上基於這一理論。
對於易變的內容,可以進一步透過策略等模式,抽取出不變和可變的部分,將可變的部分打包在一起的同時,提供一些介面來允許覆蓋這些“可變數”。隨後透過一種“灰度指令碼”,即臨時性執行的指令碼來覆蓋這些可變數,達到在一定時期內執行新的策略,但不至於打包的指令碼快取過期的目的。當打包指令碼確實需要過期時,則再將新策略內聯到指令碼中。
總結
由於系統的私有性,以及我們團隊使用的工具相對並不流行,因此本文也不再放出例項程式碼來了。
以上是我在系統中進行的構建相關的初步探索,得到的只是一個
靜態
的
簡單
的構建模型,遠未達到我預期中的最佳。
至於最佳的構建會是怎麼樣的,考慮到我還想用這些產出去升個職什麼的,便不再透露了,等到實際有了成果後再總結成文共享。