1. 程式人生 > >移動 WEB 通用優化策略介紹(一)

移動 WEB 通用優化策略介紹(一)

提醒:本文最後更新於 949 天前,文中所描述的資訊可能已發生改變,請謹慎使用。

藉助客戶端所做的優化,如現在廣為流行的移動端 Webview 容器加速方案,優化效果侷限在指定 APP 內,甚至還會導致使用通用瀏覽器訪問速度更慢(這個話題很有意思,有機會以後再討論)。

現在過去快半年,我終於想起來把這個坑填上,今天先來寫這個系列第一篇。

我在標題裡用了「通用」二字,說明我要介紹的優化策略不是為特定的 Webview 容器定製,它面向的是所有主流的移動端瀏覽器,包括各種 APP 嵌入的通用 Webview。

藉助定製化的 Webview 容器,我們完全可以通過主動提前本地化資源包,使得移動 WEB 應用跟 Native 應用一樣,只需走網路獲取必要的資料。這種情況下的效能優化,更多需要關注程式碼執行效率,本地化資源包的推送流程和成功率。

而對於通用的移動 WEB 效能優化,首先要考慮的是網路傳輸效能。

我們知道,瀏覽器獲取一個資源,花在網路上的時間開銷至少有這幾塊:DNS 解析、建立 TCP 連線、傳送請求、等待響應、傳輸響應正文。相比 PC 的網路環境,移動端更為糟糕,集中在這幾點:高時延、高丟包、低速率、多劫持。

所以,在移動 WEB 上,引入外鏈資源的網路成本非常高。在我們最近的一次測試中,移動端同域名空圖片(使用 Nginx 的 empty_gif 指令構造)載入失敗率(我們對失敗的定義是觸發圖片的 error 事件,或者超過 9s 仍未觸發 load 事件)高達 2%,外域圖片失敗率還要高上幾個百分點。

我們還知道,頭部的外鏈 CSS、JS 會阻塞頁面渲染,通俗來講就是頭部的這些外鏈資源載入完之前,頁面會一直白屏。之前我們統計過,一個 GZip 後十幾 KB 的頭部 JS,會增加大概半秒的白屏時間。

基於上述原因,我的移動 WEB 通用優化策略第一要點:

重要的 CSS、JS 直接內聯在 HTML 中,頭部禁止出現任何外鏈資源。

需要注意的是,很多效能評估工具 / 文件都說 HTML 頭部不要有任何 JS。實際上這一點在實際專案中很難做到,至少大部分頁面效能監控就需要在頭部計時(Web Performance API 並不能解決所有效能監控問題)。對於頭部內聯 JS,我只有兩點要求:1)沒有耗時操作;2)只保留必要程式碼。

現在很多 WEB 應用,尤其是 SPA,服務端往往都只提供 RESTful 資料介面。這樣頁面 JS 程式碼執行過程中,還要非同步獲取資料,在資料載入完成之前,頁面一片空白或者只有 Loading。這麼做也違背了我提出的第一要點,要解決這個問題最簡單的做法是服務端直接將首屏資料以 JSON 變數的形式輸出到頁面上;高階點的方案是利用 JavaScript 同構框架,首屏直接輸出 HTML。

將重要 CSS、JS 甚至資料介面都內聯在頁面上,可以減少由於行動網路環境造成的頁面呈現慢或者不可用等情況,但是也帶來另外的問題:多次請求之間無法利用快取,浪費流量,也讓行動網路低速率的問題雪上加霜。

為了聚焦,本文不討論程式碼壓縮、傳輸壓縮以及清理無用程式碼等減少檔案體積等基礎優化專案,也不討論非同步或按需載入的資源。我們假設要載入的所有內容都是首屏必須且壓縮過的。那麼,對於使用者首次訪問,內聯無疑是最優選擇,因為無論如何這些資源都要載入,能減少連線數就是最大的改進。那麼,如何解決使用者後續訪問,內聯導致的無法利用 HTTP 快取機制的問題呢?

我們引入了 localStorage 方案:使用者首次訪問時,服務端輸出包含內聯 CSS、JS 和 JSON 資料的頁面,並通過 JS 將這些資料存入 localStorage;使用者後續訪問時,服務端只需要輸出從 localStorage 讀取並執行程式碼的 JS 片段即可。這樣,後續訪問的頁面體積就小很多了。

可以看到,這個方案的難點在於:1)服務端如何得知使用者本地存有 localStorage;2)服務端如何得知使用者本地存的 localStorage 中的某個具體檔案的版本是否最新。有同學會說,把存入 localStorage 的檔案及對應版本都記在 Cookie 裡不就可以了?但別忘了往 Cookie 裡存太多資訊,本身就是一種錯誤的做法。況且,如果 Cookie 資訊完好,但 localStorage 卻被清除要怎麼處理?

為此,我們設計了一整套流程。首先,我們引入了由以下字元組成的 70 進位制:

0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!()*_-.

它們會被用來在 Cookie 中存放檔案路徑及版本號,70 進位制意味著可以用單字元區分 70 種不同的檔案或版本。另外,這 70 個字元都無需編碼即可存入 Cookie。

然後,在編譯流程中,對全站程式碼裡所有需要存入 localStorage 的資源(我們通過外鏈標籤是否存在某個自定義屬性來判斷)進行分析,生成檔名及對應版本號的 Map 檔案。示意如下:

{
    'file/path/to/js/1.js' : { filename : '0', version : '0', md5 : '‍xxx'},
    'file/path/to/js/2.js' : { filename : '1', version : '0', md5 : 'yyy'},
    'file/path/to/css/1.css' : { filename : '2', version : '0', md5 : 'zzz'},
}

這份配置每次編譯都需要重新構建,但有兩點需要保證:1)相同檔案路徑對應的 70 進位制字元標記必須固定;2)檔案內容 md5 發生變化時,對應的版本號需要 +1。每個使用到本方案的頁面頭部都需要包含這份配置。

那麼對於下面這樣的原始 HTML 程式碼片段:

<link rel="stylesheet" href="file/path/to/css/1.css" lsname="css_1" />
<script src="file/path/to/js/1.js" lsname="js_1"></script>
<script src="file/path/to/js/2.js" lsname="js_2"></script>

使用者首次訪問時,將以這樣的形式輸出:

<style id="css_1">‍‍/* the content of 1.css... */</style>
<script>LS.html2ls("css_1");LS.updateVersion('cookie_name','0','0')</script>
<script id="js_1">‍‍/* the content of 1.js... */</script>
<script>LS.html2ls("js_1");LS.updateVersion('cookie_name','1','0')</script>
<script id="js_2">‍‍/* the content of 2.js... */</script>
<script>LS.html2ls("js_2");LS.updateVersion('cookie_name','2','0')</script>

這時,使用者本地將會新增 css_1js_1js_2 三份 localStorage 資料,以及取值為 001020 的一個 Cookie。

使用者再次訪問時,服務端會分析 Cookie,找出對應檔案在 Map 配置中的版本號,與 Cookie 中的版本進行比較,如果都沒有變化,則只會輸出這樣少量的程式碼:

<script>LS.ls2html("css_1","style","cookie_name")</script>
<script>LS.ls2html("js_1","script","cookie_name")</script>
<script>LS.ls2html("js_2","script","cookie_name")</script>

這樣,瀏覽器就會從 localStorage 中取出之前儲存的內容,建立相應的標籤並執行。

如果之前的資源有改動,編譯後 Map 配置檔案就會更新。假設 js_1 已經迭代到版本 3,那麼服務端會輸出這樣的程式碼:

<script>LS.ls2html("css_1","style","cookie_name")</script>
<script id="js_1">‍‍/* the new content of 1.js ... */</script>
<script>LS.html2ls("js_1");LS.updateVersion('cookie_name','1','3')</script>
<script>LS.ls2html("js_2","script","cookie_name")</script>

可以看到,只有本次更新的資源才會輸出全部內容。這份程式碼執行完之後,本地 js_1 這份 localStorage 會隨之更新,Cookie 也會更新為 001320

看到這裡,大家應該明白這個方案的基本原理了,這個方案需要在服務端處理一系列複雜的分支判斷,具體實現程式碼我就不貼了。下面說幾個需要特別關注的點:

首先,移動端部分瀏覽器在隱私模式下,訪問 localStorage 物件會直接丟擲異常,必須把 localStorage 的幾個方法包裝一下,加上 try。

其次,如果 Cookie 中的標記存在,但是 localStorage 內容丟失如何處理?我們來看這行程式碼:

LS.ls2html("css_1","style","cookie_name")

它執行的具體操作是:查詢 localStorage 中名為 css_1 的內容,找到之後建立 style 標籤並插入頁面。第三個引數值 cookie_name 是為了在讀取 localStorage 失敗時,能夠清掉這個 Cookie 標記,然後重新整理頁面。這時,服務端發現 Cookie 標記不存在,就會全量輸出內聯內容,等同於使用者首次訪問。

另外,我們用單字元標記檔名和版本,容量只有 70。每個專案中,允許同時有 70 個不同的檔案存入 localStorage,完全夠用。假設一個檔案每週修改兩次版本,那麼 70 個版本號會在大半年後迴圈到起點。假設使用者瀏覽器存在某個檔案的版本 0,大半年期間一直沒來訪問,直到這個檔案的版本號輪迴到 0 他再訪問一次,這時候服務端會認為他本地的檔案已經是最新的。這種極端情況我們評估後認為完全可以接受。如果實在不放心,可以將檔案或版本擴充為兩位來表示,就能應對 4900 種不同情況。

有些 Webview 沒有開啟 localStorage 功能,如果我們檢測到這種情況,就額外記一個 Cookie 標記,服務端看到這個標記,每次直接全量輸出內聯。同樣,如果 Webview 連 Cookie 也不支援,那麼最終效果也是每次都全量內聯,至少不比優化前差。

實際上,還可以採用類似於購物車的做法實施這套方案:在使用者瀏覽器存一個 Cookie 標識,然後服務端通過 Redis 這樣的 KV 服務來找出曾經給他傳送過哪些資源及各自版本。這樣做的好處是程式碼邏輯簡單,但需要引入外部服務。

資源內聯可以緩解移動端網路的高時延、高丟包等問題;而資源 localStorage 化可以應對低速率;二者結合使用,才能有最好的效果。也就是說,我前面提出的移動 WEB 通用優化策略第一要點需要完善下:

重要的 CSS、JS、JSON 資料直接內聯在 HTML 中,頭部禁止出現任何外鏈資源。同時,儘可能減少頁面傳輸體積。

好了,這個系列的開篇先就寫這麼多。還是老規矩,如果有任何問題和疑問歡迎留言,我會及時回覆。

--EOF--

提醒:本文最後更新於 949 天前,文中所描述的資訊可能已發生改變,請謹慎使用。