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

在我去年的《AMP,來自 Google 的移動頁面優化方案》這篇文章里,我給自己挖了個坑:

借助客戶端所做的優化,如現在廣為流行的移動端 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 中,頭部禁止出現任何外鏈資源。同時,盡可能減少頁面傳輸體積。

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


所屬標簽

無標簽

25选5玩法中奖