每個參與過開發企業級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頭”所說的原則一樣——修訂文件名。即:
思路沒錯,但要怎么改變鏈接呢?變成什么樣的鏈接才能有效更新緩存,又能最大限度避免那些沒有修改過的文件緩存不失效呢? 先來看看現在一般前端團隊的做法:
接下來,項目升級,比如頁面上的html結構發生變化,對應還要修改 a.js 這個文件,得到的構建結果如下: 為了觸發用戶瀏覽器的緩存更新,我們需要更改靜態資源的url地址,如果采用構建信息(時間戳、版本號等)作為url修改的依據,如上述代碼所示,我們只修改了一個a.js文件,但再次構建會讓所有請求都更改了url地址,用戶再度訪問頁面那些沒有修改過的靜態資源的(b.js,b.js,c.js,d.js,e.js)的瀏覽器緩存也一同失效了。
此外,采用添加query的方式來清除緩存還有一個弊端,就是 覆蓋式發布 的上線問題。 采用query更新緩存的方式實際上要覆蓋線上文件的,index.html和a.js總有一個先后的順序,從而中間出現一段或大或小的時間間隔。尤其是當頁面是后端渲染的模板的時候,靜態資源和模板是部署在不同的機器集群上的,上線的過程中,靜態資源和頁面文件的部署時間間隔可能會非常長,對於一個大型互聯網應用來說即使在一個很小的時間間隔內,都有可能出現新用戶訪問。在這個時間間隔中,訪問了網站的用戶會發生什么情況呢?
這就是為什么大型web應用在版本上線的過程中經常會較集中的出現前端報錯日志的原因,也是一些互聯網公司選擇加班到半夜等待訪問低峰期再上線的原因之一。 對於靜態資源緩存更新的問題,目前來說最優方案就是 基於文件內容的hash版本冗余機制 了。也就是說,我們希望項目源碼是這么寫的:
也就是a.js發布出來后被修改了文件名,產生一個新文件,並不是覆蓋已有文件。其中”_82244e91”這串字符是根據a.js的文件內容進行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中的引用。
grunt等task-based的工具是很難在task之間協作處理這樣的需求的。 在解決了基於內容hash的版本更新問題之后,我們可以將所有前端靜態資源開啟永久強緩存,每次版本發布都可以首先讓靜態資源全量上線,再進一步上線模板或者頁面文件,再也不用擔心各種緩存和時間間隙的問題了! 靜態資源管理與模塊化框架 解決了靜態資源緩存問題之后,讓我們再來看看前面的優化原則表還剩些什么: 優化方向優化手段請求數量合並腳本和樣式表,拆分初始化負載 請求帶寬移除重復腳本 緩存利用使Ajax可緩存 頁面結構將樣式表放在頂部,將腳本放在底部,盡早刷新文檔的輸出 很不幸,剩下的優化原則都不是使用工具就能很好實現的。或許有人會辯駁:“我用某某工具可以實現腳本和樣式表合並”。嗯,必須承認,使用工具進行資源合並並替換引用或許是一個不錯的辦法,但在大型web應用,這種方式有一些非常嚴重的缺陷,來看一個很熟悉的例子 : 某個web產品頁面有A、B、C三個資源 工程師根據“減少HTTP請求”的優化原則合並了資源 產品經理要求C模塊按需出現,此時C資源已出現多余的可能 C模塊不再需要了,注釋掉吧!代碼1秒鍾搞定,但C資源通常不敢輕易剔除 不知不覺中,性能優化變成了性能惡化……
事實上,使用工具在線下進行靜態資源合並是無法解決資源按需加載的問題的。如果解決不了按需加載,則必會導致資源的冗余;此外,線下通過工具實現的資源合並通常會使得資源加載和使用的分離,比如在頁面頭部或配置文件中寫資源引用及合並信息,而用到這些資源的html組件寫在了頁面其他地方,這種書寫方式在工程上非常容易引起維護不同步的問題,導致使用資源的代碼刪除了,引用資源的代碼卻還在的情況。因此,在工業上要實現資源合並至少要滿足如下需求:
將以上要求綜合考慮,不難發現,單純依靠前端技術或者工具處理是很難達到這些理想要求的。 接下來我會講述一種新的模板架構設計,用以實現前面說到那些性能優化原則,同時滿足工程開發和維護的需要,這種架構設計的核心思想就是:
考慮一段這樣的頁面代碼:
在頁面的頭部插入一個html注釋 <!--[CSS LINKS PLACEHOLDER]--> 作為占位,而將原來字面書寫的資源引用改成模板接口 require_static 調用,該接口負責收集頁面所需資源。 require_static接口實現非常簡單,就是准備一個數組,收集資源引用,並且可以去重。最后在頁面輸出的前一刻,我們將require_static在運行時收集到的 a.css、b.css、c.css 三個資源拼接成html標簽,替換掉注釋占位 <!--[CSS LINKS PLACEHOLDER]-->,從而得到我們需要的頁面結構。 經過實踐總結,可以發現模板層面只要實現三個開發接口,就可以比較完美的實現目前遺留的大部分性能優化原則,這三個接口分別是:
實現了這些接口之后,一個重構后的模板頁面的源代碼可能看起來就是這樣的了:
不難看出,我們目前已經實現了 按需加載,將腳本放在底部,將樣式表放在頭部 三項優化原則。 前面講到靜態資源在上線后需要添加hash戳作為版本標識,那么這種使用模板語言來收集的靜態資源該如何實現這項功能呢?
考慮這樣的目錄結構:
這個 /??file1,file2,file3,… 的url請求響應就是動態combo服務提供的,它的原理很簡單,就是根據url找到對應的多個文件,合並成一個文件來響應請求,並將其緩存,以加快訪問速度。 這種方法很巧妙,有些服務器甚至直接集成了這類模塊來方便的開啟此項服務,這種做法也是大多數大型web應用的資源合並做法。但它也存在一些缺陷:
對於上述第二條缺陷,可以舉個例子來看說明:
很明顯,如果combo服務能聰明的知道A頁面使用的資源引用為 /??a,b 和 /??c,d,而B頁面使用的資源引用為 /??a,b 和 /??e,f就好了。這樣當用戶在訪問A頁面之后再訪問B頁面時,只需要下載B頁面的第二個combo文件即可,第一個文件已經在訪問A頁面時緩存好了的。 基於這樣的思考,我們在資源表上新增了一個字段,取名為 pkg,就是資源合並生成的新資源,表的結構會變成: 相比之前的表,可以看到新表中多了一個pkg字段,並且記錄了打包后的文件所包含的獨立資源。這樣,我們重新設計一下 require_static、load_widget 這兩個模板接口,實現這樣的邏輯:
比如執行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並加載它,加載完畢后觸發回調函數。 到目前為止,我們又以架構的形式實現了一項優化原則(拆分初始化負載),回顧我們的優化分類表,現在僅有兩項沒能做到了: 優化方向優化手段緩存利用使Ajax可緩存 頁面結構盡早刷新文檔的輸出 剩下的兩項優化原則要做到並不容易,真正可緩存的Ajax在現實開發中比較少見,而 盡早刷新文檔的輸出原則facebook在2010年的velocity上 提到過,就是BigPipe技術。當時facebook團隊還講到了Quickling和PageCache兩項技術,其中的PageCache算是比較徹底的實現Ajax可緩存的優化原則了。由於篇幅關系,就不在此展開了,后續還會撰文詳細解讀這兩項技術。 總結 其實在前端開發工程管理領域還有很多細節值得探索和挖掘,提升前端團隊生產力水平並不是一句空話,它需要我們能對前端開發及代碼運行有更深刻的認識,對性能優化原則有更細致的分析與研究。在前端工業化開發的所有環節均有可節省的人力成本,這些成本非常可觀,相信現在很多大型互聯網公司也都有了這樣的共識。 |