興趣部落的前端性能優化實踐概覽

本文對興趣部落項目前端開發中使用到的性能優化方式進行總結。興趣部落項目是手機QQ(以下簡稱手Q)中最大的純網頁應用,每日有大量的用戶訪問,對於騰訊這樣一個對產品有著極致要求的公司,性能優化是一個繞不開的話題。下面就對項目中所使用的性能優化的方式做一個梳理。

離線包

Hybrid App 最大的一個問題就是頁面需要從網路拉取,所以載入速度慢,從而影響用戶體驗,興趣部落中使用一個叫做 AlloyKit Mobile 的技術架構,這種技術架構中包含很多有利於提升性能的模塊,這裡要說的是離線包模塊。

離線包模塊可以允許手Q 永久緩存 HTML、JS、CSS 等靜態資源,從而當用戶訪問網頁時實現「秒開」,統計數據顯示,使用離線包能夠提升 85% 以上的速度。那有人問,既然這樣, 為什麼不把所有靜態資源一起打個包,然後在 APP 發布時一起包進去呢?其實這樣做就失去了 Hybrid App 的一個優勢:快速迭代,因為網頁中的靜態資源的更新必須跟隨 APP 更新的節奏,而我們知道 APP 更新頻率相比於一般的網頁應用是相當慢的,而離線包保持了網頁應用快速迭代的優良傳統。

那離線包是如何保持快速迭代的優良傳統的呢?這要從離線包的更新原理來說了。首先需要事先說明的是,並不是所有的網頁都被放入到離線包里的,那樣的話,離線包還不得爆炸!一般只會放幾個非常重要的網頁,比如興趣部落里就放三個最重要的頁面到離線包里。對於這幾個最重要的頁面,請求資源時會在 URL 中帶有 _bid 參數。每次請求帶有 _bid 的 URL 時,都會詢問客戶端是否有離線包,有的話則走離線包,否則正常訪問網路。另外,還會發出去一個請求,檢查離線包有沒有更新,如果離線包有更新,那麼下載離線包,以便下次使用。當然內部還會更加複雜,比如會檢查上次檢查更新的時間,時間間隔太短的話就不會去檢查更新,再比如離線包使用了 CMEM 做緩存等等。現在你知道為什麼手Q 用了一段時間後體積變大了很多吧,逃)當然你可以在手Q 的「設置-聊天記錄-清空緩存數據」來清除所有的離線包等緩存資源。

那每次更新是否將離線包里所有的東西都更新呢?非也。比如說這次新增了一個圖片,新的離線包只會更新這一張圖片,而不會更新所有離線包內容,這在離線包里被稱為「增量包」。又有人問,如果是修改一個文件里的一小部分內容呢,那也需要將這一個文件更新嗎?亦非也。其實增量包分為兩種,一種叫「基於文件的增量包」,剛剛新增一張圖片就屬於這種情況。還有一種叫「bsdiff 增量包」,它採用二進位方式對比,生成的增量包僅僅包含變化的那一小部分,這使得更新效率更高。

關於離線包還有很多可以聊的,比如剛發布的離線包發現錯誤應該咋辦?現在的策略是用戶點擊了「興趣部落」後才會下載離線包,下次再次訪問時才能使用離線包,如果我等不及,而想用戶第一次就能訪問離線包怎麼辦?還有等等的一系列問題。後面可以再寫一篇博客,著重講解一下離線包。

資源載入策略

在資源載入上做優化的根本原因就是網路通信時間佔據了響應時間的大部分,很多的性能優化都是從這方面著手的,包括上面的「離線包」策略,但因為「離線包」實在太過重要,所以將之單獨抽出來講。下面講一講其他的資源載入策略。

構建

因為興趣部落開始於 2014 年,所以項目採用 grunt 作為構建工具,以及使用 webpack 來打包,最近剛剛升級到 webpack2。我們使用 grunt 來對文件進行 minify、uglify,以及對部分文件進行 concat,從而有效地減小文件的大小以及向服務端請求的次數。簡單介紹下上面三種構建的策略,minify 是指去除代碼中的空白以及一些多餘的字元,比如分號;uglify 從名字上看是「醜化」,處理之後的代碼幾乎無法閱讀,乍一看它是用來混亂代碼的,但它其實最大的作用是壓縮代碼,混亂代碼可以使用 obfuscate;concat 就是將幾個靜態文件合併成一個文件。

另外,還使用 webpack 對文件進行打包,也能夠減少向服務端請求的次數。前一段時間發布了 webpack3,其中一個特性就是 Scope Hoisting,簡單點介紹就是 webpack2 以及 webpack1 會將每個模塊都打包在一個單獨的閉包中,這些閉包是拖累瀏覽器速度的一個因素,而 Scope Hoisting 解決這個問題的方案就是將不同的模塊打包在一個大的閉包中,從而提升代碼運行效率。不過技術新出,不能完全了解裡面的坑,將之正式使用到這樣一個大型項目中還需要一段時間。

資源按需載入

這個方式很常見,即在打開頁面時不必要載入所有內容,而是根據自己的需要來載入。比如下面這個頁面:

首頁選擇最上面的「排行」菜單時,只會載入類目列表以及明星列表的部分內容,當下拉明星列表時,還會根據需要再次向伺服器請求列表後面的內容。

模塊按需載入

還是以上面的圖舉例,當我點擊屏幕下方的「部落「Tab 時,」部落「組件才會被載入,背後是利用 webpack 的 require.ensure() 實現的。

減少不必要的通信

很多時候存在多餘通信的情況,比如最近我在做的一個「紅點需求」,簡單點講就是本來需要向伺服器發送大量請求,經過優化,將請求數量減少了到了幾乎為 0,這些都可以說是不必要的請求。下一段是對這個過程的詳細描述,自己都覺得有些啰嗦,不感興趣的可以直接略過哈。

看下圖,在興趣部落「我的」面板,「我的」 Tab 會根據「留言消息」、「任務領心」和「系統通知」的紅點而確定,也就是說只要這三者有一個有紅點,那麼「我的」 Tab 上就會有紅點,一般情況下「留言消息」和「系統通知」沒有紅點,而「任務領心」產品經理給的需求是:有紅點時點進去就消除紅點,如果之後再做一些加心的操作,那紅點重新出現。而加心的操作有很多,在很多頁面用戶都可能會做一些加心的操作,所以「任務領心」的紅點會一會兒出現一會兒消失,緊隨著的是「我的」 Tab 上的紅點也會一會兒出現一會兒消失。問題就出現在這,因為「我的」 Tab 是首頁的一部分,所以每次進入首頁時都要跟伺服器請求一次,而首頁的 PV 是非常巨大的,相對應的紅點狀態的請求數量也非常巨大。所以想到的一個策略就是當用戶操作加心時,利用 localStorage 設置一個標誌位,回到首頁時就讀取 localStorage 里的標誌位,根據這個標誌位來判斷是否顯示紅點,當點擊「任務領心」時,就把標誌位歸位。這樣,頓時減少了上億的請求。

上面只是做一個簡單的舉例,項目中還有很多這樣的地方。除此以外,如果一個頁面需要發出多個請求,還會根據實際情況合併一些請求。這些細小的改動在請求數量較小時可能沒多大影響,但每秒 PV 以萬計時,將會使得請求數量大大減少,自然優化了性能。

緩存

localStorage:因為 WebView 的緣故,手Q也能同普通瀏覽器一樣使用 localStorage。項目中有很多場景需要緩存,比如有些功能基於地理位置的,第一次獲取到的經緯度數據可以使用 localStorage 緩存起來,當一定時間內再次需要獲取地理位置時,直接使用緩存即可,除了緩存地理位置本身,還會緩存根據地理信息獲得的數據,經過一定演算法計算,在一定範圍內可以直接從緩存中獲取這些數據,避免重複請求數據;

靜態資源緩存:一般對靜態資源的 Response Header 加上 cache-control:max-age=3600以及 expires:***來緩存這些靜態資源。

HTTP/2

看上面的圖片,很多的資源載入的協議已經使用了 HTTP/2,再看 Connection ID 一欄,你會發現有的資源的 Connection ID 是一樣的,這說明什麼呢?這就是 HTTP/2 中的一個很重要的特性:多路復用。除了多路復用,當然還利用了 HTTP/2 的一些其他特性。

圖片資源載入

圖片佔據了絕大部分的帶寬,所以優化圖片對性能優化作用巨大,我們在項目中用到了以下技術:

Base64:將一些小的、程序中用到的圖片用 Base64 表示,減少請求次數;

WebP:一些圖片使用 WebP 格式,使得圖片壓縮地更小;

sharpP:騰訊自己的一套壓圖方案,幀壓縮效率比 WebP 高 31%,比 jpeg 高 43%。

DNS 預獲取

網路請求中域名解析一般會花費很大部分時間,而加了 dns-prefetch 可以提前去解析資源的域名,這樣可以減少網路請求時間。

<meta http-equiv="x-dns-prefetch-control" content="on">n<link rel="dns-prefetch" href="http://www.spreadfirefox.com/">n

可以在 HTML 頁面中加入上面的代碼來使用 DNS 預獲取功能,有的支持 dns-prefetch 的瀏覽器並不需要第一行代碼。

直出

目前業界普遍的做法就是前後端分離,服務端提供數據,客戶端根據數據渲染頁面,前後端分離在這裡其實也就是數據與渲染邏輯分離。直出,幾乎等同於服務端渲染,是在服務端將數據通過渲染邏輯生成一個頁面,然後將生成的頁面直接傳給客戶端。為什麼這種方式會比客戶端渲染有更高的性能呢?看兩張圖:

(圖片來源:InfoQ)

上面兩張圖分別是服務端渲染(Server Side Rendering, SSR)和客戶端渲染(Client Side Rendering)。我們仔細看一下圖中請求資源到頁面展示的一個簡單過程。在 SSR 中,服務端接收到請求後就在服務端將 HTML 頁面生成好,然後將之返回給客戶端,客戶端拿到完整的 HTML 很快便能夠將頁面渲染出來,用戶便能看到頁面,與此同時,客戶端也在拉取 JS 等其他資源,當 JS 拉取到本地並執行完後,頁面就變得可交互了。而客戶端的過程是將數據、JS 等資源拉取到本地,由本地執行 JS,然後渲染頁面,渲染出的頁面可以立即交互。對比上面兩種渲染方式可以看出,服務端渲染以客戶看到頁面為第一要務,也就是很多公司考核的首屏載入時間,交互可以放在次要的位置。

在興趣部落中,我們利用「玄武」框架來逐漸實現服務端渲染。玄武為古代四大神獸之一,是烏龜和蛇的合體,烏龜象徵長壽、穩重,蛇象徵靈敏,命名寄託了開發者想把它做成一個即穩定又靈活可拓展的 Web 應用框架的希冀。玄武框架基於 koa,可以使得開發者只需要關注業務邏。

合併上報

但凡線上項目,沒幾個不上報數據的,上報數據就會進行網路通信,而數據上報跟性能息息相關,如何處理數據上報便是一個很重要的問題。興趣部落同樣也涉及很多上報,比如老闆關心的 DAU(Daily Active User,日活躍用戶數量),產品經理關心的 PV(Page View,頁面瀏覽量)、UV(Unique Visitor,獨立訪客),運營關心的引流,開發關心的腳本錯誤量等等,那這些數據從哪裡來?都是通過數據上報。而興趣部落項目訪問量巨大,現在日常 PV 達數十億,每一次 PV 都會伴隨數個上報請求,粗略計算,上報請求也要達上百億。興趣部落採取的做法是合併上報,前端收集上報請求,而不是立即發送,將這些上報放到一個隊列中,延時對這些隊列里的請求參數做壓縮,生成一個統一的 URL,再將之發送至伺服器中。採用這種方式後,請求次數減少超過 80%,流量也節省了 70%。

以上僅僅簡單描述了這些實踐的大概原理,對於每一種實踐都可以單獨抽出來述以長篇大論。因為實習不久,對團隊項目還不是完全了解,以後再發現有什麼好的實踐就繼續補充。如果你的團隊有什麼好的性能優化實踐,希望不吝留言。


推薦閱讀:

精讀《2017前端性能優化備忘錄》
如何不擇手段提升scroll事件的性能
「每日一題」你是如何做性能優化的?
Angular AOT優化構建嘗試
PWA 漸進式實踐 (1) - Lighthouse in Action

TAG:前端开发 | 前端入门 | 前端性能优化 |