每個參與過開發(fā)企業(yè)級 web 應(yīng)用的前端工程師或許都曾思考過前端性能優(yōu)化方面的問題。我們有雅虎 14 條性能優(yōu)化原則,還有兩本很經(jīng)典的性能優(yōu)化指導(dǎo)書:《高性能網(wǎng)站建設(shè)指南》、《高性能網(wǎng)站建設(shè)進階指南》。經(jīng)驗豐富的工程師對于前端性能優(yōu)化方法耳濡目染,基本都能一一列舉出來。這些性能優(yōu)化原則大概是在 7 年前提出的,對于 web 性能優(yōu)化至今都有非常重要的指導(dǎo)意義。
然而,對于構(gòu)建大型 web 應(yīng)用的團隊來說,要堅持貫徹這些優(yōu)化原則并不是一件十分容易的事。因為優(yōu)化原則中很多要求與工程管理相違背,比如“把 css 放在頭部”和“把 js 放在尾部”這兩條原則,我們不能讓整個團隊的工程師在寫樣式和腳本引用的時候都去修改同一份的頁面文件。這會嚴重影響團隊成員間并行開發(fā)的效率,尤其是在團隊有版本管理的情況下,每天要花大量的時間進行代碼修改合并,這項成本是難以接受的。因此在前端工程界,總會看到周期性的性能優(yōu)化工作,辛勤的前端工程師們每到月圓之夜就會傾巢出動根據(jù)優(yōu)化原則做一次最佳實踐。
本文從一個全新的視角來思考 web 性能優(yōu)化與前端工程之間的關(guān)系,通過解讀百度前端集成解決方案小組(F.I.S)在打造高性能前端架構(gòu)并統(tǒng)一百度 40 多條前端產(chǎn)品線的過程中所經(jīng)歷的技術(shù)嘗試,揭示前端性能優(yōu)化在前端架構(gòu)及開發(fā)工具設(shè)計層面的實現(xiàn)思路。
性能優(yōu)化原則及分類
筆者先假設(shè)本文的讀者是有前端開發(fā)經(jīng)驗的工程師,并對企業(yè)級 web 應(yīng)用開發(fā)及性能優(yōu)化有一定的思考。因此我不會重復(fù)介紹雅虎 14 條性能優(yōu)化原則,如果您沒有這些前續(xù)知識的,請移步這里來學(xué)習(xí)。
首先,我們把雅虎 14 條優(yōu)化原則,《高性能網(wǎng)站建設(shè)指南》以及《高性能網(wǎng)站建設(shè)進階指南》中提到的優(yōu)化點做一次梳理,如果按照優(yōu)化方向分類可以得到這樣一張表格:
優(yōu)化方向 | 優(yōu)化手段 |
---|---|
請求數(shù)量 | 合并腳本和樣式表,CSS Sprites,拆分初始化負載,劃分主域 |
請求帶寬 | 開啟 GZip,精簡 JavaScript,移除重復(fù)腳本,圖像優(yōu)化 |
緩存利用 | 使用 CDN,使用外部 JavaScript 和 CSS,添加 Expires 頭,減少 DNS 查找,配置 ETag,使 AjaX 可緩存 |
頁面結(jié)構(gòu) | 將樣式表放在頂部,將腳本放在底部,盡早刷新文檔的輸出 |
代碼校驗 | 避免 CSS 表達式,避免重定向 |
目前大多數(shù)前端團隊可以利用 yui compressor 或者 google closure compiler 等壓縮工具很容易做到“精簡 javascript ”這條原則,同樣的,也可以使用圖片壓縮工具對圖像進行壓縮,實現(xiàn)“圖像優(yōu)化”原則,這兩條原則是對單個資源的處理,因此不會引起任何工程方面的問題;很多團隊也通過引入代碼校驗流程來確保實現(xiàn)“避免 css 表達式”和“避免重定向”原則;目前絕大多數(shù)互聯(lián)網(wǎng)公司也已經(jīng)開啟了服務(wù)端的 Gzip 壓縮,并使用 CDN 實現(xiàn)靜態(tài)資源的緩存和快速訪問;一些技術(shù)實力雄厚的前端團隊甚至研發(fā)出了自動 CSS Sprites 工具,解決了 CSS Sprites 在工程維護方面的難題。使用“查找 - 替換”思路,我們似乎也可以很好的實現(xiàn)“劃分主域”原則。
我們把以上這些已經(jīng)成熟應(yīng)用到實際生產(chǎn)中的優(yōu)化手段去除掉,留下那些還沒有很好實現(xiàn)的優(yōu)化原則,再來回顧一下之前的性能優(yōu)化分類:
優(yōu)化方向 | 優(yōu)化手段 |
---|---|
請求數(shù)量 | 合并腳本和樣式表,拆分初始化負載 |
請求帶寬 | 移除重復(fù)腳本 |
緩存利用 | 添加 Expires 頭,配置 ETag,使 Ajax 可緩存 |
頁面結(jié)構(gòu) | 將樣式表放在頂部,將腳本放在底部,盡早刷新文檔的輸出 |
誠然,不可否認現(xiàn)在有很多頂尖的前端團隊可以將上述還剩下的優(yōu)化原則也都一一解決,但業(yè)界大多數(shù)團隊都還沒能很好的解決這些問題,因此接下來本文將就這些原則的解決方案做進一步的分析與講解,從而為那些還沒有進入前端工業(yè)化開發(fā)的團隊提供一些基礎(chǔ)技術(shù)建設(shè)意見,也借此機會與業(yè)界頂尖的前端團隊在工業(yè)化工程化方向上交流一下彼此的心得。
靜態(tài)資源版本更新與緩存
如表格 2 所示,在“緩存利用”分類中保留了“添加 Expires 頭”和“配置 ETag ”兩項,或許有些人會質(zhì)疑,明明這兩項只要配置了服務(wù)器的相關(guān)選項就可以實現(xiàn),為什么說它們難以解決呢?確實,開啟這兩項很容易,但開啟了緩存后,我們的項目就開始面臨另一個挑戰(zhàn):如何更新這些緩存。
相信大多數(shù)團隊也找到了類似的答案,它和《高性能網(wǎng)站建設(shè)指南》關(guān)于“添加 Expires 頭”所說的原則一樣——修訂文件名。即:
思路沒錯,但要怎么改變鏈接呢?變成什么樣的鏈接才能有效更新緩存,又能最大限度避免那些沒有修改過的文件緩存不失效呢?
先來看看現(xiàn)在一般前端團隊的做法:
<script type="text/javascript" src="http://www.jyygyx.com/uploadfile/cj/20140920/a.js?t=20130825"></script>
或者
<script type="text/javascript" src="http://www.jyygyx.com/uploadfile/cj/20140920/a.js?v=1.0.0"></script>
大家會采用添加 query 的形式修改鏈接。這樣做是比較直觀的解決方案,但在訪問量較大的網(wǎng)站,這么做可能將面臨一些新的問題。
通常一個大型的 web 應(yīng)用幾乎每天都會有迭代和更新,發(fā)布新版本也就是發(fā)布新的靜態(tài)資源和頁面的過程。以上述代碼為例,假設(shè)現(xiàn)在線上運行著 index.html 文件,并且使用了線上的 a.js 資源。index.html 的內(nèi)容為:
<script type="text/javascript" src="http://www.jyygyx.com/uploadfile/cj/20140920/a.js?v=1.0.0"></script>
這次我們更新了頁面中的一些內(nèi)容,得到一個 index.html 文件,并開發(fā)了新的與之匹配的 a.js 資源來完成頁面交互,新的 index.html 文件的內(nèi)容因此而變成了:
<script type="text/javascript" src="http://www.jyygyx.com/uploadfile/cj/20140920/a.js?v=1.0.1"></script>
好了,現(xiàn)在要開始將兩份新的文件發(fā)布到線上去。可以看到,a.html 和 a.js 的資源實際上是要覆蓋線上的同名文件的。不管怎樣,在發(fā)布的過程中,index.html 和 a.js 總有一個先后的順序,從而中間出現(xiàn)一段或大或小的時間間隔。對于一個大型互聯(lián)網(wǎng)應(yīng)用來說即使在一個很小的時間間隔內(nèi),都有可能出現(xiàn)新用戶訪問,而在這個時間間隔中訪問了網(wǎng)站的用戶會發(fā)生什么情況呢:
這就是為什么大型 web 應(yīng)用在版本上線的過程中經(jīng)常會較集中的出現(xiàn)前端報錯日志的原因,也是一些互聯(lián)網(wǎng)公司選擇加班到半夜等待訪問低峰期再上線的原因之一。此外,由于靜態(tài)資源文件版本更新是“覆蓋式”的,而頁面需要通過修改 query 來更新,對于使用 CDN 緩存的 web 產(chǎn)品來說,還可能面臨 CDN 緩存攻擊的問題。我們再來觀察一下前面說的版本更新手段:
<script type="text/javascript" src="http://www.jyygyx.com/uploadfile/cj/20140920/a.js?v=1.0.0"></script>
我們不難預(yù)測,a.js 的下一個版本是“ 1.0.1 ”,那么就可以刻意構(gòu)造一串這樣的請求“ a.js?v=1.0.1 ”、“ a.js?v=1.0.2 ”、……讓 CDN 將當前的資源緩存為“未來的版本”。這樣當這個頁面所用的資源有更新時,即使更改了鏈接地址,也會因為 CDN 的原因返回給用戶舊版本的靜態(tài)資源,從而造成頁面錯誤。即便不是刻意制造的攻擊,在上線間隙出現(xiàn)訪問也可能導(dǎo)致區(qū)域性的 CDN 緩存錯誤。
此外,當版本有更新時,修改所有引用鏈接也是一件與工程管理相悖的事,至少我們需要一個可以“查找 - 替換”的工具來自動化的解決版本號修改的問題。
對付這個問題,目前來說最優(yōu)方案就是基于文件內(nèi)容的 hash 版本冗余機制 了。也就是說,我們希望工程師源碼是這么寫的:
<script type="text/javascript" src="http://www.jyygyx.com/uploadfile/cj/20140920/a.js"></script>
但是線上代碼是這樣的:
<script type="text/javascript" src="http://www.jyygyx.com/uploadfile/cj/20140920/a_8244e91.js"></script>
其中”_82244e91 ”這串字符是根據(jù) a.js 的文件內(nèi)容進行 hash 運算得到的,只有文件內(nèi)容發(fā)生變化了才會有更改。由于版本序列是與文件名寫在一起的,而不是同名文件覆蓋,因此不會出現(xiàn)上述說的那些問題。那么這么做都有哪些好處呢?
雖然這種方案是相比之下最完美的解決方案,但它無法通過手工的形式來維護,因為要依靠手工的形式來計算和替換 hash 值并生成相應(yīng)的文件將是一項非常繁瑣且容易出錯的工作。因此,我們需要借助工具。有了這樣的思路,我們下面就來了解一下 fis 是如何完成這項工作的。
首先,之所以有這種工具需求,完全是因為 web 應(yīng)用運行的根本機制決定的:web 應(yīng)用所需的資源是以字面的形式通知瀏覽器下載而聚合在一起運行的。這種資源加載策略使得 web 應(yīng)用從本質(zhì)上區(qū)別于傳統(tǒng)桌面應(yīng)用的版本更新方式,也是大型 web 應(yīng)用需要工具處理的最根本原因。為了實現(xiàn)資源定位的字面量替換操作,前端構(gòu)建工具理論上需要識別所有資源定位的標記,其中包括:
<script src=” path ”>
、<link href=” path ”>
、<img src=” path ”>
、已經(jīng) embed、audio、video、object 等具有資源加載功能的標簽。為了工程上的維護方便,我們希望工程師在源碼中寫的是相對路徑,而工具可以將其替換為線上的絕對路徑,從而避免相對路徑定位錯誤的問題(比如 js 中需要定位圖片路徑時不能使用相對路徑的情況)。
fis 有一個非常棒的資源定位系統(tǒng),它是根據(jù)用戶自己的配置來指定資源發(fā)布后的地址,然后由 fis 的資源定位系統(tǒng)識別文件中的定位標記,計算內(nèi)容 hash,并根據(jù)配置替換為上線后的絕對 url 路徑。
要想實現(xiàn)具備 hash 版本生成功能的構(gòu)建工具不是“查找 - 替換”這么簡單的,我們考慮這樣一種情況:
由于我們的資源版本號是通過對文件內(nèi)容進行 hash 運算得到,如上圖所示,index.html 中引用的 a.css 文件的內(nèi)容其實也包含了 a.png 的 hash 運算結(jié)果,因此我們在修改 index.html 中 a.css 的引用時,不能直接計算 a.css 的內(nèi)容 hash,而是要先計算出 a.png 的內(nèi)容 hash,替換 a.css 中的引用,得到了 a.css 的最終內(nèi)容,再做 hash 運算,最后替換 index.html 中的引用。
這意味著構(gòu)建工具需要具備“遞歸編譯”的能力,這也是為什么 fis 團隊不得不放棄 gruntjs 等 task-based 系統(tǒng)的根本原因。針對前端項目的構(gòu)建工具必須是具備遞歸處理能力的。此外,由于文件之間的交叉引用等原因,fis 構(gòu)建工具還實現(xiàn)了構(gòu)建緩存等機制,以提升構(gòu)建速度。
在解決了基于內(nèi)容 hash 的版本更新問題之后,我們可以將所有前端靜態(tài)資源開啟永久強緩存,每次版本發(fā)布都可以首先讓靜態(tài)資源全量上線,再進一步上線模板或者頁面文件,再也不用擔心各種緩存和時間間隙的問題了!
靜態(tài)資源管理與模板框架
讓我們再來看看前面的優(yōu)化原則表還剩些什么:
優(yōu)化方向 | 優(yōu)化手段 |
---|---|
請求數(shù)量 | 合并腳本和樣式表,拆分初始化負載 |
請求帶寬 | 移除重復(fù)腳本 |
緩存利用 | 使 Ajax 可緩存 |
頁面結(jié)構(gòu) | 將樣式表放在頂部,將腳本放在底部,盡早刷新文檔的輸出 |
很不幸,剩下的優(yōu)化原則都不是使用工具就能很好實現(xiàn)的。或許有人會辯駁:“我用某某工具可以實現(xiàn)腳本和樣式表合并”。嗯,必須承認,使用工具進行資源合并并替換引用或許是一個不錯的辦法,但在大型 web 應(yīng)用,這種方式有一些非常嚴重的缺陷,來看一個很熟悉的例子:
某個 web 產(chǎn)品頁面有 A、B、C 三個資源
工程師根據(jù)“減少 HTTP 請求”的優(yōu)化原則合并了資源
產(chǎn)品經(jīng)理要求 C 模塊按需出現(xiàn),此時 C 資源已出現(xiàn)多余的可能
C 模塊不再需要了,注釋掉吧!但 C 資源通常不敢輕易剔除
不知不覺中,性能優(yōu)化變成了性能惡化……
事實上,使用工具在線下進行靜態(tài)資源合并是無法解決資源按需加載的問題的。如果解決不了按需加載,則勢必會導(dǎo)致資源的冗余;此外,線下通過工具實現(xiàn)的資源合并通常會使得資源加載和使用的分離,比如在頁面頭部或配置文件中寫資源引用及合并信息,而用到這些資源的 html 組件寫在了頁面其他地方,這種書寫方式在工程上非常容易引起維護不同步的問題,導(dǎo)致使用資源的代碼刪除了,引用資源的代碼卻還在的情況。因此,在工業(yè)上要實現(xiàn)資源合并至少要滿足如下需求:
將以上要求綜合考慮,不難發(fā)現(xiàn),單純依靠前端技術(shù)或者工具處理的是很難達到這些理想要求的。現(xiàn)代大型 web 應(yīng)用所展示的頁面絕大多數(shù)都是使用服務(wù)端動態(tài)語言拼接生成的。有的產(chǎn)品使用模板引擎,比如 smarty、velocity,有的則干脆直接使用動態(tài)語言,比如 php、python。無論使用哪種方式實現(xiàn),前端工程師開發(fā)的 html 絕大多數(shù)最終都不是以靜態(tài)的 html 在線上運行的,接下來我會講述一種新的模板架構(gòu)設(shè)計,用以實現(xiàn)前面說到那些性能優(yōu)化原則,同時滿足工程開發(fā)和維護的需要,這種架構(gòu)設(shè)計的核心思想就是:
考慮一段這樣的頁面代碼:
<html> <head> <title>hello world</title> <link rel="stylesheet" type="text/css" href="A.css"> <link rel="stylesheet" type="text/css" href="B.css"> <link rel="stylesheet" type="text/css" href="C.css"> </head> <body> <div>html of A</div> <div>html of B</div> <div>html of C</div> </body> </html>
根據(jù)資源合并需求中的第二項,我們希望資源引用與使用能盡量靠近,這樣將來維護起來會更容易一些,因此,理想的源碼是:
<html> <head> <title>hello world</title> </head> <body> <link rel="stylesheet" type="text/css" href="A.css"><div>html of A</div> <link rel="stylesheet" type="text/css" href="B.css"><div>html of B</div> <link rel="stylesheet" type="text/css" href="C.css"><div>html of C</div> </body> </html>
當然,把這樣的頁面直接送達給瀏覽器用戶是會有嚴重的頁面閃爍問題的,所以我們實際上仍然希望最終頁面輸出的結(jié)果還是如最開始的截圖一樣,將 css 放在頭部輸出。這就意味著,頁面結(jié)構(gòu)需要有一些調(diào)整,并且有能力收集資源加載需求,那么我們考慮一下這樣的源碼:
<html> <head> <title>hello world</title> <!--[CSS LINKS PLACEHOLDER]--> </head> <body> {require name="A.css"}<div>html of A</div> {require name="B.css"}<div>html of B</div> {require name="C.css"}<div>html of C</div> </body> </html>
在頁面的頭部插入一個 html 注釋“<!--[CSS LINKS PLACEHOLDER]-->
”作為占位,而將原來字面書寫的資源引用改成模板接口(require)調(diào)用,該接口負責收集頁面所需資源。require 接口實現(xiàn)非常簡單,就是準備一個數(shù)組,收集資源引用,并且可以去重。最后在頁面輸出的前一刻,我們將 require 在運行時收集到的“ A.css ”、“ B.css ”、“ C.css ”三個資源拼接成 html 標簽,替換掉注釋占位“<!--[CSS LINKS PLACEHOLDER]-->
”,從而得到我們需要的頁面結(jié)構(gòu)。
經(jīng)過 fis 團隊的總結(jié),我們發(fā)現(xiàn)模板層面只要實現(xiàn)三個開發(fā)接口,既可以比較完美的實現(xiàn)目前遺留的大部分性能優(yōu)化原則,這三個接口分別是:
實現(xiàn)了這些接口之后,一個重構(gòu)后的模板頁面的源代碼可能看起來就是這樣的了:
<html> <head> <title>hello world</title> <!--[CSS LINKS PLACEHOLDER]--> {require name="jquery.js"} {require name="bootstrap.css"} </head> <body> {require name="A/A.css"}{widget name="A/A.tpl"} {script}console.log('A loaded'){/script} {require name="B/B.css"}{widget name="B/B.tpl"} {require name="C/C.css"}{widget name="C/C.tpl"} <!--[SCRIPTS PLACEHOLDER]--> </body> </html>
而最終在模板解析的過程中,資源收集與去重、頁面 script 收集、占位符替換操作,最終從服務(wù)端發(fā)送出來的 html 代碼為:
<html> <head> <title>hello world</title> <link rel="stylesheet" type="text/css" href="bootstrap.css"> <link rel="stylesheet" type="text/css" href="A/A.css"> <link rel="stylesheet" type="text/css" href="B/B.css"> <link rel="stylesheet" type="text/css" href="C/C.css"> </head> <body> <div>html of A</div> <div>html of B</div> <div>html of C</div> <script type="text/javascript" src="jquery.js"></script> <script type="text/javascript">console.log('A loaded');</script> </body> </html>
不難看出,我們目前已經(jīng)實現(xiàn)了“按需加載”,“將腳本放在底部”,“將樣式表放在頭部”三項優(yōu)化原則。
前面講到靜態(tài)資源在上線后需要添加 hash 戳作為版本標識,那么這種使用模板語言來收集的靜態(tài)資源該如何實現(xiàn)這項功能呢?答案是:靜態(tài)資源依賴關(guān)系表。假設(shè)前面講到的模板源代碼所對應(yīng)的目錄結(jié)構(gòu)為下圖所示:
那么我們可以使用工具掃描整個 project 目錄,然后創(chuàng)建一張資源表,同時記錄每個資源的部署路徑,可以得到這樣的一張表:
{
"res": {
"A/A.css": {
"uri": "/A/A_1688c82.css",
"type": "css"
},
"B/B.css": {
"uri": "/B/B_52923ed.css",
"type": "css"
},
"C/C.css": {
"uri": "/C/C_6dda653.css",
"type": "css"
},
"bootstrap.css": {
"uri": "bootstrap_08f2256.css",
"type": "css"
},
"jquery.js": {
"uri": "jquery_9155343.css",
"type": "js"
},
},
"pkg": {}
}
基于這張表,我們就很容易實現(xiàn) {require name=” id ”} 這個模板接口了。只須查表即可。比如執(zhí)行{require name=” jquery.js ”},查表得到它的 url 是“/jquery_9151577.js ”,聲明一個數(shù)組收集起來就好了。這樣,整個頁面執(zhí)行完畢之后,收集資源加載需求,并替換頁面的占位符,即可實現(xiàn)資源的 hash 定位,得到:
<html> <head> <title>hello world</title> <link rel="stylesheet" type="text/css" href="bootstrap_08f2256.css"> <link rel="stylesheet" type="text/css" href="A/A_1688c82.css"> <link rel="stylesheet" type="text/css" href="B/B_52923ed.css"> <link rel="stylesheet" type="text/css" href="C/C_6dda653.css"> </head> <body> <div>html of A</div> <div>html of B</div> <div>html of C</div> <script type="text/javascript" src="jquery_9155343.js"></script> <script type="text/javascript">console.log('A loaded');</script> </body> </html>
接下來,我們討論如何在基于表的設(shè)計思想上是如何實現(xiàn)靜態(tài)資源合并的。或許有些團隊使用過 combo 服務(wù),也就是我們在最終拼接生成頁面資源引用的時候,并不是生成多個獨立的 link 標簽,而是將資源地址拼接成一個 url 路徑,請求一種線上的動態(tài)資源合并服務(wù),從而實現(xiàn)減少 HTTP 請求的需求,比如:
<html> <head> <title>hello world</title> <link rel="stylesheet" type="text/css" href="/combo?files=bootstrap_08f2256.css,A/A_1688c82.css,B/B_52923ed.css,C/C_6dda653.css"> </head> <body> <div>html of A</div> <div>html of B</div> <div>html of C</div> <script type="text/javascript" src="jquery_9155343.js"></script<生活不易,碼農(nóng)辛苦
如果您覺得本網(wǎng)站對您的學(xué)習(xí)有所幫助,可以手機掃描二維碼進行捐贈
![]()