App內網頁啟動加速實踐:靜態資源預加載視角
摘要
App應用的功能代碼,通常在用戶訪問之前,就已經以安裝包的形式,通過應用市場下載安裝好了。而網頁應用的功能代碼(靜態資源),則是在用戶實際點擊訪問時,才實時下載運行。
這一『用時下載』的特點是一把雙刃劍,既帶來了實時更新的靈活性,也造成了應用啟動的延遲,導致網頁應用啟動速度遠遠落後於App應用,造成交互體驗和用戶轉化短板。
本文提出一種基於靜態資源預加載技術,提升App內網頁啟動速度的新方案。根據線上項目實踐數據,此方案可顯著提升網頁啟動速度(縮短頁面加載時間30%-50%以上)、提高網頁加載成功率。相比於傳統的離線化技術方案,本方案具有差異性優勢,且能規避iOS WKWebview中無法攔截請求的問題。
1 背景和問題
在App應用開發中,網頁應用(又名H5/WebApp/Hybrid等)以其跨平台/跨App、開發成本低、迭代試錯速度快的特點,始終佔有一席之地。
然而,在上述優勢之外,網頁應用啟動速度慢(尤其是用戶首次訪問時)、點擊轉化率低等問題,使其難以進入核心業務技術選型。
下面我們以微信內的一個頁面為例,從一個普通用戶視角,感受一下網頁應用的啟動過程。

然後從技術視角,分析一下網頁啟動的幾個關鍵耗時階段。

(注:上述數據根據線下測試數據及部分線上項目TP95用戶數據得出,僅用於說明佔比趨勢,不同項目會有一定差異)
從圖中可知,靜態資源下載是最大的耗時環節,那麼,如何解決這一耗時瓶頸?
調研發現,針對靜態資源下載慢的問題,業界常規的解決方案是鏈接預取( link prefetching )。
鏈接預取是一種瀏覽器機制,其利用瀏覽器空閒時間來提前下載用戶在不久的將來可能訪問的文檔。其主要實現思路是:
- 網頁向瀏覽器提供一組預取提示,在完成當前頁面加載後,瀏覽器按照預取提示,開始靜默地下載指定的文檔,並將其存儲在瀏覽器緩存中
- 當用戶訪問預取文檔時,便可以快速從瀏覽器緩存中讀取
- 鏈接預取屬於預加載技術,已成為Web事實標準的一部分
<!-- 链接预取範例 --><linkrel="prefetch"href="https://www.companyA.com/projectA/js/index.abcd1234.js"><linkrel="prefetch"href="https://www.companyA.com/projectA/css/index.abcd1234.css"><linkrel="prefetch"href="https://www.companyA.com/projectA/img/index.abcd1234.png">
鏈接預取已在前端項目中廣泛應用,在各類構建工具中均有默認實現。
在用戶進入網頁應用首頁之後,鏈接預取能加速後續頁面的訪問速度,而對於網頁應用首頁本身的訪問加速,鏈接預取則顯得無能為力。
因為鏈接預取需要一個前置頁面來設置預取提示,而網頁應用首頁通常是用戶訪問的起點,不存在這樣的前置頁面。
2 App內網頁靜態資源預加載
2.1 App內網頁特點和預加載問題
回到App場景內,App內的網頁應用,入口一般投放在導航類/頻道類Native頁面,用戶點擊入口圖標後啟動WebView組件展示。
Native頁面是網頁應用首頁的前置頁面,這就給了我們一個很好的預加載首頁時機:可以在Native頁面中去預加載網頁應用首頁,從而提高網頁應用首頁的啟動速度。
要實現這一機制,需要解決如下問題:
- 如何在Native頁面下載Web網頁靜態資源,並放入緩存區?
- Web網頁打開時,如何命中上述緩存?
2.2 技術方案
針對上述兩個關鍵問題,提出基於『靜態資源預加載+ 瀏覽器緩存』的解決方案,核心思路如下圖:

主要包括三個核心模塊:隱藏WebView啟動模塊、預加載器模塊和靜態資源URL列表配置模塊
1)隱藏WebView啟動模塊
此模塊由客戶端實現,主要功能:
- 在App啟動或進入導航類Native頁面時,初始化一個隱藏不可見的WebView組件,打開預加載器模塊H5頁面
- 一般僅在網絡空閒、使用WIFI情況下執行,以避免佔用用戶正常訪問帶寬,節省用戶流量成本
注意:iOS中,由於UIWebview和WKWebview 緩存不共享,預加載隱藏webview和用戶實際訪問的webview,建議都是WKWebview。
2)預加載器模塊
此模塊由Web前端實現,主要功能:
- 請求服務端接口,獲取需要預加載的靜態資源URL列表
- 調用瀏覽器Fetch方法,下載列表中的靜態資源,存儲到WebView HTTP緩存區
- 靜態資源下載完畢後,通知Native銷毀隱藏WebView
//預加載器核心代碼範例functionprefetch(url){returnself.fetch==null?xhrPrefetchStrategy(url):fetch(url,{credentials:`omit`});}functionxhrPrefetchStrategy(url){returnnewPromise((resolve:()=>{},reject:()=>{})=>{constreq=newXMLHttpRequest();req.open(`GET`,url,(req.withCredentials=false));req.onload=()=>{req.status===200?resolve():reject();};req.send();});}
3)靜態資源URL列表配置模塊
此模塊由服務端實現,通常以管理配置平台的形式,開放給企業內所有業務線接入,主要功能:
- 配置和管理需要被預加載的各網頁應用的URL列表
- 組合所有接入方的URL列表,形成統一列表,提供給預加載器模塊調用
//URL列表配置接口示意//资源列表接口URL:https://www.demo.com/prefetch-platform/config.json//资源列表接口响应体範例//实际返回给预加载器页面的结果,需要对此配置中的assetsURL进行请求后,返回实际要加载的URL地址{"prefetch":true,//全局预加载开关"assets":[//资源URL列表{"name":"projectA",//接入项目名称"assetsURL":"https://www.companyA.com/projectA/prefetch-assets.json",//接入项目资源列表接口地址"prefetch":true//demo项目预加载开关},{"name":"projectA","assetsURL":"https://www.companyA.com/projectB/prefetch-assets.json","prefetch":true}]}
接入方要接入App預加載功能,需要:
- 項目上線時,構建生成自己項目的靜態資源URL列表
- 設置靜態資源響應頭,允許預加載器跨域下載列表中的資源
//接入方式範例//1、构建URL列表//资源列表接口URL:https://www.companyA.com/projectA/prefetch-assets.json//资源列表接口响应体:{"prefetch":true,//是否开启预加载"assets":[//资源URL列表"https://www.companyA.com/projectA/js/index.abcd1234.js","https://www.companyA.com/projectA/css/index.abcd1234.css","https://www.companyA.com/projectA/img/index.abcd1234.png"]}//2、资源跨域头设置location~*.(html|js|css|png)${add_headerAccess-Control-Allow-Origin*;}
2.3 針對HTML主文檔的預加載
上文我們已經對網頁中的JavaScript/CSS等資源進行了預加載,那如何對入口HTML文檔進行預加載呢?

眾所周知,入口HTML通常設置為不緩存,每次請求都會從服務端獲取最新內容。
這就導致HTML無法進行預加載,進而導致整個網頁應用無法實現離線化(斷網可用)。
要解決這一問題,我們需要給HTML文檔增加版本號,並應用新的緩存策略。主要實現思路如下:
1)在項目上線構建時,對HTML主文檔增加版本號,並將帶版本號的入口地址URL,傳給服務端入口配置系統更新
// 构建发布时bash脚本範例// 编译构建完毕后,复制生成带版本号的HTML主文档- htmlVersion = $( date +%Y%m%d%H%M%S ) - cp ./dist/index.html ./dist/index. $htmlVersion .html // index.20190501124536.html // 上线成功后,发消息通知服务端入口配置系统,更新为最新版本的入口URL - push a message: [ url = 'https://www.companyA.com/projectA/index.20190501124536.html' ] - to backend config server
2)針對帶版本號的主文檔,設置長期緩存
# Nginx配置範例# *.[version-number].html file: cache 1 year location ~ * .(d+).html $ { add_header Access-Control-Allow-Origin * ; add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable" ; } # *.html file: no-cache location ~ * .html $ { add_header Access-Control-Allow-Origin * ; add_header Cache-Control "no-cache, no-store, must-revalidate" ; }
3)通過服務端接口,下髮帶版本號的入口URL
// 配置URL範例服务端下发带版本号的URL範例: https : //www.companyA.com/projectA/index.20190501124536.html客户端Native页面兜底降级使用的不带版本号的URL範例: https : //www.companyA.com/projectA/index.html
4)預加載所有靜態資源(包括HTML)項目URL配置列表範例
//配置URL範例{"prefetch":true,"assets":["https://www.companyA.com/projectA/index.20190501124536.html","https://www.companyA.com/projectA/index.20190501124536.html?from=test","https://www.companyA.com/projectA/js/index.abcd1234.js","https://www.companyA.com/projectA/css/index.abcd1234.css"]}
對這些資源進行預加載後,便可以實現在用戶首次訪問頁面時,所有的靜態資源都從本地緩存讀取。主要收益:
- 消除靜態資源下載帶來的網絡連接建立、數據傳輸等時間消耗,提高網頁應用啟動速度和點擊轉化率
- 實現網站首頁完全離線化,很大程度上解除應用對靜態資源服務器的核心依賴,提高系統可用性
5)帶動態可變參數的HTML地址,如何解決?
在生產實踐中,存在URL中有動態可變參數的案例,以支付收銀台為例,業務打開收銀台時,其入口地址為:
// 配置URL範例// 打开收银台地址,其中pay_order_number为动态变化的参数,是启动收银台时动态生成的参数支付订单1 => https : //www.companyA.com/cashier/index.20190501124536.html?pay_order_number=0001支付订单2 => https : //www.companyA.com/cashier/index.20190501124536.html?pay_order_number=0002支付订单3 => https : //www.companyA.com/cashier/index.20190501124536.html?pay_order_number=0003
由於訂單號是用戶下單動態生成的,而緩存是以URL為key進行查詢的,動態參數將導致無法在App啟動或其他時機對HTML進行預加載。
這類問題如何解決呢?當然也是有辦法的,這裡就不直接拋出解決方案了,有興趣、遇到同樣問題的朋友可以思考下。
此外,還有部分業務的HTML入口URL是固定不變的,無法通過服務端動態下發。對於這種情況,可以給入口URL設置一個短時間的緩存時間(如10-30分鐘),這樣既能提前預加載,又不會導致長時間無法更新。
6)服務端API接口數據如何離線?
對於頁面使用前端渲染的項目,除HTML/JS/CSS等靜態數據外,應用首次啟動一般還會有服務端API數據請求,此請求的離線化思路是:
- 先嘗試網絡請求,失敗後走下一步
- 從本地LocalStorge讀取(數據為上一次正常網絡請求時存儲),如果讀取成功,則用上次的API數據渲染,並在頁面上展示網絡異常通知文案;如果讀取失敗,則走下一步;
- 展示JavaScript代碼中內置的默認兜底數據,以及網絡異常通知文案
從線上項目實踐來看,僅對html增加版本號這一項(未進行任何資源預加載),即可提升完全加載時間10%-20%左右,對於老客多的項目,這是一個簡單有效的優化手段。
2.4 成本分析
由於用戶瀏覽行為的難以預測性,靜態資源預加載會帶來一定的流量浪費,需要對這部分成本進行核算。
1)企業下行帶寬成本
以預加載一個H5項目(資源大小50KB)1000萬次為例,增加流量= 1000萬* 50KB ≈ 500GB。
按照當前某知名云CDN按流量計費價格,對應的下行帶寬價格為95元。
2)用戶手機流量成本
以預加載50個網頁項目為例,增加的手機流量= 50 * 50KB ≈ 2.5MB,4G流量約5分錢,僅在WIFI下載則用戶成本更低。
這裡需要指出的是,並非每次App啟動都會下載2.5MB。在一個項目上線週期內,緩存資源失效前,僅會下載1次,後續資源預加載請求也會命中緩存。
預加載並非適合所有的項目場景,不同項目的投入產出比是不同的,需要具體項目具體分析,以上給出的是成本分析的計算方法。
下面給出一些適用的項目場景:
- 高點擊率、大流量網頁項目。高點擊率、大流量意味著高緩存命中、低流量浪費,如:點擊App首頁banner打開的熱點活動網頁
- 對啟動速度和轉化率有極致要求的網頁項目。如:支付收銀台、登錄、頻道首頁等核心路徑項目
- 不適合的項目。流量小、轉化收益低的項目
2.5 預加載策略
如果上述成本還無法接受,我們可以通過靜態化和動態化策略,進一步降成本。
1)靜態化預加載策略
- 僅下載大流量/重點項目
- 僅WIFI環境和瀏覽器網絡空閒時下載
- 在網頁啟動前,最近的上一步或上兩步Native頁面預加載,而不是都放到App啟動時下載
- 僅預加載網站首頁需要的靜態資源,首頁之外後續其他頁面的靜態資源,由網站首頁進行預加載
- 按照項目配置的優先級順序分批下載,控制每次並發的下載連接數,響應緩慢時及時終止下載
上述部分策略,需通過服務端管理平台落地,實現在合適的時機下載合適的項目。
2)動態化智能預加載策略
- 基於用戶畫像(包括基礎畫像、長期畫像、用戶行為軌跡、實時數據、歷史數據等),預測一個用戶未來會點擊哪些頁面,計算出未來訪問概率
- 僅僅下載訪問概率高於特定閾值項目的靜態資源
動態策略舉例:僅針對未登錄用戶,預加載登陸網頁的靜態資源。
這裡需要指出的是,部分動態化策略的實現,需要大量研發資源和計算資源的投入,這部分投入可能已經遠遠超過了流量成本,因此在實施動態化策略時,需要綜合評估考慮。
2.6 緩存命中率統計
通過瀏覽器的PerformanceAPI來得知請求的靜態資源是fromLocalCache還是fromRemoteServer。
具體思路:performance.getEntries() ,可以獲取每一個靜態資源的請求訊息,其返回如下圖:

- transferSize等於0 說明從緩存讀取,transferSize不等於0 說明從網絡讀取
- 如果transferSize不可用,則使用duration。 duration等於0,說明從緩存讀取;duration不等於0,說明從網絡讀取
緩存命中率= fromLocalCache / (fromLocalCache + fromRemoteServer)
這裡需要指出的是,緩存命中率只是一個過程指標,在項目初期,建議將更多精力關注到完全加載時間和頁面加載成功率率等結果指標上。
2.7 App啟動時WebView內置公共基礎庫
在組件化、服務化盛行的今天,各個前端項目之間,共用了大量的基礎庫、組件庫、業務框架,對於這些共用的部分,可以獨立成一組公共靜態資源,在App啟動時預加載,直接內置到WebView緩存中。
//公共基础库内置範例{"prefetch":true,"assets":["https://www.companyA.com/library/vue/2.5.0/vue.runtime.min.js","https://www.companyA.com/library/vue/2.6.0/vue.runtime.min.js","https://www.companyA.com/library/react/16.8.6/react.runtime.min.js","https://www.companyA.com/library/UIKit/2.1.6/UIKit.min.js","https://www.companyA.com/library/UIKit/2.1.6/UIKit.min.css","https://www.companyA.com/library/analytics/1.0.1/analytics.js",]}
業務項目只需要引用這些地址,便可以直接從WebView緩存中讀取公共庫。這是對公共CDN服務的一種改進,不會再有一種為別人做嫁衣的感覺。
在Webview內置這一類公共基礎資源,可避免各項目之間重複下載公共基礎代碼,節省公司流量成本,提高網頁啟動速度。
2.8 與離線化/離線包方案的對比
在解決靜態資源下載慢這一問題上,業界還有一種廣為應用的技術方案,既:離線化/離線包方案。其主要思路是:
- 將包括HTML/JS/CSS等靜態資源打包到一個壓縮包內,在用戶訪問項目前,預先下載該離線包到本地並解壓
- 當用戶訪問頁面發出資源請求時,WebView容器會對請求進行攔截,如果已經在離線包內,會使用離線包中的本地資源響應給用戶
自iOS 8.0起,Apple使用WKWebView來替換UIWebView,由於WKWebView在獨立於app 進程之外的進程中執行網絡請求,請求數據不經過主進程,使得無法直接使用NSURLProtocol 攔截請求,導致離線化方案在iOS端陷入困境,各大廠不得不採用頗具風險的私有API來解決這一問題。而本方案則摒棄了攔截的思路,有效規避了這一問題。
此外,與離線化/離線包相比,本方案還具備如下差異化優勢:

當然,離線化方案也有其優勢,如:包的形式可以減少請求數、對緩存全生命週期都可以做到細粒度控制等,需要大家根據實際應用場景進行選擇。
特別提示:如果在App啟動時進行預加載且App DAU很高,容易導致靜態資源服務器QPS激增,形成流量突刺造成宕機,需要做好流量預估,逐步放量。
3 收益
我們針對線上部分項目,進行了資源預加載實踐,結果數據如下:
- 網頁應用啟動完全加載時間,縮短30%-50%以上(PerformanceAPI統計的頁面onload指標,TP95),顯著提高App內網頁應用啟動速度
- 網頁加載成功率,提高約1%(網頁加載成功率= 網頁加載成功數/ 入口圖標點擊次數)
4 總結規劃與暢想
4.1 總結
App應用的功能代碼,通常在用戶訪問之前,就已經以安裝包的形式,通過應用市場下載安裝好了。而網頁應用的功能代碼(靜態資源),則是在用戶實際點擊訪問時,才實時下載運行。
這一『用時下載』的特點是一把雙刃劍,既帶來了實時更新的靈活性,也造成了應用啟動的延遲,導致網頁應用啟動速度遠遠落後於App應用,造成交互體驗和用戶轉化短板。
本文提出一種基於靜態資源預加載技術,提升App內網頁啟動速度的新方案。根據線上項目實踐數據,此方案可顯著提升網頁啟動速度(縮短頁面加載時間30%-50%以上)、提高網頁加載成功率。
相比於傳統的離線化技術方案,本方案具有差異性優勢。
4.2 未來暢想
從事技術開發多年,一直在思考一個問題:面對鋪天蓋地、日新月異的技術,十年之後哪些技術還會存在?哪些技術終將消亡?活在當下我們又應當如何自處?
思索尋找之後,看到一句廣為流傳的話語:『一流公司做標準,二流公司做技術,三流公司做產品』。我們常常執迷於技術和產品,而忽視了其背後標準的力量。
iOS/Andriod/ReactNative/Flutter/小程序/快應用等當下火熱的封閉性/私有化技術,終會隨著巨頭的興衰而沉浮,Flash/塞班等技術便是先例。
而Web將會歷久彌新,因為我們相信開放終將戰勝封閉,分裂終將趨於標準和統一。
敬畏標準、擁抱標準、融入標準,圍繞Web標准開展技術實踐創新,『 Leading the web to its full potential 』,這或許是我們未來值得投入長期耐心和努力的方向!
5 參考資料
- fouber.大公司裡怎樣開發和部署前端代碼? [EB/OL]. 鏈接,2019-02-02
- Ilya Grigorik.HTTP Caching[EB/OL]. 鏈接,2019-04-21
- 育新,徐宏,嘉潔.WebView性能、體驗分析與優化[EB/OL]. 鏈接,2019-05-01
- Brian Jackson.Resource Hints - What is Preload, Prefetch, and Preconnect?[EB/OL]. 鏈接,2019-05-01
- MDN.HTTP Caching[EB/OL]. 鏈接,2019-04-21
- 阿里雲.阿里雲產品定價[EB/OL]. 鏈接,2019-05-01
- GoogleChromeLabs,quicklink[EB/OL]. 鏈接,2019-05-01
- 螞蟻金服,離線包簡介[EB/OL]. 鏈接,2019-04-21
- W3C,W3C官方主頁[EB/OL]. 鏈接,2019-05-01