TL;DR
- 可以考慮基於HTTP Cache來定義打包維度,將Cache週期相同的script儘量打包在一起,最大限度利用Cache;
- 合併零散的小指令碼,避免觸發瀏覽器併發請求限制後,資源請求序列,TTFB疊加等待時間;
- 注意打包後的資源依賴與資源引入順序。
1. 引言
效能優化涵蓋的範圍非常之廣,其中包含的知識也非常繁雜。從載入效能到渲染效能、執行時效能,每個點都有非常多可以學習與實踐的知識。
優化問題包含方方面面,優化手段也依場景和具體問題而定。因此,本文並不是一個泛而全的概覽文章,而是以之前的一次對於業務產品的簡單優化(主要是DOMContentLoaded時間)為例,介紹瞭如何使用Chrome Dev Tools來分析問題,使用一些策略來縮短DOMContentLoaded的時間,提高載入速度。
2. DOMContentLoaded事件
W3C將頁面載入分為了許多階段, DOMContentLoaded(以下簡稱DCL)類似的有一些 DOM readState ,它們都會標識頁面的載入狀態與所處的階段。我們接觸最多的也就是 readState 中的 interactive、complete(或load事件)以及DCL事件
簡單瞭解一下它們。瀏覽器會基於HTML內容來構建DOM,並基於CSS構建CSSOM。兩者構建完成後,會合併為Render Tree。當DOM構建完畢後, document.readyState
狀態會變為 interactive
。
Render Tree構建完成就會進入到我們非常熟悉的 Layout –>> Paint –>> Composite 管道。
但是當頁面包含Javascript時,這個過程會有些區別。
根據HTML5 spec,由於在Javascript中可以訪問DOM,因此當瀏覽器解析頁面遇到Javascript後會阻塞 DOM 的解析;於此同時,為避免CSS與Javascript之間的競態,CSSOM的構建會阻塞 Javascript 指令碼的執行。不過有一個例外,如果將指令碼設定為async,會有一個區別,DCL的觸發不需要等待async的指令碼被執行。
也就是:
- 當瀏覽器完成對於document的解析(parse)時,文件狀態就會被標記為
interactive
。即 "DOM tree is ready"。 - 當所有普通(既不是defer也不是async)與defer的指令碼被執行,並且已經沒有任何阻塞指令碼的樣式時,瀏覽器就會觸發
DOMContentLoaded
事件。即 "CSSOM is ready"。
或者將上面的部分精簡一下:
DOM construction can’t proceed until JavaScript is executed, and JavaScript can’t proceed until CSSOM is available. [1]
3. 排查問題
下面就可以通過Chrome Dev Tools來分析問題。為了內容精簡,以下截圖取了在slow 3G 無快取模式下的訪問情況,為了保持和線上環境類似(還原瀏覽器的同源最大請求併發),在本地搭建對應的伺服器放置靜態資源。wifi情況下,各個時間點大致等比縮短8~9倍。
首先看一個整體的waterfall
在最下面可以看到 DCL 為 17.00s(slow 3G)。
p.s. 頁面load時間也很長。主要因為業務膨脹後,頁面包含過多資源,沒有使用一些懶載入與非同步渲染技術,這部分也存在很多優化空間,但由於篇幅不在本文中討論內。
頁面裡有一個很明顯的請求block了DCL —— common.js。那麼common.js是什麼呢?它其實就是專案中一些通用指令碼檔案的打包合併。
由於common.js為同步指令碼,因此等到它其下載並執行完畢後,才會觸發DCL。而與此對應的,其他各個指令碼的時間線與其有很大差距。具體來看common.js的Timing pharse,耗時11.44s,其中download花費 7.12s。
4. 分析診斷
download過長最直接的原因就是檔案太大。common.js的打包合併包含了下面的內容
'pkg/common.js': [
'static/js/bridge.js', // 業務基礎庫
'static/js/zepto.min.js', // 第三方庫
'static/js/zepto.touch.min.js', // 第三方庫
'static/js/bluebird.core.min.js', // 第三方庫
'static/js/link.interceptor.js', // 業務基礎庫
'static/js/global.js', // 業務基礎庫
'static/js/felog.js', // 業務基礎庫
'widget/utils/*.js' // 業務工具元件
]
複製程式碼
這裡,我們發現這麼打包會存在下面幾個問題:
4.1. 檔案大小
download過長最直接的原因:檔案過大。
將這些資源全部打包在一起導致common.js較大,原檔案161KB,gzip 之後為52.5KB,單點阻塞了關鍵渲染路徑。你也可以在 audits 中的Critical Request Chains部分發現common.js是瓶頸。
4.2. HTTP Cache
zepto/bluebird這種第三方庫屬於非常穩定的資源,幾乎不會改動。雖然程式碼量較多,但是通過HTTP Cache可以有效避免重複下載。同時,上線新版後,為了避免一些檔案走 HTTP Cache,我們會給靜態資源加上 md5。
然而,當這些穩定的第三方庫與一些其他檔案打包後,會因為該打包中某些檔案的區域性變動導致合併打包後的hash變化而快取失效。
例如,其中bridge.js與/utils/*.js容易隨著版本上線迭代,迭代後打包導致common的hash變化,HTTP Cache失效,zepto/bluebird等較大的資源雖然未更改,但由於打包在了一起,仍需要重新下載。每次上線新版本後,一些載入的效能資料表現都會顯著下降,其中一部分原因在於此。
5. 實施優化手段
結合上面分析的問題,可以進行一些簡單而有效的優化。
5.1. 拆包
考慮將檔案的打包合併按照檔案的更新頻率進行劃分。這樣既可以有效縮減common.js的大小,也可以基於不同型別的資源,更好利用HTTP Cache。
例如:
-
將基本不會變動的檔案打包為 lib.js,主要為一些第三方庫,這類檔案幾乎不會改動,非常穩定。
-
將專案依賴的最基礎js打包為common.js,例如本文中的global.js、link.interceptor.js,專案中的所有部分都需要它們,同時也是專案特有的,相較上一部分的lib會有一定量的開發與改動,但是更新間隔可能會有幾個版本。
-
將專案中變動較為頻繁的工具庫打包為util.js,理論上其中工具由於不作為基礎執行的依賴,是可以非同步載入的。這部分程式碼是三者之中變動最為頻繁的。
'pkg/util.js': [
'widget/utils/*.js'
],
'pkg/common.js': [
'static/js/link.interceptor.js',
'static/js/global.js',
'static/js/felog.js'
],
'pkg/lib.js': [
'static/js/zepto.min.js',
'static/js/zepto.touch.min.js',
'static/js/bluebird.core.min.js'
]
複製程式碼
5.2 Quene Delay
但是在拆分後DCL時間幾乎沒有減少。
這裡就不得不提到打包的初衷之一:減少併發。我們將common.js拆分為三個部分後,觸碰到了同域TCP連線數限制,圖中的這四個資源被chrome放入了佇列(圖中白色長條)。
Queueing. The browser queues requests when:
- There are higher priority requests.
- There are already six TCP (Chrome) connections open for this origin, which is the limit. Applies to HTTP/1.0 and HTTP/1.1 only.
- The browser is briefly allocating space in the disk cache
我們打包合併資源一定程度上也是為了減少TCP round trip,同時儘量規避同域下的請求併發數量限制。因此在common.js拆分時,也要注意不宜分得過細,否則過猶不及,忘了初衷。
從network waterfall中也很容易發現,大部分資源由於size較小,其下載時間其實非常短,耗時主要是在TTFB(Time To First Byte),可以粗略理解為在等待伺服器返回資料(圖中表現出來就是綠色較多)。所以除了打包專案依賴的lib.js/common.js/util.js外,還可以考慮將部分依賴的元件指令碼進行打包合併,
像上圖中這四個指令碼的耗時都在在TTFB上,而且在同一個CDN上,可以通過打包減小不必要的併發。將首屏依賴的關鍵元件進行打包:
'pkg/util.js': [
'widget/utils/*.js'
],
'pkg/common.js': [
'static/js/bridge.js',
'static/js/link.interceptor.js',
'static/js/global.js',
'static/js/felog.js'
],
'pkg/lib.js': [
'static/js/zepto.min.js',
'static/js/zepto.touch.min.js',
'static/js/bluebird.core.min.js'
],
'pkg/homewgt.js': [
'widget/home/**.js',
'widget/player/*.js',
]
複製程式碼
優化後的DCL變為了11.20s。
5.3 資源引入順序
注意,一些打包工具會自動分析檔案依賴關係,檔案打包後會同時替換資源路徑。例如:在HTML中,引用了 static/js/zepto.min.js
和 static/js/bluebird.core.min.js
兩個資源,在打包後構建工具會將HTML中的引用自動替換為 lib.js
。因此需要注意打包後的資源載入順序。
例如,原HTML中的資源順序
<script type="text/javascript" src="//your.cdn.com/static/js/bridge.js"></script>
<script type="text/javascript" src="//your.cdn.com/static/js/zepto.min.js"></script>
<script type="text/javascript" src="//your.cdn.com/static/js/bluebird.core.min.js"></script>
<script type="text/javascript" src="//your.cdn.com/static/js/global.js"></script>
複製程式碼
其中 global.js
依賴於 zepto.min.js
,這個在目前看來沒有問題。但是由於打包合併,構建工具會自動替換指令碼檔名。由於 bridge.js
的位置,在打包後common.js
的引入順序先於lib.js
。這就導致 global.js
先於 zepto.min.js
引入與執行,出現錯誤。
對此,在不影響原有依賴的情況下,可以調整指令碼順序
<script type="text/javascript" src="//your.cdn.com/static/js/zepto.min.js"></script>
<script type="text/javascript" src="//your.cdn.com/static/js/bluebird.core.min.js"></script>
<script type="text/javascript" src="//your.cdn.com/static/js/bridge.js"></script>
<script type="text/javascript" src="//your.cdn.com/static/js/global.js"></script>
複製程式碼
輸出的結果如下:
6. 驗證效果
最終在無快取的slow 3G下DCL時間11.19s,相比最初的17.00s,降低34%。(wifi情況下降比例相同,時間大致同比為1/8~1/9,接近1s)。同時,相較於之前,一些靜態資源能夠更好地去利用HTTP Cache,節省頻寬,降低每次新版上線後使用者訪問站點的靜態資源下載量。
7. 寫在最後
需要指出,效能優化也許有一些“基本準則”,但絕對沒有銀彈。無論是多麼“基礎與通用”的優化手段,亦或是多麼“複雜而有針對性”的優化手段,都是在解決特定的具體問題。因此,解決效能問題往往都是從實際出發,通過“排查問題 --> 分析診斷 --> 實施優化 --> 驗證效果”這樣一條不斷迴圈的路徑來開展的。
同時,提升效能的其中一個目的就是更好的使用者體驗。使用者體驗往往是一個寬泛的概念,涉及方方面面。相對應的,效能優化也不能只死盯著某個“指標”,更應該理解其背後對產品與使用者的意義。從問題出發,拿資料量化,找解決方案。
在實際環境下,面對有限的資源和各種限制,創造最大的價值。效能優化更是如此。