性能優(yōu)化方向分類
- 請(qǐng)求數(shù)量:
- 合并腳本和樣式表,
- CSS Sprites,
- 拆分初始化負(fù)載,
- 劃分主域(使用“查找-替換”思路,我們似乎也可以很好的實(shí)現(xiàn) 劃分主域
原則) - 請(qǐng)求帶寬:
- 開啟GZip (開啟了服務(wù)端的Gzip壓縮)
- 精簡(jiǎn)JavaScript(利用 yui compressor 或者 google closure compiler 等壓縮工具很容易做到 ),
- 移除重復(fù)腳本,
- 圖像優(yōu)化(也可以使用圖片壓縮工具對(duì)圖像進(jìn)行壓縮,實(shí)現(xiàn) 圖像優(yōu)化
原則) - 緩存利用:
- 使用CDN(實(shí)現(xiàn)靜態(tài)資源的緩存和快速訪問),
- 使用外部Javascript和Css,
- 添加Expires,
- 減少DNS查找,
- 配置ETag,
- 使用Ajax
- 頁面結(jié)構(gòu):
- 將樣式表放在頂部,
- 盡早刷新文檔的輸出
- 代碼校驗(yàn):
- 避免CSS表達(dá)式(一些技術(shù)實(shí)力雄厚的前端團(tuán)隊(duì)甚至研發(fā)出了自動(dòng)CSS Sprites工具,解決了CSS Sprites在工程維護(hù)方面的難題),
- 避免重定向(通過引入代碼校驗(yàn)流程來確保實(shí)現(xiàn) 避免css表達(dá)式和 避免重定向原則)
名詞解釋
CSS Sprites【在國(guó)內(nèi)很多人叫css精靈,是一種網(wǎng)頁圖片應(yīng)用處理方式。它允許你將一個(gè)頁面涉及到的所有零星圖片都包含到一張大圖中去,這樣一來,當(dāng)訪問該頁面時(shí),載入的圖片就不會(huì)像以前那樣一幅一幅地慢慢顯示出來了。對(duì)于當(dāng)前網(wǎng)絡(luò)流行的速度而言,不高于200KB的單張圖片的所需載入時(shí)間基本是差不多的,所以無需顧忌這個(gè)問題】
把以上這些已經(jīng)成熟應(yīng)用到實(shí)際生產(chǎn)中的優(yōu)化手段去除掉,留下那些還沒有很好實(shí)現(xiàn)的優(yōu)化原則。再來回顧一下之前的性能優(yōu)化分類:
- 請(qǐng)求數(shù)量: 合并腳本和樣式表,拆分初始化負(fù)載
- 請(qǐng)求帶寬 :移除重復(fù)腳本
- 緩存利用:添加Expries頭,配置ETag,使用Ajax可緩存
- 頁面結(jié)構(gòu): 將樣式表放在頭部,將腳本放在底部,盡早刷新文檔的輸出
靜態(tài)資源版本更新與緩存
添加Expires頭 和 配置ETag兩項(xiàng)只要配置了服務(wù)器的相關(guān)選項(xiàng)就可以實(shí)現(xiàn)但是問題在于開啟緩存后如何更新思路:最有效的解決方案是修改其所有鏈接,這樣,全新的請(qǐng)求將從原始服務(wù)器下載最新的內(nèi)容
但要怎么改變鏈接呢?變成什么樣的鏈接才能有效更新緩存,又能最大限度避免那些沒有修改過的文件緩存不失效呢?
先來看看現(xiàn)在一般前端團(tuán)隊(duì)的做法:
<h1>hello world</h1>
<script type="text/javascript" src="a.js?t=201404231123"></script>
<script type="text/javascript" src="b.js?t=201404231123"></script>
<script type="text/javascript" src="c.js?t=201404231123"></script>
<script type="text/javascript" src="d.js?t=201404231123"></script>
<script type="text/javascript" src="e.js?t=201404231123"></script>
也有團(tuán)隊(duì)采用構(gòu)建版本號(hào)為靜態(tài)資源請(qǐng)求添加query,它們?cè)诒举|(zhì)上是沒有區(qū)別的
接下來,項(xiàng)目升級(jí),比如頁面上的html結(jié)構(gòu)發(fā)生變化,對(duì)應(yīng)還要修改 a.js 這個(gè)文件,得到的構(gòu)建結(jié)果如下:
<header>hello world</header>
<script type="text/javascript" src="a.js?t=201404231826"></script>
<script type="text/javascript" src="b.js?t=201404231826"></script>
<script type="text/javascript" src="c.js?t=201404231826"></script>
<script type="text/javascript" src="d.js?t=201404231826"></script>
<script type="text/javascript" src="e.js?t=201404231826"></script>
為了觸發(fā)用戶瀏覽器的緩存更新,我們需要更改靜態(tài)資源的url地址,如果采用構(gòu)建信息(時(shí)間戳、版本號(hào)等)作為url修改的依據(jù),如上述代碼所示,我們只修改了一個(gè)a.js文件,但再次構(gòu)建會(huì)讓所有請(qǐng)求都更改了url地址,用戶再度訪問頁面那些沒有修改過的靜態(tài)資源的(b.js,b.js,c.js,d.js,e.js)的瀏覽器緩存也一同失效了。使用構(gòu)建信息作為靜態(tài)資源更新標(biāo)記會(huì)導(dǎo)致每次構(gòu)建發(fā)布后所有靜態(tài)資源都被迫更新,瀏覽器緩存利用率降低,給性能帶來傷害。
此外,采用添加query的方式來清除緩存還有一個(gè)弊端,就是 覆蓋式發(fā)布的上線問題。
采用query更新緩存的方式實(shí)際上要覆蓋線上文件的,index.html和a.js總有一個(gè)先后的順序,從而中間出現(xiàn)一段或大或小的時(shí)間間隔。尤其是當(dāng)頁面是后端渲染的模板的時(shí)候,靜態(tài)資源和模板是部署在不同的機(jī)器集群上的,上線的過程中,靜態(tài)資源和頁面文件的部署時(shí)間間隔可能會(huì)非常長(zhǎng),對(duì)于一個(gè)大型互聯(lián)網(wǎng)應(yīng)用來說即使在一個(gè)很小的時(shí)間間隔內(nèi),都有可能出現(xiàn)新用戶訪問。在這個(gè)時(shí)間間隔中,訪問了網(wǎng)站的用戶會(huì)發(fā)生什么情況呢?
- 如果先覆蓋index.html,后覆蓋a.js,用戶在這個(gè)時(shí)間間隙訪問,會(huì)得到新的index.html配合舊的a.js的情況,從而出現(xiàn)錯(cuò)誤的頁面。
- 如果先覆蓋a.js,后覆蓋index.html,用戶在這個(gè)間隙訪問,會(huì)得到舊的index.html配合新的a.js的情況,從而也出現(xiàn)了錯(cuò)誤的頁面。
這就是為什么大型web應(yīng)用在版本上線的過程中經(jīng)常會(huì)較集中的出現(xiàn)前端報(bào)錯(cuò)日志的原因,也是一些互聯(lián)網(wǎng)公司選擇加班到半夜等待訪問低峰期再上線的原因之一。
對(duì)于靜態(tài)資源緩存更新的問題,目前來說最優(yōu)方案就是 基于文件內(nèi)容的hash版本冗余機(jī)制
了。也就是說,我們希望項(xiàng)目源碼是這么寫的:
<script type="text/javascript" src="a.js"></script>
發(fā)布后代碼變成
<script type="text/javascript" src="a_8244e91.js"></script>
也就是a.js發(fā)布出來后被修改了文件名,產(chǎn)生一個(gè)新文件,并不是覆蓋已有文件。其中”_82244e91”這串字符是根據(jù)a.js的文件內(nèi)容進(jìn)行hash運(yùn)算得到的,只有文件內(nèi)容發(fā)生變化了才會(huì)有更改。由于將文件發(fā)布為帶有hash的新文件,而不是同名文件覆蓋,因此不會(huì)出現(xiàn)上述說的那些問題。同時(shí),這么做還有其他的好處:
- 上線的a.js不是同名文件覆蓋,而是文件名+hash的冗余,所以可以先上線靜態(tài)資源,再- 上線html頁面,不存在間隙問題;
- 遇到問題回滾版本的時(shí)候,無需回滾a.js,只須回滾頁面即可;
由于靜態(tài)資源版本號(hào)是文件內(nèi)容的hash,因此所有靜態(tài)資源可以開啟永久強(qiáng)緩存,只有更新了內(nèi)容的文件才會(huì)緩存失效,緩存利用率大增;
以文件內(nèi)容的hash值為依據(jù)生產(chǎn)新文件的非覆蓋式發(fā)布策略是解決靜態(tài)資源緩存更新最有效的手段。####
雖然這種方案是相比之下最完美的解決方案,但它無法通過手工的形式來維護(hù),因?yàn)橐揽渴止さ男问絹碛?jì)算和替換hash值,并生成相應(yīng)的文件,將是一項(xiàng)非常繁瑣且容易出錯(cuò)的工作,因此我們需要借助工具來處理。
用grunt來實(shí)現(xiàn)md5功能是非常困難的,因?yàn)間runt只是一個(gè)task管理器,而md5計(jì)算需要構(gòu)建工具具有遞歸編譯的能,而不是簡(jiǎn)單的任務(wù)調(diào)度。考慮這樣的例子:
由于我們的資源版本號(hào)是通過對(duì)文件內(nèi)容進(jìn)行hash運(yùn)算得到,如上圖所示,index.html中引用的a.css文件的內(nèi)容其實(shí)也包含了a.png的hash運(yùn)算結(jié)果,因此我們?cè)谛薷膇ndex.html中a.css的引用時(shí),不能直接計(jì)算a.css的內(nèi)容hash,而是要先計(jì)算出a.png的內(nèi)容hash,替換a.css中的引用,得到了a.css的最終內(nèi)容,再做hash運(yùn)算,最后替換index.html中的引用。
計(jì)算index.html中引用的a.css文件的url過程:
- 壓縮a.png后計(jì)算其內(nèi)容的md5值
- 將a.png的md5寫入a.css,再壓縮a.css,計(jì)算其內(nèi)容的md5值
- 將a.css的md5值寫入到index.html中
grunt等task-based的工具是很難在task之間協(xié)作處理這樣的需求的。在解決了基于內(nèi)容hash的版本更新問題之后,我們可以將所有前端靜態(tài)資源開啟永久強(qiáng)緩存,每次版本發(fā)布都可以首先讓靜態(tài)資源全量上線,再進(jìn)一步上線模板或者頁面文件,再也不用擔(dān)心各種緩存和時(shí)間間隙的問題了!
靜態(tài)資源管理與模塊化框架
剩余問題:
- 請(qǐng)求數(shù)量: 合并腳本和樣式表,拆分初始化負(fù)載
- 請(qǐng)求帶寬 :移除重復(fù)腳本
- 緩存利用:使用Ajax可緩存
- 頁面結(jié)構(gòu): 將樣式表放在頭部,將腳本放在底部,盡早刷新文檔的輸出
剩下的優(yōu)化原則都不是使用工具就能很好實(shí)現(xiàn)的,使用工具進(jìn)行資源合并并替換引用或許是一個(gè)不錯(cuò)的辦法,但在大型web應(yīng)用,這種方式有一些非常嚴(yán)重的缺陷,來看一個(gè)很熟悉的例子 :
某個(gè)web產(chǎn)品頁面有A、B、C三個(gè)資源

工程師根據(jù)“減少HTTP請(qǐng)求”的優(yōu)化原則合并了資源

產(chǎn)品經(jīng)理要求C模塊按需出現(xiàn),此時(shí)C資源已出現(xiàn)多余的可能

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

不知不覺中,性能優(yōu)化變成了性能惡化……
這個(gè)例子來自 Facebook靜態(tài)網(wǎng)頁資源的管理和優(yōu)化@Velocity China 2010
事實(shí)上,使用工具在線下進(jìn)行靜態(tài)資源合并是無法解決資源按需加載的問題的。如果解決不了按需加載,則必會(huì)導(dǎo)致資源的冗余;此外,線下通過工具實(shí)現(xiàn)的資源合并通常會(huì)使得資源加載和使用的分離,比如在頁面頭部或配置文件中寫資源引用及合并信息,而用到這些資源的html組件寫在了頁面其他地方,這種書寫方式在工程上非常容易引起維護(hù)不同步的問題,導(dǎo)致使用資源的代碼刪除了,引用資源的代碼卻還在的情況。因此,在工業(yè)上要實(shí)現(xiàn)資源合并至少要滿足如下需求:
- 確實(shí)能減少HTTP請(qǐng)求,這是基本要求(合并)
- 在使用資源的地方引用資源(就近依賴),不使用不加載(按需)雖然資源引用不是集中書寫的,但資源引用的代碼最終還能出現(xiàn)在頁面頭部(css)或尾部(js)
能夠避免重復(fù)加載資源(去重)
將以上要求綜合考慮,不難發(fā)現(xiàn),單純依靠前端技術(shù)或者工具處理是很難達(dá)到這些理想要求的。
接下來我會(huì)講述一種新的模板架構(gòu)設(shè)計(jì),用以實(shí)現(xiàn)前面說到那些性能優(yōu)化原則,同時(shí)滿足工程開發(fā)和維護(hù)的需要,這種架構(gòu)設(shè)計(jì)的核心思想就是:
基于依賴關(guān)系表的靜態(tài)資源管理系統(tǒng)與模塊化框架設(shè)計(jì)
考慮一段這樣的頁面代碼:
<html><head>
<title>page</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> content of module a </div>
<div> content of module b </div>
<div> content of module c </div>
</body>
</html>
根據(jù)資源合并需求中的第二項(xiàng),我們希望資源引用與使用能盡量靠近,這樣將來維護(hù)起來會(huì)更容易一些,因此,理想的源碼是:
<html>
<head>
<title>page</title>
</head>
<body>
<link rel="stylesheet" type="text/css" href="a.css"/>
<div> content of module a </div>
<link rel="stylesheet" type="text/css" href="b.css"/>
<div> content of module b </div>
<link rel="stylesheet" type="text/css" href="c.css"/>
<div> content of module c </div>
</body></html>
當(dāng)然,把這樣的頁面直接送達(dá)給瀏覽器用戶是會(huì)有嚴(yán)重的頁面閃爍問題的,所以我們實(shí)際上仍然希望最終頁面輸出的結(jié)果還是如最開始的截圖一樣,將css放在頭部輸出。這就意味著,頁面結(jié)構(gòu)需要有一些調(diào)整,并且有能力收集資源加載需求,那么我們考慮一下這樣的源碼(以php為例):
<html>
<head>
<title>page</title>
<!--[ CSS LINKS PLACEHOLDER ]-->
</head>
<body>
<?php require_static('a.css'); ?>
<div> content of module a </div>
<?php require_static('b.css'); ?>
<div> content of module b </div>
<?php require_static('c.css'); ?>
<div> content of module c </div>
</body>
</html>
在頁面的頭部插入一個(gè)html注釋 作為占位,而將原來字面書寫的資源引用改成模板接口 require_static 調(diào)用,該接口負(fù)責(zé)收集頁面所需資源。require_static接口實(shí)現(xiàn)非常簡(jiǎn)單,就是準(zhǔn)備一個(gè)數(shù)組,收集資源引用,并且可以去重。最后在頁面輸出的前一刻,我們將require_static在運(yùn)行時(shí)收集到的 a.css、b.css,c.css 三個(gè)資源拼接成html標(biāo)簽,替換掉注釋占位
,從而得到我們需要的頁面結(jié)構(gòu)。
經(jīng)過實(shí)踐總結(jié),可以發(fā)現(xiàn)模板層面只要實(shí)現(xiàn)三個(gè)開發(fā)接口,就可以比較完美的實(shí)現(xiàn)目前遺留的大部分性能優(yōu)化原則,這三個(gè)接口分別是:
- require_static(res_id):收集資源加載需求的接口,參數(shù)是靜態(tài)資源id。
- load_widget(wiget_id):加載拆分成小組件模板的接口。你可以叫它為widget,component或者pagelet之類的??傊?,我們需要一個(gè)接口把一個(gè)大的頁面模板拆分成一個(gè)個(gè)的小部分來維護(hù),最后在原來的頁面中以組件為單位來加載這些小部件。
- script(code):收集寫在模板中的js腳本,使之出現(xiàn)的頁面底部,從而實(shí)現(xiàn)性能優(yōu)化原則中的 將js放在頁面底部 原則。
實(shí)現(xiàn)了這些接口之后,一個(gè)重構(gòu)后的模板頁面的源代碼可能看起來就是這樣的了:
<html><head>
<title>page</title>
<?php require_static('jquery.js'); ?>
<?php require_static('bootstrap.css'); ?>
<?php require_static('bootstrap.js'); ?>
<!--[ CSS LINKS PLACEHOLDER ]-->
</head>
<body>
<?php load_widget('a'); ?>
<?php load_widget('b'); ?>
<?php load_widget('c'); ?>
<!--[ SCRIPTS PLACEHOLDER ]-->
</body>
</html>
而最終在模板解析的過程中,資源收集與去重、頁面script收集、占位符替換操作,最終從服務(wù)端發(fā)送出來的html代碼為:
<html><head>
<title>page</title>
<link rel="stylesheet" type="text/css" href="bootstrap.css"/>
<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> content of module a </div>
<div> content of module b </div>
<div> content of module c </div>
<script type="text/javascript" src="jquery.js"></script>
<script type="text/javascript" src="bootstrap.js"></script>
<script type="text/javascript" src="a.js"></script>
<script type="text/javascript" src="b.js"></script>
<script type="text/javascript" src="c.js"></script>
</body>
</html>
不難看出,我們目前已經(jīng)實(shí)現(xiàn)了 按需加載,將腳本放在底部,將樣式表放在頭部 三項(xiàng)優(yōu)化原則。
前面講到靜態(tài)資源在上線后需要添加hash戳作為版本標(biāo)識(shí),那么這種使用模板語言來收集的靜態(tài)資源該如何實(shí)現(xiàn)這項(xiàng)功能呢?
答案是:靜態(tài)資源依賴關(guān)系表。###
考慮這樣的目錄結(jié)構(gòu):
![]CI6%_0FBW4.png](http://upload-images.jianshu.io/upload_images/1058258-e4067324e4a4c04e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
如果我們可以使用工具掃描整個(gè)project目錄,然后創(chuàng)建一張資源表,同時(shí)記錄每個(gè)資源的部署路徑,得到這樣的一張表:
基于這張表,我們就很容易實(shí)現(xiàn) require_static(file_id),load_widget(widget_id)
這兩個(gè)模板接口了。以load_widget為例:
利用查表來解決md5戳的問題,這樣,我們的頁面最終送達(dá)給用戶的結(jié)果就是這樣的:
接下來,我們討論基于表的設(shè)計(jì)思想上是如何實(shí)現(xiàn)靜態(tài)資源合并的?;蛟S有些團(tuán)隊(duì)使用過combo服務(wù),也就是我們?cè)谧罱K拼接生成頁面資源引用的時(shí)候,并不是生成多個(gè)獨(dú)立的link標(biāo)簽,而是將資源地址拼接成一個(gè)url路徑,請(qǐng)求一種線上的動(dòng)態(tài)資源合并服務(wù),從而實(shí)現(xiàn)減少HTTP請(qǐng)求的需求,比如前面的例子,稍作調(diào)整即可得到這樣的結(jié)果:
這個(gè) /??file1,file2,file3,… 的url請(qǐng)求響應(yīng)就是動(dòng)態(tài)combo服務(wù)提供的,它的原理很簡(jiǎn)單,就是根據(jù)url找到對(duì)應(yīng)的多個(gè)文件,合并成一個(gè)文件來響應(yīng)請(qǐng)求,并將其緩存,以加快訪問速度。
這種方法很巧妙,有些服務(wù)器甚至直接集成了這類模塊來方便的開啟此項(xiàng)服務(wù),這種做法也是大多數(shù)大型web應(yīng)用的資源合并做法。但它也存在一些缺陷:
- 瀏覽器有url長(zhǎng)度限制,因此不能無限制的合并資源。
- 如果用戶在網(wǎng)站內(nèi)有公共資源的兩個(gè)頁面間跳轉(zhuǎn)訪問,由于兩個(gè)頁面的combo的url不一樣導(dǎo)致用戶不能利用瀏覽器緩存來加快對(duì)公共資源的訪問速度。
- 如果combo的url中任何一個(gè)文件發(fā)生改變,都會(huì)導(dǎo)致整個(gè)url緩存失效,從而導(dǎo)致瀏覽器緩存利用率降低。
對(duì)于上述第二條缺陷,可以舉個(gè)例子來看說明:
假設(shè)網(wǎng)站有兩個(gè)頁面A和B
A頁面使用了a,b,c,d四個(gè)資源
B頁面使用了a,b,e,f四個(gè)資源
如果使用combo服務(wù),我們會(huì)得:
A頁面的資源引用為:/??a,b,c,d
B頁面的資源引用為:/??a,b,e,f
兩個(gè)頁面引用的資源是不同的url,因此瀏覽器會(huì)請(qǐng)求兩個(gè)合并后的資源文件,跨頁面訪問沒能很好的利用a、b這兩個(gè)資源的緩存。
很明顯,如果combo服務(wù)能聰明的知道A頁面使用的資源引用為 /??a,b
和 /??c,d
,而B頁面使用的資源引用為 /??a,b
和 /??e,f
就好了。這樣當(dāng)用戶在訪問A頁面之后再訪問B頁面時(shí),只需要下載B頁面的第二個(gè)combo文件即可,第一個(gè)文件已經(jīng)在訪問A頁面時(shí)緩存好了的?;谶@樣的思考,我們?cè)谫Y源表上新增了一個(gè)字段,取名為 pkg,就是資源合并生成的新資源,表的結(jié)構(gòu)會(huì)變成:
相比之前的表,可以看到新表中多了一個(gè)pkg字段,并且記錄了打包后的文件所包含的獨(dú)立資源。這樣,我們重新設(shè)計(jì)一下 require_static、load_widget 這兩個(gè)模板接口,實(shí)現(xiàn)這樣的邏輯:
在查表的時(shí)候,如果一個(gè)靜態(tài)資源有pkg字段,那么就去加載pkg字段所指向的打包文件,否則加載資源本身。
比如執(zhí)行require_static('bootstrap.js'),查表得知bootstrap.js被打包在了p1中,因此取出p1包的url /pkg/lib_cef213d.js,并且記錄頁面已加載了 jquery.js 和 bootstrap.js 兩個(gè)資源。這樣一來,之前的模板代碼執(zhí)行之后得到的html就變成了:
![]6PSM{F1%%UED4R.png](http://upload-images.jianshu.io/upload_images/1058258-8bc134c681a0d7f2.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
雖然這種策略請(qǐng)求有4個(gè),不如combo形式的請(qǐng)求少,但可能在統(tǒng)計(jì)上是性能更好的方案。由于兩個(gè)lib打包的文件修改的可能性很小,因此這兩個(gè)請(qǐng)求的緩存利用率會(huì)非常高,每次項(xiàng)目發(fā)布后,用戶需要重新下載的靜態(tài)資源可能要比combo請(qǐng)求節(jié)省很多帶寬。
性能優(yōu)化既是一個(gè)工程問題,又是一個(gè)統(tǒng)計(jì)問題。優(yōu)化性能時(shí)如果只關(guān)注一個(gè)頁面的首次加載是很片面的。還應(yīng)該考慮全站頁面間跳轉(zhuǎn)、項(xiàng)目迭代后更新資源等情況下的優(yōu)化策略。###
此時(shí),我們又引入了一個(gè)新的問題:如何決定哪些文件被打包?
從經(jīng)驗(yàn)來看,項(xiàng)目初期可以采用人工配置的方式來指定打包情況,比如:
但隨著系統(tǒng)規(guī)模的增大,人工配置會(huì)帶來非常高的維護(hù)成本,此時(shí)需要一個(gè)輔助系統(tǒng),通過分析線上訪問日志和靜態(tài)資源組合加載情況來自動(dòng)生成這份配置文件,系統(tǒng)設(shè)計(jì)如圖:

至此,我們通過基于表的靜態(tài)資源管理系統(tǒng)和三個(gè)模板接口實(shí)現(xiàn)了幾個(gè)重要的性能優(yōu)化原則,現(xiàn)在我們?cè)賮砘仡櫼幌虑懊娴男阅軆?yōu)化原則分類表,剔除掉已經(jīng)做到了的,看看還剩下哪些沒做到的:
- 請(qǐng)求數(shù)量: 拆分初始化負(fù)載
- 緩存利用:使用Ajax可緩存
- 頁面結(jié)構(gòu):盡早刷新文檔的輸出
拆分初始化負(fù)載 的目標(biāo)是將頁面一開始加載時(shí)不需要執(zhí)行的資源從所有資源中分離出來,等到需要的時(shí)候再加載。工程師通常沒有耐心去區(qū)分資源的分類情況,但我們可以利用組件化框架接口來幫助工程師管理資源的使用。還是從例子開始思考,如果我們有一個(gè)js文件是用戶交互后才需要加載的,會(huì)怎樣呢:
<html><head>
<title>page</title>
<?php require_static('jquery.js'); ?>
<?php require_static('bootstrap.css'); ?>
<?php require_static('bootstrap.js'); ?>
<!--[ CSS LINKS PLACEHOLDER ]-->
</head>
<body>
<?php load_widget('a'); ?>
<?php load_widget('b'); ?>
<?php load_widget('c'); ?>
<?php script('start'); ?>
<script> $(document.body).click(function(){
require.async('dialog.js', function(dialog){
dialog.show('you catch me!');
});
});
</script>
<?php script('end'); ?>
<!--[ SCRIPTS PLACEHOLDER ]-->
</body>
</html>
很明顯,dialog.js 這個(gè)文件我們不需要在初始化的時(shí)候就加載,因此它應(yīng)該在后續(xù)的交互中再加載,但文件都加了md5戳,我們?nèi)绾文茉跒g覽器環(huán)境中知道加載的url呢?
答案就是:把靜態(tài)資源表的一部分輸出在頁面上,供前端模塊化框架加載靜態(tài)資源。
我就不多解釋代碼的執(zhí)行過程了,大家看到完整的html輸出就能理解是怎么回事了:
<html><head> <title>page</title>
<link rel="stylesheet" type="text/css" href="/pkg/lib_afec33f.css"/>
<link rel="stylesheet" type="text/css" href="/pkg/widgets_af23ce5.css"/><
/head><body>
<div> content of module a </div>
<div> content of module b </div>
<div> content of module c </div>
<script type="text/javascript" src="/pkg/lib_cef213d.js"></script>
<script type="text/javascript" src="/pkg/widgets_22feac1.js"></script>
<script> //將靜態(tài)資源表輸出在前端頁面中
require.config({ res : { 'dialog.js' : '/dialog_fa3df03.js' } });
</script>
<script> $(document.body).click(function(){ //require.async接口查表確定加載資源的url require.async('dialog.js', function(dialog){ dialog.show('you catch me!');
}); });
</script>
</body>
</html>
dialog.js不會(huì)在頁面以script src的形式輸出,而是變成了資源注冊(cè),這樣,當(dāng)頁面點(diǎn)擊觸發(fā)require.async執(zhí)行的時(shí)候,async函數(shù)才會(huì)查表找到資源的url并加載它,加載完畢后觸發(fā)回調(diào)函數(shù)。以上框架示例我實(shí)現(xiàn)了一個(gè)java-jsp版的,有興趣的同學(xué)請(qǐng)看這里:https://github.com/fouber/fis-java-jsp
到目前為止,我們又以架構(gòu)的形式實(shí)現(xiàn)了一項(xiàng)優(yōu)化原則(拆分初始化負(fù)載),回顧我們的優(yōu)化分類表,現(xiàn)在僅有兩項(xiàng)沒能做到了:
- 緩存利用:使用Ajax可緩存
- 頁面結(jié)構(gòu):盡早刷新文檔的輸出
剩下的兩項(xiàng)優(yōu)化原則要做到并不容易,真正可緩存的Ajax在現(xiàn)實(shí)開發(fā)中比較少見,而 盡早刷新文檔的輸出原則facebook在2010年的velocity上 提到過,就是BigPipe技術(shù)。當(dāng)時(shí)facebook團(tuán)隊(duì)還講到了Quickling和PageCache兩項(xiàng)技術(shù),其中的PageCache算是比較徹底的實(shí)現(xiàn)Ajax可緩存的優(yōu)化原則了。由于篇幅關(guān)系,就不在此展開了,后續(xù)還會(huì)撰文詳細(xì)解讀這兩項(xiàng)技術(shù)。
總結(jié)
其實(shí)在前端開發(fā)工程管理領(lǐng)域還有很多細(xì)節(jié)值得探索和挖掘,提升前端團(tuán)隊(duì)生產(chǎn)力水平并不是一句空話,它需要我們能對(duì)前端開發(fā)及代碼運(yùn)行有更深刻的認(rèn)識(shí),對(duì)性能優(yōu)化原則有更細(xì)致的分析與研究。在前端工業(yè)化開發(fā)的所有環(huán)節(jié)均有可節(jié)省的人力成本,這些成本非??捎^,相信現(xiàn)在很多大型互聯(lián)網(wǎng)公司也都有了這樣的共識(shí)。
問題
1.每個(gè)文件改動(dòng)后生產(chǎn)md5后綴,多次上線后線上會(huì)產(chǎn)生:
···
a_xxx1.js
a_xxx2.js
a_xxx3.js
···
也許是我要潔癖,但是這樣循環(huán)N次后,上線的全量包會(huì)越來約大,如何處理這個(gè)的?
- 每次上線,只有修改過的文件才會(huì)出現(xiàn)新的md5戳,所以文件冗余沒有想象中的那么多
比較頻繁修改的業(yè)務(wù)模塊大概每年會(huì)產(chǎn)生100m左右的冗余,預(yù)計(jì)每3年有必要清理一次 - 清理的時(shí)候,寫一個(gè)腳本,根據(jù)文件名規(guī)則找到最后訪問的文件然后刪除其他的?;钪纱嗄炒紊暇€把發(fā)布后的文件之外的其他文件都清理一次,總之這個(gè)不成問題
** 2.HTML是后端們JAVA寫的動(dòng)態(tài)頁面,前端們只寫JS,css,然后靜態(tài)資源發(fā)布后,生成了新的md5,那么JAVA寫的頁面里怎么去獲取這個(gè)新的MD5,以保證加載正確的靜態(tài)資源。是要在前端靜態(tài)文件服務(wù)器上搞個(gè)監(jiān)控,把新的MD5存某個(gè)地方,然后JAVA那邊每次請(qǐng)求頁面都要獲取下新的MD5,替換生成新的鏈接?**
java寫動(dòng)態(tài)頁面不是?不要讓他們?cè)趈ava的模板中寫這樣的代碼:
<script src="a.js"></script>
改成寫這樣的代碼:
<fis:require id="a.js"/>
這個(gè) fis:require
的標(biāo)簽,是擴(kuò)展了jsp的自定義標(biāo)簽。然后,構(gòu)建工具掃描前端寫的js、css,建立一個(gè)map資源表,內(nèi)容大概是:
{ "a.js" : {
"url": "/static/js/a_0fa0c3b.js",
"deps": [ "b.js" ] },
"b.js" : {
"url": "/static/js/b_4cb04f9.js"
}
}
然后,我們把這個(gè)資源表和java的動(dòng)態(tài)頁面放在一起。前面提到的模板中的那個(gè) fis:require 標(biāo)簽,在模板解釋執(zhí)行的時(shí)候,會(huì)去查這個(gè)map表,根據(jù) a.js 這個(gè)資源id找到它的帶md5戳的url就是“/static/js/a_0fa0c3b.js”,同時(shí)還知道這個(gè)文件依賴了 b.js
就順便把b.js的url也收集起來。
最后,在java動(dòng)態(tài)頁面生成html之前,把收集到的兩個(gè)js標(biāo)簽用字符串替換的方式生成script標(biāo)簽插入到頁面上,得到:
<script src="/static/js/a_0fa0c3b.js"></script>
<script src="/static/js/b_4cb04f9.js"></script>
有一個(gè)項(xiàng)目展示了這個(gè)思路的整個(gè)實(shí)現(xiàn)過程: https://github.com/fouber/fis-java-jsp
**
這個(gè) 資源表(map)和fis:require標(biāo)簽是解決這個(gè)問題的重點(diǎn),map是構(gòu)建工具生成的,通過靜態(tài)掃描整個(gè)前端工程代碼得到。map的作用是記錄資源的依賴關(guān)系和部署路徑,然后交給資源管理框架去決定資源加載策略,因此我們最終要把map跟java動(dòng)態(tài)語言部署在一起。fis:require是運(yùn)行在后端動(dòng)態(tài)模板語言中的資源管理框架,它依賴map表的數(shù)據(jù)信息,你可以把它理解成一個(gè)寫在模板引擎中的requirejs。設(shè)計(jì)這個(gè)框架的目的是徹底替<script>標(biāo)簽和<link>標(biāo)簽這種字面量資源定位符,把它們改造成可編程的資源管理框架,在模板渲染的過程中收集頁面所用資源,實(shí)現(xiàn)去重、依賴管理、資源加載、帶md5等等功能**
3、構(gòu)建工具掃描前端寫的js、css,是根據(jù)ID匹配文件名截取文件名上的MD5還是掃描文件內(nèi)容生成MD5?然后生成MAP。
掃描所有文件,計(jì)算文件的摘要,然后生成url。再以文件工程路徑為key,建立map表,整個(gè)過程不會(huì)替換任何文件內(nèi)容,只是建立表。
4、JS源文件是PUSH到server1,然后在server1上fis編譯JS,后端代碼是放server2,構(gòu)建工具是往server1上掃描編譯好后的js吧,還是源文件?
都是線下編譯。線下設(shè)置好js、css要發(fā)布的server1的域名、路徑,然后release,生成編譯后的代碼和map,把代碼發(fā)布到server1上,把map發(fā)布到server2上,map中寫入的js、css的路徑都是符合預(yù)期的。構(gòu)建工具掃描的并不是簡(jiǎn)單的編譯后的結(jié)果。我們用工具讀取所有文件,然后逐個(gè)編譯,然后把編譯后的結(jié)果發(fā)布為帶md5戳的資源,同時(shí)在map中記錄的是 源碼的文件路徑(也就是開發(fā)中的工程路徑)
和 發(fā)布后的資源路徑
的映射關(guān)系,工程路徑 ≠ 部署路徑,它們有很大差別。部署路徑帶md5戳,而且可能變換了發(fā)布目錄。這樣我們采用源碼的工程路徑作為文件id,在java等動(dòng)態(tài)語言中也可以使用工程路徑去加載資源,看起來非常符合人類的直覺。
5、我們后端是groovy語言和grails框架寫的頁面,fis支持嗎?
其他語言可以根據(jù)fis的map.json結(jié)構(gòu),和fis資源管理的思想自己實(shí)現(xiàn)這個(gè)框架,并不復(fù)雜
6.map.json的升級(jí)問題,有兩個(gè)方案:
- 非覆蓋式發(fā)布map.json,配置fis,讓map.json發(fā)布的時(shí)候帶一個(gè)構(gòu)建時(shí)間戳,然后把這個(gè)時(shí)間戳寫入到j(luò)ava模板中,先發(fā)布map.json,但是線上運(yùn)行的java頁面讀取的還是舊的map,然后部署模板,模板中聲明了使用新版本的map.json,問題解決
- 持久化模板中的map數(shù)據(jù)。模板引擎一般只有再模板修改后才會(huì)重新編譯模板,你把讀取map的邏輯變成編譯后靜態(tài)寫入的結(jié)果,下次上線后,先覆蓋map.json,這個(gè)時(shí)候所有模板都還只是使用上一個(gè)版本的map數(shù)據(jù),然后發(fā)布模板,再觸發(fā)一下模板編譯,讀入新的map
7.放在require.asyn里面 一樣是異步加載 但是在編譯的時(shí)候 直接把md5后的名字替換了dialog.js這名字 瀏覽器運(yùn)行時(shí) 在需要的時(shí)候加載的也還是對(duì)應(yīng)的資源
require.async要做兩件事,一個(gè)是加載資源,一個(gè)是加載完成后回調(diào)。
加載資源不僅僅是加載資源本身,還要加載依賴的資源,以及依賴的依賴。比如這個(gè)dialog.js,并不是獨(dú)立資源,它可能還會(huì)依賴其他文件,假設(shè)它依賴了component.js和dialog.css兩個(gè)資源,component.js又依賴component.css,那么我們得到一顆依賴樹:
dialog.js
├ dialog.css
└ component.js
└ component.css
問題來了,我們?cè)趺锤嬖Vrequire.async,在加載dialog.js的時(shí)候,要一并加載其他3個(gè)資源呢?我們勢(shì)必要將依賴關(guān)系表放在前端才能實(shí)現(xiàn)這個(gè)優(yōu)化,也就有了針對(duì)require.async加載的依賴配置項(xiàng)。有這個(gè)依賴表,還意味著我們根本沒必要把 require.async(id, callback)
接口設(shè)計(jì)成 require.async(url, callback)
,因?yàn)楸A鬷d,在查詢依賴關(guān)系的時(shí)候最方便。
當(dāng)然,你或許會(huì)想到“我們用文件的url建立依賴關(guān)系不就行了么?”,這里還涉及到另外一個(gè)問題,就是我們加載dialog.js,未必就是加載dialog.js這個(gè)文件的獨(dú)立url,如果它被打包了,我們其實(shí)要加載的是它所在資源包的url,比如dialog.js和component.js合并成了aio.js,我們雖然require.async('dialog.js'),但實(shí)際上請(qǐng)求的是aio.js這個(gè)url。
你或許又想到了“我們用構(gòu)建工具把require.async的資源路徑改成打包后的url地址不就行了?”,恩,這里又涉及到另外一個(gè)資源加載問題:動(dòng)態(tài)請(qǐng)求。比如我們需要根據(jù)一些運(yùn)行時(shí)的參數(shù)來加載模塊:
var mod = isIE ? 'fuck.js' : 'nice.js';
require.async(mod, function(m){
//blablabla
});
前端只有資源表的好處是支持動(dòng)態(tài)加載模塊,只要把依賴表輸出給前端,就能實(shí)現(xiàn)真正的按需加載,這是單純的靜態(tài)分析所無法實(shí)現(xiàn)的。
此外,require.async還要監(jiān)聽資源加載完畢時(shí)間,require.async(id, callback)
這樣的設(shè)計(jì),可以讓define(id, factory)接口被調(diào)用的時(shí)候,根據(jù)id派發(fā)模塊加載完畢事件,如果把require.async設(shè)計(jì)成使用url作為參數(shù),那就要改成通過監(jiān)聽script的onload事件來判斷資源加載完成與否,這樣也麻煩一些。
8.實(shí)際開發(fā)debug調(diào)試的時(shí)候和最終打包發(fā)布線上這之間是如何區(qū)分的
這其實(shí)是一個(gè)構(gòu)建工具的使用技巧,本地開發(fā)和上線部署的構(gòu)建過程稍微有一些差別而已,上線部署的構(gòu)建過程需要給資源加上domain。
以fis為例,我們把壓縮、資源合并、加md5,加域名等構(gòu)建操作變成命令行的參數(shù),比如我們本地開發(fā)這樣的命令:
fis release --dest ../dev
就是構(gòu)建一下代碼,把結(jié)果發(fā)布到dev目錄下,然后我們?cè)赿ev目錄下啟動(dòng)服務(wù)器進(jìn)行本地開發(fā)調(diào)試,而當(dāng)我們要提測(cè)的時(shí)候,并不是用dev目錄的東西,而是真的源碼又發(fā)布一次:
fis release --optimize --hash --pack --dest ../test
這回,我們對(duì)代碼進(jìn)行了壓縮、加md5、資源合并操作,并發(fā)布到了另外一個(gè)test目錄中,測(cè)試是在test目錄下進(jìn)行的。
最終上線,我們也不是使用的test目錄下的代碼,而是又從源碼重新發(fā)布一份:
fis release --optimize --hash --pack --domain --dest ../prod
有多了一個(gè) --domain 參數(shù),給資源加上CDN的域名,最終上線用的是prod里的代碼。設(shè)計(jì)原則是始終從源碼構(gòu)建出結(jié)果,構(gòu)建結(jié)果可能是開發(fā)中的,可能是提測(cè)用的,也可能是部署到生產(chǎn)環(huán)境的。
作為構(gòu)建構(gòu)建,至少要保證針對(duì)不同環(huán)境的構(gòu)建代碼邏輯是等價(jià)的,不能引入額外的不確定因素導(dǎo)致測(cè)試和部署結(jié)果不一致
摘自fouber前端工程與性能優(yōu)化