前端工程與效能優化

發表於2015-09-05

每個參與過開發企業級web應用的前端工程師或許都曾思考過前端效能優化方面的問題。我們有雅虎14條效能優化原則,還有兩本很經典的效能優化指導書:《高效能網站建設指南》、《高效能網站建設進階指南》。經驗豐富的工程師對於前端效能優化方法耳濡目染,基本都能一一列舉出來。這些效能優化原則大概是在7年前提出的,對於web效能優化至今都有非常重要的指導意義。

然而,對於構建大型web應用的團隊來說,要堅持貫徹這些優化原則並不是一件十分容易的事。因為優化原則中很多要求是與工程管理相違背的,比如 把css放在頭部 和 把js放在尾部 這兩條原則,我們不能讓團隊的工程師在寫樣式和指令碼引用的時候都去修改一個相同的頁面檔案。這樣做會嚴重影響團隊成員間並行開發的效率,尤其是在團隊有版本管理的情況下,每天要花大量的時間進行程式碼修改合併,這項成本是難以接受的。因此在前端工程界,總會看到週期性的效能優化工作,辛勤的前端工程師們每到月圓之夜就會傾巢出動根據優化原則做一次效能優化。

效能優化是一個工程問題

本文將從一個全新的視角來思考web效能優化與前端工程之間的關係,揭示前端效能優化在前端架構及開發工具設計層面的實現思路。

效能優化原則及分類

po主先假設本文的讀者是有前端開發經驗的工程師,並對企業級web應用開發及效能優化有一定的思考,因此我不會重複介紹雅虎14條效能優化原則。如果您沒有這些前續知識,請移步 這裡 來學習。

首先,我們把雅虎14條優化原則,《高效能網站建設指南》以及《高效能網站建設進階指南》中提到的優化點做一次梳理,按照優化方向分類,可以得到這樣一張表格:

優化方向 優化手段
請求數量 合併指令碼和樣式表,CSS Sprites,拆分初始化負載,劃分主域
請求頻寬 開啟GZip,精簡JavaScript,移除重複指令碼,影象優化
快取利用 使用CDN,使用外部JavaScript和CSS,新增Expires頭,
減少DNS查詢,配置ETag,使AjaX可快取
頁面結構 將樣式表放在頂部,將指令碼放在底部,儘早重新整理文件的輸出
程式碼校驗 避免CSS表示式,避免重定向

目前大多數前端團隊可以利用 yui compressor 或者 google closure compiler 等壓縮工具很容易做到 精簡Javascript 這條原則;同樣的,也可以使用圖片壓縮工具對影象進行壓縮,實現 影象優化 原則。這兩條原則是對單個資源的處理,因此不會引起任何工程方面的問題。很多團隊也通過引入程式碼校驗流程來確保實現避免css表示式 和 避免重定向 原則。目前絕大多數網際網路公司也已經開啟了服務端的Gzip壓縮,並使用CDN實現靜態資源的快取和快速訪問;一些技術實力雄厚的前端團隊甚至研發出了自動CSS Sprites工具,解決了CSS Sprites在工程維護方面的難題。使用“查詢-替換”思路,我們似乎也可以很好的實現 劃分主域 原則。

我們把以上這些已經成熟應用到實際生產中的優化手段去除掉,留下那些還沒有很好實現的優化原則。再來回顧一下之前的效能優化分類:

優化方向 優化手段
請求數量 合併指令碼和樣式表,拆分初始化負載
請求頻寬 移除重複指令碼
快取利用 新增Expires頭,配置ETag,使Ajax可快取
頁面結構 將樣式表放在頂部,將指令碼放在底部,儘早重新整理文件的輸出

有很多頂尖的前端團隊可以將上述還剩下的優化原則也都一一解決,但業界大多數團隊都還沒能很好的解決這些問題。因此,本文將就這些原則的解決方案做進一步的分析與講解,從而為那些還沒有進入前端工業化開發的團隊提供一些基礎技術建設意見,也藉此機會與業界頂尖的前端團隊在工業化工程化方向上交流一下彼此的心得。

靜態資源版本更新與快取

快取利用 分類中保留了 新增Expires頭 和 配置ETag 兩項。或許有些人會質疑,明明這兩項只要配置了伺服器的相關選項就可以實現,為什麼說它們難以解決呢?確實,開啟這兩項很容易,但開啟了快取後,我們的專案就開始面臨另一個挑戰: 如何更新這些快取?

相信大多數團隊也找到了類似的答案,它和《高效能網站建設指南》關於“新增Expires頭”所說的原則一樣——修訂檔名。即:

最有效的解決方案是修改其所有連結,這樣,全新的請求將從原始伺服器下載最新的內容。

思路沒錯,但要怎麼改變連結呢?變成什麼樣的連結才能有效更新快取,又能最大限度避免那些沒有修改過的檔案快取不失效呢?

先來看看現在一般前端團隊的做法:

ps: 也有團隊採用構建版本號為靜態資源請求新增query,它們在本質上是沒有區別的,在此就不贅述了。

接下來,專案升級,比如頁面上的html結構發生變化,對應還要修改 a.js 這個檔案,得到的構建結果如下:

 

為了觸發使用者瀏覽器的快取更新,我們需要更改靜態資源的url地址,如果採用構建資訊(時間戳、版本號等)作為url修改的依據,如上述程式碼所示,我們只修改了一個a.js檔案,但再次構建會讓所有請求都更改了url地址,使用者再度訪問頁面那些沒有修改過的靜態資源的(b.js,b.js,c.js,d.js,e.js)的瀏覽器快取也一同失效了。

使用構建資訊作為靜態資源更新標記會導致每次構建釋出後所有靜態資源都被迫更新,瀏覽器快取利用率降低,給效能帶來傷害。

此外,採用新增query的方式來清除快取還有一個弊端,就是 覆蓋式釋出 的上線問題。

採用query更新快取的方式實際上要覆蓋線上檔案的,index.html和a.js總有一個先後的順序,從而中間出現一段或大或小的時間間隔。尤其是當頁面是後端渲染的模板的時候,靜態資源和模板是部署在不同的機器叢集上的,上線的過程中,靜態資源和頁面檔案的部署時間間隔可能會非常長,對於一個大型網際網路應用來說即使在一個很小的時間間隔內,都有可能出現新使用者訪問。在這個時間間隔中,訪問了網站的使用者會發生什麼情況呢?

  1. 如果先覆蓋index.html,後覆蓋a.js,使用者在這個時間間隙訪問,會得到新的index.html配合舊的a.js的情況,從而出現錯誤的頁面。
  2. 如果先覆蓋a.js,後覆蓋index.html,使用者在這個間隙訪問,會得到舊的index.html配合新的a.js的情況,從而也出現了錯誤的頁面。

這就是為什麼大型web應用在版本上線的過程中經常會較集中的出現前端報錯日誌的原因,也是一些網際網路公司選擇加班到半夜等待訪問低峰期再上線的原因之一。

對於靜態資源快取更新的問題,目前來說最優方案就是 基於檔案內容的hash版本冗餘機制 了。也就是說,我們希望專案原始碼是這麼寫的:

釋出後程式碼變成

也就是a.js釋出出來後被修改了檔名,產生一個新檔案,並不是覆蓋已有檔案。其中”_82244e91”這串字元是根據a.js的檔案內容進行hash運算得到的,只有檔案內容發生變化了才會有更改。由於將檔案釋出為帶有hash的新檔案,而不是同名檔案覆蓋,因此不會出現上述說的那些問題。同時,這麼做還有其他的好處:

  1. 上線的a.js不是同名檔案覆蓋,而是檔名+hash的冗餘,所以可以先上線靜態資源,再上線html頁面,不存在間隙問題;
  2. 遇到問題回滾版本的時候,無需回滾a.js,只須回滾頁面即可;
  3. 由於靜態資源版本號是檔案內容的hash,因此所有靜態資源可以開啟永久強快取,只有更新了內容的檔案才會快取失效,快取利用率大增;

以檔案內容的hash值為依據生產新檔案的非覆蓋式釋出策略是解決靜態資源快取更新最有效的手段。

雖然這種方案是相比之下最完美的解決方案,但它無法通過手工的形式來維護,因為要依靠手工的形式來計算和替換hash值,並生成相應的檔案,將是一項非常繁瑣且容易出錯的工作,因此我們需要藉助工具來處理。

用grunt來實現md5功能是非常困難的,因為grunt只是一個task管理器,而md5計算需要構建工具具有遞迴編譯的能,而不是簡單的任務排程。考慮這樣的例子:

由於我們的資源版本號是通過對檔案內容進行hash運算得到,如上圖所示,index.html中引用的a.css檔案的內容其實也包含了a.png的hash運算結果,因此我們在修改index.html中a.css的引用時,不能直接計算a.css的內容hash,而是要先計算出a.png的內容hash,替換a.css中的引用,得到了a.css的最終內容,再做hash運算,最後替換index.html中的引用。

計算index.html中引用的a.css檔案的url過程:
1. 壓縮a.png後計算其內容的md5值
2. 將a.png的md5寫入a.css,再壓縮a.css,計算其內容的md5值
3. 將a.css的md5值寫入到index.html中

grunt等task-based的工具是很難在task之間協作處理這樣的需求的。

在解決了基於內容hash的版本更新問題之後,我們可以將所有前端靜態資源開啟永久強快取,每次版本釋出都可以首先讓靜態資源全量上線,再進一步上線模板或者頁面檔案,再也不用擔心各種快取和時間間隙的問題了!

靜態資源管理與模組化框架

解決了靜態資源快取問題之後,讓我們再來看看前面的優化原則表還剩些什麼:

優化方向 優化手段
請求數量 合併指令碼和樣式表,拆分初始化負載
請求頻寬 移除重複指令碼
快取利用 使Ajax可快取
頁面結構 將樣式表放在頂部,將指令碼放在底部,儘早重新整理文件的輸出

很不幸,剩下的優化原則都不是使用工具就能很好實現的。或許有人會辯駁:“我用某某工具可以實現指令碼和樣式表合併”。嗯,必須承認,使用工具進行資源合併並替換引用或許是一個不錯的辦法,但在大型web應用,這種方式有一些非常嚴重的缺陷,來看一個很熟悉的例子 :

某個web產品頁面有A、B、C三個資源

工程師根據“減少HTTP請求”的優化原則合併了資源

產品經理要求C模組按需出現,此時C資源已出現多餘的可能

C模組不再需要了,註釋掉吧!程式碼1秒鐘搞定,但C資源通常不敢輕易剔除

不知不覺中,效能優化變成了效能惡化……

這個例子來自 Facebook靜態網頁資源的管理和優化@Velocity China 2010

事實上,使用工具線上下進行靜態資源合併是無法解決資源按需載入的問題的。如果解決不了按需載入,則必會導致資源的冗餘;此外,線下通過工具實現的資源合併通常會使得資源載入和使用的分離,比如在頁面頭部或配置檔案中寫資源引用及合併資訊,而用到這些資源的html元件寫在了頁面其他地方,這種書寫方式在工程上非常容易引起維護不同步的問題,導致使用資源的程式碼刪除了,引用資源的程式碼卻還在的情況。因此,在工業上要實現資源合併至少要滿足如下需求:

  1. 確實能減少HTTP請求,這是基本要求(合併)
  2. 在使用資源的地方引用資源(就近依賴),不使用不載入(按需)
  3. 雖然資源引用不是集中書寫的,但資源引用的程式碼最終還能出現在頁面頭部(css)或尾部(js)
  4. 能夠避免重複載入資源(去重)

將以上要求綜合考慮,不難發現,單純依靠前端技術或者工具處理是很難達到這些理想要求的。

接下來我會講述一種新的模板架構設計,用以實現前面說到那些效能優化原則,同時滿足工程開發和維護的需要,這種架構設計的核心思想就是:

基於依賴關係表的靜態資源管理系統與模組化框架設計

考慮一段這樣的頁面程式碼:

根據資源合併需求中的第二項,我們希望資源引用與使用能儘量靠近,這樣將來維護起來會更容易一些,因此,理想的原始碼是:

當然,把這樣的頁面直接送達給瀏覽器使用者是會有嚴重的頁面閃爍問題的,所以我們實際上仍然希望最終頁面輸出的結果還是如最開始的截圖一樣,將css放在頭部輸出。這就意味著,頁面結構需要有一些調整,並且有能力收集資源載入需求,那麼我們考慮一下這樣的原始碼(以php為例):

 

在頁面的頭部插入一個html註釋 <!--[CSS LINKS PLACEHOLDER]--> 作為佔位,而將原來字面書寫的資源引用改成模板介面 require_static 呼叫,該介面負責收集頁面所需資源。

require_static介面實現非常簡單,就是準備一個陣列,收集資源引用,並且可以去重。最後在頁面輸出的前一刻,我們將require_static在執行時收集到的 a.cssb.cssc.css 三個資源拼接成html標籤,替換掉註釋佔位 <!--[CSS LINKS PLACEHOLDER]-->,從而得到我們需要的頁面結構。

經過實踐總結,可以發現模板層面只要實現三個開發介面,就可以比較完美的實現目前遺留的大部分效能優化原則,這三個介面分別是:

  1. require_static(res_id):收集資源載入需求的介面,引數是靜態資源id。
  2. load_widget(wiget_id):載入拆分成小元件模板的介面。你可以叫它為widget、component或者pagelet之類的。總之,我們需要一個介面把一個大的頁面模板拆分成一個個的小部分來維護,最後在原來的頁面中以元件為單位來載入這些小部件。
  3. script(code):收集寫在模板中的js指令碼,使之出現的頁面底部,從而實現效能優化原則中的 將js放在頁面底部 原則。

實現了這些介面之後,一個重構後的模板頁面的原始碼可能看起來就是這樣的了:

而最終在模板解析的過程中,資源收集與去重、頁面script收集、佔位符替換操作,最終從服務端傳送出來的html程式碼為:

 

不難看出,我們目前已經實現了 按需載入將指令碼放在底部將樣式表放在頭部 三項優化原則。

前面講到靜態資源在上線後需要新增hash戳作為版本標識,那麼這種使用模板語言來收集的靜態資源該如何實現這項功能呢?

答案是:靜態資源依賴關係表。

考慮這樣的目錄結構:

如果我們可以使用工具掃描整個project目錄,然後建立一張資源表,同時記錄每個資源的部署路徑,得到這樣的一張表:

基於這張表,我們就很容易實現 require_static(file_id)load_widget(widget_id) 這兩個模板介面了。以load_widget為例:

利用查表來解決md5戳的問題,這樣,我們的頁面最終送達給使用者的結果就是這樣的:

接下來,我們討論基於表的設計思想上是如何實現靜態資源合併的。或許有些團隊使用過combo服務,也就是我們在最終拼接生成頁面資源引用的時候,並不是生成多個獨立的link標籤,而是將資源地址拼接成一個url路徑,請求一種線上的動態資源合併服務,從而實現減少HTTP請求的需求,比如前面的例子,稍作調整即可得到這樣的結果:

 

這個 /??file1,file2,file3,… 的url請求響應就是動態combo服務提供的,它的原理很簡單,就是根據url找到對應的多個檔案,合併成一個檔案來響應請求,並將其快取,以加快訪問速度。

這種方法很巧妙,有些伺服器甚至直接整合了這類模組來方便的開啟此項服務,這種做法也是大多數大型web應用的資源合併做法。但它也存在一些缺陷:

  1. 瀏覽器有url長度限制,因此不能無限制的合併資源。
  2. 如果使用者在網站內有公共資源的兩個頁面間跳轉訪問,由於兩個頁面的combo的url不一樣導致使用者不能利用瀏覽器快取來加快對公共資源的訪問速度。
  3. 如果combo的url中任何一個檔案發生改變,都會導致整個url快取失效,從而導致瀏覽器快取利用率降低。

對於上述第二條缺陷,可以舉個例子來看說明:

  • 假設網站有兩個頁面A和B
  • A頁面使用了a,b,c,d四個資源
  • B頁面使用了a,b,e,f四個資源
  • 如果使用combo服務,我們會得:
    • A頁面的資源引用為:/??a,b,c,d
    • B頁面的資源引用為:/??a,b,e,f
  • 兩個頁面引用的資源是不同的url,因此瀏覽器會請求兩個合併後的資原始檔,跨頁面訪問沒能很好的利用a、b這兩個資源的快取。

很明顯,如果combo服務能聰明的知道A頁面使用的資源引用為 /??a,b 和 /??c,d,而B頁面使用的資源引用為 /??a,b 和 /??e,f就好了。這樣當使用者在訪問A頁面之後再訪問B頁面時,只需要下載B頁面的第二個combo檔案即可,第一個檔案已經在訪問A頁面時快取好了的。

基於這樣的思考,我們在資源表上新增了一個欄位,取名為 pkg,就是資源合併生成的新資源,表的結構會變成:

 

相比之前的表,可以看到新表中多了一個pkg欄位,並且記錄了打包後的檔案所包含的獨立資源。這樣,我們重新設計一下 require_static、load_widget 這兩個模板介面,實現這樣的邏輯:

在查表的時候,如果一個靜態資源有pkg欄位,那麼就去載入pkg欄位所指向的打包檔案,否則載入資源本身。

比如執行require_static('bootstrap.js'),查表得知bootstrap.js被打包在了p1中,因此取出p1包的url/pkg/lib_cef213d.js,並且記錄頁面已載入了 jquery.js 和 bootstrap.js 兩個資源。這樣一來,之前的模板程式碼執行之後得到的html就變成了:

 

雖然這種策略請求有4個,不如combo形式的請求少,但可能在統計上是效能更好的方案。由於兩個lib打包的檔案修改的可能性很小,因此這兩個請求的快取利用率會非常高,每次專案釋出後,使用者需要重新下載的靜態資源可能要比combo請求節省很多頻寬。

效能優化既是一個工程問題,又是一個統計問題。優化效能時如果只關注一個頁面的首次載入是很片面的。還應該考慮全站頁面間跳轉、專案迭代後更新資源等情況下的優化策略。

此時,我們又引入了一個新的問題:如何決定哪些檔案被打包?

從經驗來看,專案初期可以採用人工配置的方式來指定打包情況,比如:

但隨著系統規模的增大,人工配置會帶來非常高的維護成本,此時需要一個輔助系統,通過分析線上訪問日誌和靜態資源組合載入情況來自動生成這份配置檔案,系統設計如圖:

至此,我們通過基於表的靜態資源管理系統和三個模板介面實現了幾個重要的效能優化原則,現在我們再來回顧一下前面的效能優化原則分類表,剔除掉已經做到了的,看看還剩下哪些沒做到的:

優化方向 優化手段
請求數量 拆分初始化負載
快取利用 使Ajax可快取
頁面結構 儘早重新整理文件的輸出

拆分初始化負載 的目標是將頁面一開始載入時不需要執行的資源從所有資源中分離出來,等到需要的時候再載入。工程師通常沒有耐心去區分資源的分類情況,但我們可以利用元件化框架介面來幫助工程師管理資源的使用。還是從例子開始思考,如果我們有一個js檔案是使用者互動後才需要載入的,會怎樣呢:

 

很明顯,dialog.js 這個檔案我們不需要在初始化的時候就載入,因此它應該在後續的互動中再載入,但檔案都加了md5戳,我們如何能在瀏覽器環境中知道載入的url呢?

答案就是:把靜態資源表的一部分輸出在頁面上,供前端模組化框架載入靜態資源。

我就不多解釋程式碼的執行過程了,大家看到完整的html輸出就能理解是怎麼回事了:

 

dialog.js不會在頁面以script src的形式輸出,而是變成了資源註冊,這樣,當頁面點選觸發require.async執行的時候,async函式才會查表找到資源的url並載入它,載入完畢後觸發回撥函式。

以上框架示例我實現了一個java-jsp版的,有興趣的同學請看這裡:https://github.com/fouber/fis-java-jsp

到目前為止,我們又以架構的形式實現了一項優化原則(拆分初始化負載),回顧我們的優化分類表,現在僅有兩項沒能做到了:

優化方向 優化手段
快取利用 使Ajax可快取
頁面結構 儘早重新整理文件的輸出

剩下的兩項優化原則要做到並不容易,真正可快取的Ajax在現實開發中比較少見,而 儘早重新整理文件的輸出 原則facebook在2010年的velocity上 提到過,就是BigPipe技術。當時facebook團隊還講到了Quickling和PageCache兩項技術,其中的PageCache算是比較徹底的實現Ajax可快取的優化原則了。由於篇幅關係,就不在此展開了,後續還會撰文詳細解讀這兩項技術。

總結

其實在前端開發工程管理領域還有很多細節值得探索和挖掘,提升前端團隊生產力水平並不是一句空話,它需要我們能對前端開發及程式碼執行有更深刻的認識,對效能優化原則有更細緻的分析與研究。在前端工業化開發的所有環節均有可節省的人力成本,這些成本非常可觀,相信現在很多大型網際網路公司也都有了這樣的共識。

本文只是將這個領域中很小的一部分知識的展開討論,拋磚引玉,希望能為業界相關領域的工作者提供一些不一樣的思路。

相關文章