LsLoader 專註移動web的工程化本地緩存前端組件

GitHub - sexdevil/LSLoader: localStorage loader to increase mobile webapp speed

一、瀏覽器原生緩存,PC時代我們怎麼做的

對於瀏覽器的緩存機制,我們傳統用expire時間控制。當頁面緩存沒有到期時,瀏覽器會讀取本地文件的js/css/圖片;如果我們強制刷新,瀏覽器會發起304協議請求。

對於js構建,對於小型頁面,有原始的全局變數模式,中型以上的模塊化有AMD模式,React,Vue使用的Webpack模式。

這些模式在載入的時候一般有兩種:

  • 1、並行載入多個模塊,利用RequireJS, Webpack的回調函數進行模塊依賴處理;

優點:按需載入,每個模塊文件瀏覽器都會根據文件名進行緩存

缺點:http請求過多,耗費在延遲的性能大,對伺服器的並發高

  • 2、相對常用的,線下把所有依賴打包成一個大文件,一次請求全部。

優點:http少,一次握手全部載入完成

缺點:大文件間可能模塊有重複載入,浪費時間和流量

二、移動端的新坑

移動瀏覽器環境,相對於PC環境又有如下新問題:

  • 非Wi-Fi情況下網路延遲很大,達到200-400ms,直接結果就是304或者200情況下css,js載入很慢,加大白屏時間

  • IOS webview 大坑:IOS webview資源緩存存儲在內存,如果IOS退出程序後台,所有圖片/css/js全部緩存消失,再訪問頁面全部200請求。

  • 各種客戶端的webview,第三方瀏覽器行為不一致,經常expire沒有失效卻觸發了304協議

綜上,移動端傳統線下打包+304緩存即無法滿足移動web性能需求。

三、業內解決方案 ——LocalStorage本地緩存

目前業內有Scrat - webapp模塊化開發體系,基於FIS的整套前端架構,有i.meituan.com美團用的truckJS解決方案。基本原理就是使用md5版本號+localStorage,把靜態文件存儲在瀏覽器本地數據,利用js控制js的載入運行。

靜態資源(JS/CSS)存儲在localStorage有什麼缺點?為什麼沒有被廣泛應用? - 互聯網

基本原理高票答案張雲龍大神的回答已經解釋了,大概如下

  • 配合線上combo,實現模塊文件粒度的緩存,多頁面間共用模塊文件

  • 上線灰度時不用打一個大文件包一起更新,緩存組件會按需更新模塊文件,減少流量,提升時間

  • 既然是js控制,所以可以避免刷新,地址欄重新輸入造成的304請求

  • LocalStorage在IOS webview裡面是文件存儲,解決了退進程緩存失效的bug

四、Why LsLoader ,這個東西有什麼不同

目前主流的解決scart.io是利用重新定義require/define的方式,把緩存作為整個體系的一個部分揉入工作流。缺點在於只支持AMD模式開發的js程序,對於首屏css,非AMD模式例如Webpack,原生模塊化,CMD等並不支持,是一個全家桶形式的解決方案。

而LsLoader 構成相對簡單,她只有兩個部分:

1、 LsLoader.js,運行在客戶端,非同步載入所有css,js,css通過出現順序的正確排列達到執行效果等同於原生link標籤。同時css成功有回調函數,利用回調可以讓頁面先全部display:none,當首屏css載入完成後再顯示,防止非同步css造成的頁面錯亂的問題。JS的執行原理則是非同步載入,順序執行。意思是所有的js根據頁面中出現的順序壓入一個執行隊列,非同步載入完成後根據隊列的順序執行js文件。

圖1 本地緩存的代碼結構

/static/js/lib/wm_lib.js:"http://xs01.meituan.net/waimai_c_activity_web/js/lib/wm_lib.d162d9dc.js/*codestartv1*/var Zepto=function(){function t(t){r...

第一項,相對路徑為存在localStorage中的唯一key,value中有帶md5版本號的線上路徑,用來標示版本&載入資源。如果LsLoader發現緩存中程序版本有效,啟用本地,否則遠程請求資源執行並緩存。

圖2 LsLoader 緩存/載入流程圖

載入資源時,LsLoader會用ajax請求並吧代碼結構連同代碼版本號存入localStorage,這樣二次訪問時如果版本號不變LsLoader會直接從localStorage取出代碼執行

2、templateBuild.js,基於gulp的編譯組件,運行在上線前編譯過程。通過模版掃描,AST語法分析,templateBuild.js能把頁面中用注釋標記的資源文件轉換成為LsLoader介面的形式.AMD入口程序會把依賴的模塊按順序全部傳入LsLoader介面,再配合almond.js,順序執行define模塊後,require函數就能使用之前運行過的模塊。同理,原生js按照順序執行,依賴順序按照出現順序。Webpack模塊文件則是一系列comonJS的文件,通過模版分析順序執行依賴即可讓程序執行。

他是基於gulp的,開發時我們用各種注釋來標示要編譯的動作,gulp編譯後開發環境代碼即可編譯為線上緩存模式。基本標註格式為<!--任務名 build--><!--任務名 endbuild-->

如下:編譯前的業務代碼,一段script同步外引,一段內聯腳本

<!--js ls build--><script src="/static/js/page/home/home.js"></script><!--js ls endbuild--><!--js inline build--><script> require(["page/home/home"], function (homePage) { homePage.init({ baseurl: "${_baseurl_}", channel: "${global_channel_id!}", terminal: "${__wm_terminal__}", pageCount: "${(data_obj.poi_total_num/data_obj.page_size)?ceiling}" }); });</script><!--js inline endbuild-->

編譯後的代碼:外引js改為非同步載入/localStorage緩存,內聯腳本用textarea包裹,用lsLoader的js執行隊列延遲處理保證執行順序的正確

<script>lsloader.load("/static/js/lib/wm_lib.js","http://xs01.meituan.net/waimai_i/js/lib/wm_lib.c75cec25.js" )</script><textarea stylex="display:none" id="ls-loader-inlinecode11"> require(["page/home/home"], function (homePage) { homePage.init({ baseurl: "", channel: "1033", terminal: "i", pageCount: "63" }); });</textarea><script>lsloader.runInlineScript("ls-loader-inlinerun11","ls-loader-inlinecode11")</script>

首屏CSS的緩存,高級功能

編譯前,就是一段普通的加了onload的link標籤,css載入前讓html根節點踢出文檔流,載入成功後再恢復。

<script>document.documentElement.style.display="none"</script> <!--css ls build--> <link href="/static/css/page/home/home.css" onload="document.documentElement.style.display="";" rel="stylesheet"/> <!--css ls endbuild-->

編譯後,LsLoader會傳入回調函數並在從本地緩存運行css/遠程載入運行css成功後執行回調,防止非同步載入css非阻塞形式造成文檔樹先載入顯示,css沒有就緒造成的頁面錯亂和repaint/reflow性能開銷

<style id="/static/css/page/home/home.css"></style><script>lsloader.load("/static/css/page/home/home.css","http://xs01.meituan.net/waimai_i/css/page/home/home.cc82edd3.css",function(){document.documentElement.style.display="";} )</script>

綜上,LsLoader對於業務開發來說兼容各種形式的模塊化方式,需要使用者適配的只是templateBuild,模版編譯部分。通過自定義這個gulp組件,我們可以把各種形式的js編譯成LsLoader可以載入/緩存的格式。

六、進階功能,combo服務和模塊化緩存

我們都知道,本地打包合併css/js模塊,單文件上線可以減少http請求,減少等待網路延遲的時間,亦可減少伺服器的並發。但是這種形式對模塊文件的復用性較差。

例如,頁面A 頁面B兩個主js 文件a.js,b.js,都依賴了c.js,我們採用線下打包的時候線上a.js,b.js就會包含兩份c.js,重複下載。代碼更新時,如果c.js發生變化,a.js,b.js也都會緩存失效,重新發起一個200請求把整個打包後的大文件下載。這個對於移動端的高網路延遲環境性能損耗明顯。

為了解決這種問題,我們LsLoader支持線上combo服務來提升性能。

何為線上combo?簡單說就是傳入一連串css/js文件的路徑,服務端把對應的文件按順序拼接成一個reponse返回客戶端。

舉個例子:

我們發出一個請求,請求如下3個文件

/combo?combo=js/util/util-17b72c16ec.js;js/common/commonDialog-2ddfc1cdfc.js;js/common/test-9c740d7b0d.js;

服務端根據參數查找這3個文件,按順序拼接成一個response返回

/*combojs*/define("util/util",function(){return{utilNumber:2}});/*combojs*/define("common/commonDialog",["util/util"],function(m){return{commonNumber:m.utilNumber+2}});/*combojs*/define("common/test",["common/commonDialog"],function(m){return{testNumber:m.commonNumber+2}});

其中,/*combojs*/分隔符是用來分隔每個文件的,lsLoader得到combo後的返回後按照分隔符把代碼分隔成數組,再根據對應的請求文件名順序保證解析對應的js內容。

AMD形式的線上代碼,參見demo localhost:3000/

<script>lsloader.loadCombo([{name:"../js/util/util.js",path:"js/util/util-17b72c16ec.js"},{name:"../js/common/commonDialog.js",path:"js/common/commonDialog-2ddfc1cdfc.js"},{name:"../js/common/test.js",path:"js/common/test-9c740d7b0d.js"},{name:"../js/AmdModule.js",path:"js/AmdModule-9c50525029.js"},{name:"../js/index.js",path:"js/index-cd302927b1.js"}])</script>

頁面中編譯後模塊化文件被拆分成了一個數組,這個數組內的所有文件會按照順序執行。如果數組內文件有本地版本,直接加入隊列,否則所有的待更新模塊會被統一請求。

對於這個編譯過程,用到編譯知識比較複雜,我會後面介紹。

例子中,我使用的是AMD模式分塊,LsLoader只負責把所有define模塊按順序執行出來,define,require函數我們交給您自己來選擇。我的業務中選擇的就是almond.js ,簡化版的require.js,只負責模塊define,require依賴關係,不負責遠程載入模塊。

同理,如果我們使用非模塊化js,那就是原始的按照順序來執行即可保證文件關係正確。

原生依賴方式的代碼,參見demo localhost:3000/html/noA

<script>lsloader.loadCombo([{name:"../js/jquery.js",path:"js/jquery-aca795763d.js"},{name:"../js/noAMD/1.js",path:"js/noAMD/1-c41a23fafd.js"},{name:"../js/noAMD/2.js",path:"js/noAMD/2-749181ac61.js"}])</script>

非 AMD模塊很簡單,就是按順序執行js.

對於Webpack模式,他提供了一個webpack.optimize.CommonsChunkPlugin函數,熟悉Webpack的同學應該知道這是一個提取Webpack公共函數的功能,他能把Webpack依賴的公共文件拆出來放到主文件前面,只要保證執行順序正確即可。由於LsLoader.loadCombo順序執行的特性,Webpack亦可接入我么的緩存系統。

Webpack的載入代碼,參見demo中的localhost:3000/html/web

<script>lsloader.loadCombo([{name:"../js/webpack/picker.js",path:"js/webpack/picker-f49e85ff50.js"},{name:"../js/webpack/timepicker.js",path:"js/webpack/timepicker-af98294a63.js"}])</script>

綜上,所有模塊化系統轉變為順序執行的一堆js文件後,都可以使用combo服務實現非同步載入/順序執行/本地緩存/模塊化粒度更新。

七、模塊化文件怎麼編譯切分,有現成工具么?

有~ 為了方便生產環境使用,我利用AST語法樹分析開發了一套解析AMD文件依賴關係的組件,gulp組件ASTBuild.js。

什麼是AST語法樹?

這個話題說開了篇幅較大,詳細的請參見團廠的文章tech.meituan.com/abstra

簡單來說,AST --抽象語法樹分析就是把js源代碼按照語法抽象成一棵樹,每個節點都是他的語義詞。利用AST,我們能方便的找出js源文件中的define節點,require節點,然後找出裡面的參數。形如define(id,[a,b],function(){})的模塊,語法樹解析後我們即可知道他依賴a,b兩個文件,然後用ASTBuild.js 遞歸搜索a.js,b.js兩文件的代碼,當查到沒有依賴的樹葉為止。過程中所有依賴的列表我們逆序排列,即可得到一個從樹葉到樹根的依賴數組,去除重複,即可得到我們想要的唯一正確順序的依賴樹。

編譯前,我們用AMD build注釋包裹這個AMD入口文件,告訴編譯程序它是我們要分析的AMD格式的文件。

<!--js ls AMDModule build--><script src="../js/index.js"></script><!--js ls AMDModule endbuild-->

編譯後,所有的依賴從樹葉到樹枝是一個數組,沒有重複。

<script>lsloader.loadCombo([{name:"../js/util/util.js",path:"js/util/util-17b72c16ec.js"},{name:"../js/common/commonDialog.js",path:"js/common/commonDialog-2ddfc1cdfc.js"},{name:"../js/common/test.js",path:"js/common/test-9c740d7b0d.js"},{name:"../js/AmdModule.js",path:"js/AmdModule-9c50525029.js"},{name:"../js/index.js",path:"js/index-cd302927b1.js"}])</script></script>

對於其他格式的依賴,我們有combo build編譯任務處理

形如

<!--js combo build--><script src="../js/jquery.js"></script><script src="../js/noAMD/1.js"></script><script src="../js/noAMD/2.js"></script><!--js combo endbuild-->

編譯後就是順序執行的js

<script>lsloader.loadCombo([{name:"../js/jquery.js",path:"js/jquery-aca795763d.js"},{name:"../js/noAMD/1.js",path:"js/noAMD/1-c41a23fafd.js"},{name:"../js/noAMD/2.js",path:"js/noAMD/2-749181ac61.js"}])</script>

八、說的好多,有沒有例子?

有,gitHub上公開了我提出來的LsLoader源項目,附帶一個node的演示服務,全功能,有緩存,有combo載入。下面我來一一演示

首先上GitHub - sexdevil/LSLoader: localStorage loader to increase mobile webapp speed,

  1. clone代碼到本地。

  2. 切換到根目錄下,運行npm install. 沒有node的同學自行安裝node先~

  3. 運行gulp ,可以看到編譯開始

  4. 完成後運行node app.js 啟動後端

  5. 瀏覽器輸入localhost:3000 即可看到效果

chrome我們開發者工具查看,console輸入localStorage,我們看到了首頁的資源都被緩存了。

輸入localStorage.clear(); 清空本地緩存,再刷新,我們看network

載入瀑布是非同步,同時利用css onload我們也阻止了討厭的頁面錯亂問題。

這是,我們再模擬一個業務開發中常見的問題,index.js中依賴一個模塊test.js,我們修改他保存,然後gulp編譯。傳統模式線下combo會整個index.js大包全部載入,這裡我們試試重新打開頁面如何。

頁面只去請求了被修改的模塊~ 省流量,省時間,同時結合combo也不增加請求,業務快速迭代情況下亦可保證用戶性能,節省服務端流量。

九、我的項目適合這個緩存方案么?有什麼坑么

沒有最好的解決方案,只有最適合你的方案。

實現這套緩存機制需要的技術成本如下:

  1. 靜態資源要配置為可以跨域訪問,因為緩存資源都是ajax請求,如果跨域了LsLoader會用script標籤作個fallback,但是就得不嘗失了。

  2. 項目編譯需要使用gulp/grunt工具,編譯過程是gulp插件,基於nodejs語法。

  3. 線上有combo服務的站點可以優化實現性能提升,如果沒有,亦可對IOS webview有很大收益。

這套緩存機制不適用的情況:

  1. PC由於IE問題,對LocalStorage兼容不一致。

  2. PC帶寬較高,拆分模塊載入收益可以忽略。

  3. 移動端,如果您的頁面只是在瀏覽器環境,沒有IOS webview的坑,本地緩存失效風險較低。這時部署這套方案的價值就在線上combo實現的模塊級別更新發布,如果您沒有線上combo,就不建議花費時間在這個上面

綜上,LsLoader.js提供了一個非同步載入/順序執行/本地緩存的前端庫,同時配套了基於gulp的編譯工具來對應項目中的各種AMD,原生,CMD代碼組織方式,讓你的移動網站訪問更快,流量更高,同時業務架構不受影響。


推薦閱讀:

網站性能優化——DNS預熱與合併HTTP請求
頁面白屏有哪些檢測手段?
前端瀏覽器緩存及代碼部署
不要寫垃圾代碼,即使它跑在別人的電腦上
如何做前端異常監控?

TAG:前端开发 | 前端性能优化 | 前端框架 |