本系列文章從一個全新的視角來思考web性能優化與前端工程之間的關系,通過解讀百度前端集成解決方案小組(F.I.S)在打造高性能前端架構并統一百度40多條前端產品線的過程中所經歷的技術嘗試,揭示前端性能優化在前端架構及開發工具設計層面的實現思路。
靜態資源管理與模板框架
讓我們再來看看前面的優化原則表還剩些什么:
?
優化方向 |
優化手段
?
|
請求數量 |
合并腳本和樣式表,拆分初始化負載 |
請求帶寬 |
移除重復腳本 |
緩存利用 |
使Ajax可緩存 |
頁面結構 |
將樣式表放在頂部,將腳本放在底部,盡早刷新文檔的輸出 |
很不幸,剩下的優化原則都不是使用工具就能很好實現的。或許有人會辯駁:“我用某某工具可以實現腳本和樣式表合并”。嗯,必須承認,使用工具進行資源合并并替換引用或許是一個不錯的辦法,但在大型web應用,這種方式有一些非常嚴重的缺陷,來看一個很熟悉的例子:
某個web產品頁面有A、B、C三個資源
工程師根據“減少HTTP請求”的優化原則合并了資源
產品經理要求C模塊按需出現,此時C資源已出現多余的可能
C模塊不再需要了,注釋掉吧!但C資源通常不敢輕易剔除
不知不覺中,性能優化變成了性能惡化……
事實上,使用工具在線下進行靜態資源合并是無法解決資源按需加載的問題的。如果解決不了按需加載,則勢必會導致資源的冗余;此外,線下通過工具實現的資源合并通常會使得資源加載和使用的分離,比如在頁面頭部或配置文件中寫資源引用及合并信息,而用到這些資源的html組件寫在了頁面其他地方,這種書寫方式在工程上非常容易引起維護不同步的問題,導致使用資源的代碼刪除了,引用資源的代碼卻還在的情況。因此,在工業上要實現資源合并至少要滿足如下需求:
- 確實能減少HTTP請求,這是基本要求(合并)
- 在使用資源的地方引用資源(就近依賴),不使用不加載(按需)
- 雖然資源引用不是集中書寫的,但資源引用的代碼最終還能出現在頁面頭部(css)或尾部(js)
- 能夠避免重復加載資源(去重)
將以上要求綜合考慮,不難發現,單純依靠前端技術或者工具處理是很難達到這些理想要求的。現代大型web應用所展示的頁面絕大多數都是使用服務端動態語言拼接生成的。有的產品使用模板引擎,比如smarty、velocity,有的則干脆直接使用動態語言,比如php、python。無論使用哪種方式實現,前端工程師開發的html絕大多數最終都不是以靜態的html在線上運行的。
接下來我會講述一種新的模板架構設計,用以實現前面說到那些性能優化原則,同時滿足工程開發和維護的需要,這種架構設計的核心思想就是:
基于依賴關系表的靜態資源管理系統與模板框架設計
考慮一段這樣的頁面代碼:
根據資源合并需求中的第二項,我們希望資源引用與使用能盡量靠近,這樣將來維護起來會更容易一些,因此,理想的源碼是:
當然,把這樣的頁面直接送達給瀏覽器用戶是會有嚴重的頁面閃爍問題的,所以我們實際上仍然希望最終頁面輸出的結果還是如最開始的截圖一樣,將css放在頭部輸出。這就意味著,頁面結構需要有一些調整,并且有能力收集資源加載需求,那么我們考慮一下這樣的源碼:
在頁面的頭部插入一個html注釋“<!--[CSS LINKS PLACEHOLDER]-->”作為占位,而將原來字面書寫的資源引用改成模板接口(require)調用,該接口負責收集頁面所需資源。require接口實現非常簡單,就是準備一個數組,收集資源引用,并且可以去重。最后在頁面輸出的前一刻,我們將require在運行時收集到的“A.css”、“B.css”、“C.css”三個資源拼接成html標簽,替換掉注釋占位“<!--[CSS LINKS PLACEHOLDER]-->”,從而得到我們需要的頁面結構。
經過fis團隊的總結,我們發現模板層面只要實現三個開發接口,既可以比較完美的實現目前遺留的大部分性能優化原則,這三個接口分別是:
- require(String id):收集資源加載需求的接口,參數是資源id。
- widget(String template_id):加載拆分成小組件模板的接口。你可以叫它為load、component或者pagelet之類的。總之,我們需要一個接口把一個大的頁面模板拆分成一個個的小部分來維護,最后在原來的大頁面以組件為單位來加載這些小部件。
- script(String code):收集寫在模板中的js腳本,使之出現的頁面底部,從而實現性能優化原則中的“將js放在頁面底部”原則。
實現了這些接口之后,一個重構后的模板頁面的源代碼可能看起來就是這樣的了:
而最終在模板解析的過程中,資源收集與去重、頁面script收集、占位符替換操作,最終從服務端發送出來的html代碼為:
不難看出,我們目前已經實現了“按需加載”,“將腳本放在底部”,“將樣式表放在頭部”三項優化原則。
前面講到靜態資源在上線后需要添加hash戳作為版本標識,那么這種使用模板語言來收集的靜態資源該如何實現這項功能呢?答案是: 靜態資源依賴關系表 。
假設前面講到的模板源代碼所對應的目錄結構為下圖所示:
那么我們可以使用工具掃描整個project目錄,然后創建一張資源表,同時記錄每個資源的部署路徑,可以得到這樣的一張表:
基于這張表,我們就很容易實現 {require name=”id”} 這個模板接口了。只須查表即可。比如執行{require name=”jquery.js”},查表得到它的url是“/jquery_9151577.js”,聲明一個數組收集起來就好了。這樣,整個頁面執行完畢之后,收集資源加載需求,并替換頁面的占位符,即可實現資源的hash定位,得到:
接下來,我們討論如何在基于表的設計思想上是如何實現靜態資源合并的。或許有些團隊使用過combo服務,也就是我們在最終拼接生成頁面資源引用的時候,并不是生成多個獨立的link標簽,而是將資源地址拼接成一個url路徑,請求一種線上的動態資源合并服務,從而實現減少HTTP請求的需求,比如:
這個“/combo?files=file1,file2,file3,…”的url請求響應就是動態combo服務提供的,它的原理很簡單,就是根據get請求的files參數找到對應的多個文件,合并成一個文件來響應請求,并將其緩存,以加快訪問速度。
這種方法很巧妙,有些服務器甚至直接集成了這類模塊來方便的開啟此項服務,這種做法也是大多數大型web應用的資源合并做法。但它也存在一些缺陷:
- 瀏覽器有url長度限制,因此不能無限制的合并資源。
- 如果用戶在網站內有公共資源的兩個頁面間跳轉訪問,由于兩個頁面的combo的url不一樣導致用戶不能利用瀏覽器緩存來加快對公共資源的訪問速度。
對于上述第二條缺陷,可以舉個例子來看說明:
- 假設網站有兩個頁面A和B
- A頁面使用了a,b,c,d四個資源
- B頁面使用了a,b,e,f四個資源
-
如果使用combo服務,我們會得:
- A頁面的資源引用為:/combo?files=a,b,c,d
- B頁面的資源引用為:/combo?files=a,b,e,f
- 兩個頁面引用的資源是不同的url,因此瀏覽器會請求兩個合并后的資源文件,跨頁面訪問沒能很好的利用a、b這兩個資源的緩存。
很明顯,如果combo服務能聰明的知道A頁面使用的資源引用為“/combo?files=a,b”和“/combo?files=c,d”,而B頁面使用的資源引用為“/combo?files=a,b”,“/combo?files=e,f”就好了。這樣當用戶在訪問A頁面之后再訪問B頁面時,只需要下載B頁面的第二個combo文件即可,第一個文件已經在訪問A頁面時緩存好了的。
基于這樣的思考,fis在資源表上新增了一個字段,取名為“pkg”,就是資源合并生成的新資源,表的結構會變成:
相比之前的表,可以看到新表中多了一個pkg字段,并且記錄了打包后的文件所包含的獨立資源。這樣,我們重新設計一下{require name=”id”}這個模板接口: 在查表的時候,如果一個靜態資源有 pkg 字段,那么就去加載pkg 字段所指向的打包文件,否則加載資源本身 。比如執行{require name=”bootstrap.css”},查表得知bootstrap.css被打包在了“p0”中,因此取出p0包的url“/pkg/utils_b967346.css”,并且記錄頁面已加載了“bootstrap.css”和“A/A.css”兩個資源。這樣一來,之前的模板代碼執行之后得到的html就變成了:
css資源請求數由原來的4個減少為2個。
這樣的打包結果是怎么來的呢?答案是 配置得到 的。
我們來看一下帶有打包結果的資源表的fis配置:
我們將“bootstrap.css”、“A/A.css”打包在一起,其他css另外打包,從而生成兩個打包文件,當頁面需要打包文件中的資源時,模塊框架就會收集并計算出最優的資源加載結果,從而解決靜態資源合并的問題。
這樣做的原因是為了彌補combo在前面講到的兩點技術上的不足而設計的。但也不難發現這種打包策略是需要配置的,這就意味著維護成本的增加。但好在它有兩個優勢可以一定程度上彌補這個問題:
- 打包的資源只是原來獨立資源的備份。打包與否不會導致資源的丟失,最多是沒有合并的很好而已。
- 配置可以由工程師根據經驗人工維護,也可以由統計日志生成,這為性能優化自適應網站設計提供了非常好的基礎。
關于第二點,fis有這樣輔助系統來支持自適應打包算法:
至此,我們通過基于表的靜態資源管理系統和三個模板接口實現了幾個重要的性能優化原則,現在我們再來回顧一下前面的性能優化原則分類表,剔除掉已經做到了的,看看還剩下哪些沒做到的:
優化方向 |
優化手段 |
請求數量 |
拆分初始化負載 |
緩存利用 |
使Ajax可緩存 |
頁面結構 |
盡早刷新文檔的輸出 |
?
“拆分初始化負載”的目標是將頁面一開始加載時不需要執行的資源從所有資源中分離出來,等到需要的時候再加載。工程師通常沒有耐心去區分資源的分類情況,但我們可以利用組件化框架接口來幫助工程師管理資源的使用。還是從例子開始思考:
模板源代碼
在fis給百度內部團隊開發的架構中,如果這樣書寫代碼,頁面最終的執行結果會變成:
模板運行后輸出的html代碼
fis系統會分析頁面中require(id)函數的調用,并將依賴關系記錄到資源表對應資源的 deps 字段中,從而在頁面渲染查表時可以加載依賴的資源。但此時dialog.js是以script標簽的形式同步加載的,這樣會在頁面初始化時出現資源的浪費。因此,fis團隊提供了require.async的接口,用于異步加載一些資源,源碼修改為:
這樣書寫之后,fis系統會在表里以 async 字段來標準資源依賴關系是異步的。fis提供的靜態資源管理系統會將頁面輸出的結果修改為:
dialog.js不會在頁面以script src的形式輸出,而是變成了資源注冊,這樣,當頁面點擊按鈕觸發require.async執行的時候,async函數才會查表找到資源的url并加載它,加載完畢后觸發回調函數。
到目前為止,我們又以架構的形式實現了一項優化原則(拆分初始化負載),回顧我們的優化分類表,現在僅有兩項沒能做到了:
優化方向 |
優化手段 |
緩存利用 |
使Ajax可緩存 |
頁面結構 |
盡早刷新文檔的輸出 |
?
剩下的兩項優化原則要做到并不容易,真正可緩存的Ajax在現實開發中比較少見,而盡早刷新文檔的輸出的情況facebook在2010年的velocity上提到過,就是BigPipe技術。當時facebook團隊還講到了Quickling和PageCache兩項技術,其中的PageCache算是比較徹底的實現Ajax可緩存的優化原則了。fis團隊也曾與某產品線合作基于靜態資源表、模板組件化等技術實現了頁面的PipeLine輸出、以及Quickling和PageCache功能,但最終效果沒有達到理想的性能優化預期,因此這兩個方向尚在探索中,相信在不久的將來會有新的突破。
?
本文只是將這個領域中很小的一部分知識的展開討論,拋磚引玉,希望能為業界相關領域的工作者提供一些不一樣的思路。歡迎關注 fis項目 ,對本文有任何意見或建議都可以在fis開源項目中進行反饋和討論。
更多文章、技術交流、商務合作、聯系博主
微信掃碼或搜索:z360901061

微信掃一掃加我為好友
QQ號聯系: 360901061
您的支持是博主寫作最大的動力,如果您喜歡我的文章,感覺我的文章對您有幫助,請用微信掃描下面二維碼支持博主2元、5元、10元、20元等您想捐的金額吧,狠狠點擊下面給點支持吧,站長非常感激您!手機微信長按不能支付解決辦法:請將微信支付二維碼保存到相冊,切換到微信,然后點擊微信右上角掃一掃功能,選擇支付二維碼完成支付。
【本文對您有幫助就好】元
