Web靜態資源快取及優化

美團點評點餐發表於2019-03-04

前言

對於頁面中靜態資源(html/js/css/img/webfont),理想中的效果:
  1. 頁面以最快的速度獲取到所有必須靜態資源,渲染飛快;
  2. 伺服器上靜態資源未更新時再次訪問不請求伺服器;
  3. 伺服器上靜態資源更新時請求伺服器最新資源,載入又飛快。
總結下來也就是2個指標:
  • 靜態資源載入速度
  • 頁面渲染速度
靜態資源載入速度引出了我們今天的主題,因為最直接的方式就是將靜態資源進行快取。頁面渲染速度建立在資源載入速度之上,但不同資源型別的載入順序和時機也會對其產生影響,所以也留給了我們更多的優化空間。
當然除了速度,快取還有另外2大功效,減少使用者請求的頻寬和減少伺服器壓力。

先用一張圖來概括下本文中將會涉及到的內容。

Web靜態資源快取及優化

常見快取型別

1、瀏覽器快取

對於前端而言,這可能是我們最容易忽略的快取型別,原因在於大部分設定都在伺服器運維層面上進行,不屬於前端開發的維護範圍。但靜態資源的內容更新時機其實前端是最清楚的,如果能在理解瀏覽器快取策略的基礎上合理配置效果最佳。
瀏覽器快取策略一般通過資源的Response Header來定義,html檔案在很早之前的規範裡也可以通過Meta標籤的http-equiv來定義。
一個Response Header示例:
Web靜態資源快取及優化

可在w3c的官方文件中檢視所有HTTP Response Header欄位的定義,跟快取相關的主要有上圖中被圈出來的幾個:

    • public:響應被快取,並且在多使用者間共享。
    • private:預設值,響應只能夠作為私有的快取(e.g., 在一個瀏覽器中),不能再使用者間共享;
    • no-cache:響應不會被快取,而是實時向伺服器端請求資源。
    • max-age:數值,單位是秒,從請求時間開始到過期時間之間的秒數。基於請求時間(Date欄位)的相對時間間隔,而不是絕對過期時間;
注:HTTP/1.0 沒有實現 Cache-Control,所以為了相容HTTP/1.0出現了Pragma欄位。
  • Pragma: 只有一個用法Pragma: no-cache,它和Cache-Control:no-cache作用一模一樣。(Cache-Control: no-cache是http 1.1才提供的, 因此Pragma: no-cache可以使no-cache應用到http 1.0 和http 1.1。)
  • Expires:指定了在瀏覽器上緩衝儲存的頁距過期還有多少時間,等同Cache-control中的max-age的效果,如果同時存在,則被Cache-Control的max-age覆蓋。若把其值設定為0,則表示頁面立即過期。並且若此屬性在頁面當中被設定了多次,則取其最小值。
注:這個規則允許源伺服器,對於一個給定響應,向 HTTP/1.1(或之後)快取比 HTTP/1.0 提供一個更長的過期時間。
  • Date:生成訊息的具體時間和日期;
  • Last-Modified/If-Modified-Since:本地檔案在伺服器上的最後一次修改時間。快取過期時把瀏覽器端快取頁面的最後修改時間傳送到伺服器去,伺服器會把這個時間與伺服器上實際檔案的最後修改時間進行對比,如果時間一致,那麼返回304,客戶端就直接使用本地快取檔案。
  • Etag/If-None-Match:(EntityTags)是URL的tag,用來標示URL物件是否改變,一般為資源實體的雜湊值。和Last-Modified類似,如果伺服器驗證資源的ETag沒有改變(該資源沒有更新),將返回一個304狀態告訴客戶端使用本地快取檔案。Etag的優先順序高於Last-Modified,Etag主要為了解決 Last-Modified 無法解決的一些問題。
    • 檔案也許會週期性的更改,但是他的內容並不改變,不希望客戶端重新get;
    • If-Modified-Since能檢查到的粒度是s級;
    • 某些伺服器不能精確的得到檔案的最後修改時間。
快取策略執行過程
Web靜態資源快取及優化
本地快取過期後,瀏覽器會像伺服器傳送請求,request中會攜帶以下兩個欄位:
  • If-Modified-Since:值為之前response中Last-Modified;
  • If-None-Match:值為之前response中Etag(如果存在的話);
其中在圖右側的“file modified?”判斷中,伺服器會讀取請求頭這兩個值,判斷出客戶端快取的資源是否最新,如果是的話伺服器就會返回HTTP/304 Not Modified響應頭,但沒有響應體。客戶端收到304響應後,就會從快取中讀取對應的資源;否則返回HTTP/200和響應體。
meta是html語言head區的一個輔助性標籤,其中的http-equiv欄位定義了伺服器和使用者代理的一些行為。在之前的規範中,meta的http-equiv欄位中有以下值與http header快取相關的欄位功能類似。
  • Cache-Control
  • Pragma
  • Expires
使用方法:
<meta http-equiv="Cache-Control" content="no-cache" /> <!-- HTTP 1.1 -->
<meta http-equiv="Pragma" content="no-cache" /> <!-- 相容HTTP1.0 -->
<meta http-equiv="Expires" content="0" /> <!-- 資源到期時間設為0 -->複製程式碼
但現在w3c的規範欄位中這些值已經被移除,一個很好的理由是:
Putting caching instructions into meta tags is not a good idea, because although browsers may read them, proxies won`t. For that reason, they are invalid and you should send caching instructions as real HTTP headers.
其實也很好理解,寫在meta標籤中代表必須解析讀取html的內容,但代理伺服器是不會去讀取的。大多瀏覽器已經不再支援,會忽略這樣的寫法,所以快取還是通過HTTP headers去設定。
注:HTTP Headers中的快取設定優先順序比meta中http-equiv更高一些。

2、HTML5 Application Cache

Application Cache是html5引入的本地儲存方案之一,可以構建離線快取。目前除IE10-外其他瀏覽器均支援。
使用方法

a、增加manifest檔案

application cache是通過mannifest檔案來管理的,manifest檔案是簡單的文字檔案,內容是需要被快取供離線使用的檔案列表,及不需要被快取或讀取快取失敗的檔案控制。
  • 檔案的第一行必須是 CACHE MANIFEST
  • #開頭的行作為註釋語句
  • 網站的快取不能超過5M
  • 檔案資源路徑可以使用絕對路徑也可以使用相對路徑
  • 檔案列表中任意一個快取失敗都會導致整個快取失效
  • 既可以站點使用同一個minifest檔案,也可以每個頁面使用一個
檔案包含3個指令
  • CACHE:需要快取的資原始檔,瀏覽器會自動快取帶有manifest屬性的html頁面;
  • NETWORK:不需要快取的檔案,可以使用萬用字元;
  • FALLBACK:無法訪問快取檔案的備選檔案,可以使用萬用字元。

b、伺服器配置

mannifest檔案可以使用任意擴充名,但需要在伺服器中新增MIME型別匹配,使用apache比較簡單,如果使用.manifest作為擴充名在apache配置檔案中新增。
AddType text/cache-manifest .appcache複製程式碼

c、html中引用

<html lang="zh" manifest="main.manifest">複製程式碼
注:千萬不要把manifest檔案本身放在快取檔案列表中,不然瀏覽器無法更新manifest檔案檔案,最好在manifest檔案的http headers中設定其立即過期。
快取載入及更新過程


1、事件

  • cached/checking/downloading/error/noupdate/obsolete/progress/updateready

2、執行過程

第一次載入:
  • Creating Application Cache with manifest(訪問到帶manifest屬性的html檔案,將manifest檔案儲存,載入html檔案及其他資原始檔);
  • Application Cache Checking event(檢查要快取的檔案列表)
  • Application Cache Downloading event(開始下載快取檔案)
  • Application Cache Progress event (0 of 4)(依次下載快取檔案)
  • ……
  • Application Cache Progress event (4 of 4)
  • Application Cache Cached event(檔案快取完畢)
第二次載入:
  • Document was loaded from Application Cache with manifest(從快取中讀取html檔案和其他靜態資原始檔,供頁面展示)
  • Application Cache Checking event(獲取新的manifest檔案,檢查是否更新)
    • 是:重新下載快取檔案,供下次訪問使用(不會影響當前瀏覽器展示內容)
      • Application Cache Downloading event(開始下載快取檔案)
      • Application Cache Progress event (0 of 4)(依次下載快取檔案)
      • ……
      • Application Cache Progress event (4 of 4)
      • Application Cache UpdateReady event(快取檔案更新完畢)
      • Application Cache NoUpdate event(啥也不做)
刪除html中manifest檔案引用
  • Document was loaded from Application Cache with manifest(從快取中讀取html檔案和其他靜態資原始檔,供頁面展示)
  • Application Cache Checking event(獲取新的manifest檔案,檢查是否更新)
  • Application Cache Obsolete event(刪除本地快取中的所有檔案,不再使用快取)
一些問題
  1. Application Cache會預設快取引用manifest檔案的HTML文件,對於動態更新的html頁面來說是個坑(可以使用tricky的iframe嵌入方式來避免);
  2. 只要快取列表中的一個資源載入失敗,所有檔案都將快取失敗;
  3. 如果資源沒有被快取,而又沒有設定NETWORK的情況下,將會無法載入,所以Network中必須使用萬用字元配置;
  4. 快取更新後第一次只能載入manifest檔案,其他靜態資源需要第二次載入才能看到最新效果;
  5. 快取檔案清單中的檔案本身更新瀏覽器是不會重新快取,那怎麼告訴瀏覽器快取需要更新了呢?
    • 更新manifest檔案:修改註釋的版本號或者日期。
    • 通過Application Cache提供的介面(window.applicationCache.swapCache)來檢查更新。
還有最後一個問題,該標準已經從 Web 標準中刪除……
該特性已經從 Web 標準中刪除,雖然一些瀏覽器目前仍然支援它,但也許會在未來的某個時間停止支援,請儘量不要使用該特性。在此刻使用這裡描述的應用程式快取功能高度不鼓勵; 它正在處於從Web平臺中被刪除的過程。請改用Service Workers 代替。

3、PWA(Service Worker)

PWA全稱為“Progressive Web Apps”,漸進式網頁應用,Service Worker是其幾大核心技術之一。
Service worker is a programmable network proxy, allowing you to control how network requests from your page are handled.
沒錯,這就是官方建議替代Application Cache的方案。早在2014年,W3C就公佈了Service Worker的草案。它作為一個獨立的執行緒,是一段在後臺執行的指令碼。它的出現使得web app也可以具有類似native app的離線使用、訊息推送、後臺自動更新等能力。
不過它有以下限制:
  • 不能訪問 DOM
  • 不能使用同步 API
  • 需要HTTPS協議(http://localhost 或 http://127.0.0.1也可)
雖然現在其瀏覽器支援情況並不是很廣泛,但以後應該會大面積支援。本文做簡單介紹,具體使用方法可以參考官方文件《The Offline Cookbook》。

簡單使用

1、首先,要使用Service Worker,需要新增一個Service Worker的js的檔案,然後在我們的html頁面中註冊對這個檔案的引用。
index.html
<script>
navigator.serviceWorker
    .register(`./sw.js`)
   .then(function (registration) {
       // 註冊成功
   });
</script>複製程式碼
2、其次,我們在js檔案中補充Service Worker的生命週期事件。Service Worker生命週期有三部曲:註冊,安裝和啟用。
Web靜態資源快取及優化
一般來說我們需要註冊的有3個事件:
self.addEventListener(`install`, function(event) { 
  /* 安裝後... */
  // cache.addAll:把快取檔案加進來,如a.css,b.js
});

self.addEventListener(`activate`, function(event) {
 /* 啟用後... */
 // caches.delete :更新快取檔案
});

self.addEventListener(`fetch`, function(event) {
  /* 請求資源後... */ 
  // cache.put 攔截請求直接返回快取資料
});複製程式碼
對於獲取檔案和快取檔案,Service worker依賴了兩個 API:Fetch (通過網路重新獲取內容的標準方式) 和 Cache(應用資料的內容儲存,此快取獨立於瀏覽器快取和網路狀態)。
React腳手架create-react-app中已經內建了PWA功能,我們來看下打包後的build資料夾下的檔案結構:

Web靜態資源快取及優化

index.html檔案中引用了static/js/main.js,main.js中註冊了service-worker.js。service-worker.js中我們可以看到有 precacheConfig(快取列表)和 cacheName(版本號)兩個變數。斷開網路,我們看到precacheConfig列表中的檔案仍能從本地載入。

Web靜態資源快取及優化

更新機制

以註冊檔案為service-worker.js為例,每次訪問ServiceWorker控制的頁面,瀏覽器都會載入最新的service-worker.js檔案,跟當前service-worker.js檔案對比,只要內容有任何不同,瀏覽器都會獲取並安裝新檔案。但是不會立即生效,原有的ServiceWorker還是會執行,只有當ServiceWorker控制的頁面全部關閉後,新的ServiceWorker才會被啟用。


4、LocalStorage

LocalStorage雖是瀏覽器端快取一種,但有多少人會用它來快取檔案呢?首先快取讀取需要依靠js的執行,所以前提條件就是能夠讀取到html及js程式碼段;其次檔案的版本更新控制會帶來更多的程式碼層面的維護成本,所以LocalStorage更適合關鍵的業務資料而非靜態資源。


5、CDN快取

這是一種以空間換時間的方案,減少了使用者的訪問延時,也減少的源站的負載。
客戶端瀏覽器先檢查是否有本地快取是否過期,如果過期,則向CDN邊緣節點發起請求,CDN邊緣節點會檢測使用者請求資料的快取是否過期,如果沒有過期,則直接響應使用者請求,此時一個完成HTTP請求結束;如果資料已經過期,那麼CDN還需要向源站發出回源請求。


更新機制

CDN邊緣節點快取策略因服務商不同而不同,但一般都會遵循http標準協議,通過http響應頭中的Cache-control: max-age的欄位來設定CDN邊緣節點資料快取時間。另外可通過CDN服務商提供的“重新整理快取”介面來更新快取。

prebrowsing

預載入是瀏覽器對將來可能被使用資源的一種暗示,一些資源可以在當前頁面使用到,一些可能在將來的某些頁面中被使用。作為開發人員,我們比瀏覽器更加了解我們的應用,所以我們可以對我們的核心資源使用該技術。
通過prebrowsing可以提前快取部分檔案,可作為一種靜態資源載入優化的手段。prebrowsing有以下幾種:
  • dns-prefetch:DNS預解析,告訴瀏覽器未來我們可能從某個特定的 URL 獲取資源,當瀏覽器真正使用到該域中的某個資源時就可以儘快地完成 DNS 解析。多在使用第三方資源時使用。
  • preconnect:預連線,完成 DNS 預解析同時還將進行 TCP 握手和建立傳輸層協議。
  • prerender:預渲染,預先載入文件的所有資源,類似於在一個隱藏的 tab 頁中開啟了某個連結 – 將下載所有資源、建立 DOM 結構、完成頁面佈局、應用 CSS 樣式和執行 JavaScript 指令碼等。
  • prefetch:預獲取,使用 prefetch 宣告的資源是對瀏覽器的提示,暗示該資源可能『未來』會被用到,適用於對可能跳轉到的其他路由頁面進行資源快取。被 prefetch 的資源的載入時機由瀏覽器決定,一般來說優先順序較低,會在瀏覽器『空閒』時進行下載。
  • preload:預載入,主動通知瀏覽器獲取本頁的關鍵資源,只是預載入,載入資源後並不會執行;

prefetch & preload

對於前面三種不少瀏覽器已經內部預設做了優化,而prefetch & preload需要開發者根據情況程式碼手動設定。

相容性

prefetchpreload的瀏覽器支援情況來看,prefetch除了safari外基本瀏覽器都有所支援,但preload作為新出的規範,相容性差些,但safari正慢慢支援這一標準,如在iOS的safari高階選項的試驗性Webkit功能中已經有Link Preload這一選項。

優先順序

preload 是宣告式的 fetch,可以強制瀏覽器請求資源,同時不阻塞文件 onload 事件,是對瀏覽器指示預先請求當前頁需要的資源(關鍵的指令碼,字型,主要圖片)。
prefetch 提示瀏覽器這個資源將來可能需要,但是把決定是否和什麼時間載入這個資源的決定權交給瀏覽器。prefetch 應用場景稍微有些不同 —— 使用者將來可能在其他部分(比如檢視或頁面)使用到的資源。
從以上的描述可以看出,對於preload和prefetch宣告,preload明顯高於prefetch。


注:prebrowsing 好用但千萬不要亂用,除非你非常明確會載入要prebrowsing的檔案,不然會加重瀏覽器負擔適得其反。

應用

接觸過Next.js的同學都知道,next.js提供了一個具有預獲取功能的模組:next/prefetch,看起來功能與prefetch類似,但其優先順序與preload類似。
<Link prefetch href=`/`><a>Home</a></Link>

<Link prefetch href=`/features`> <a>Features</a></Link>

{ /* we imperatively prefetch on hover */ }
<Link href=`/about`>
  <a onMouseEnter={() => { Router.prefetch(`/about`); console.log(`prefetching /about!`) }}>About</a>
</Link>

<Link href=`/contact`><a>Contact (<small>NO-PREFETCHING</small>)</a> </Link>複製程式碼
Web靜態資源快取及優化
由於features連結設定了prefetch,訪問Index頁面時瀏覽器會在頁面載入完畢後從伺服器取feature.js的檔案,在index頁面訪問features頁面時不會再從伺服器請求features.js檔案,直接從本地快取中讀取;contact沒有做處理,從index訪問contact時會從伺服器請求concact.js檔案。
我們還可以發現,在next.js打包出來的html檔案頭中,都會將index.js / error.js / app.js 3個檔案作為preload載入,因為這3個檔案是本頁面中必須用到的資源。
Web靜態資源快取及優化

優化嘗試

不同檔案型別

1、HTML檔案

雖然大多數html只會在每次釋出上線時才會改變,如更新js/css資源的引用地址,所以一般將HTTP Headers中設定一個比較短的max-age值,如cache-control: max-age=300,除此之外建議伺服器開啟Etag。
但以實時內容為主的網站(如金融類)為了頁面的開啟速度,會採取後臺服務生產的方式 ,將所有首頁資料全部生成到html中,省去使用者首次載入時的後臺介面請求等待時間。一般會設定cache-control: no-cache。

2、js/css/img檔案

現在一般都通過檔名進行版本控制。Webpack打包命名可根據檔案內容生成檔名的hash值,每次打包只有當內容改才重新生成hash值。此種情況之下,可以在HTTP Headers設定一個較大的快取時間,如max-age=2592000,儘量避免304請求和伺服器進行請求連線。

// js
output: {
    path: config.build.assetsRoot,
    filename: utils.assetsPath(`js/[name].[chunkhash].js`),
    chunkFilename: utils.assetsPath(`js/[id].[chunkhash].js`),
}
// css
new ExtractTextPlugin({
    filename: utils.assetsPath(`css/[name].[contenthash].css`),
}),複製程式碼

3、webfont

webfont檔案比較特殊,正如這篇文章中所說:
  • 瀏覽器在DOMNode的CSS選擇器中發現@font-face時才會下載web fonts檔案,這個時候瀏覽器已經下載完成html/css/js檔案;
  • 如果在瀏覽器發現需要載入font檔案之前就告訴瀏覽器下載font檔案,會加快檔案下載和頁面載入速度。
其實不同瀏覽器下載font檔案的時間不太一樣,有的碰到css的宣告就會載入,有的會等到dom節點匹配css宣告時載入。
優化實踐
根據以上羅列的快取建議,對當前的一個移動端專案進行優化。專案背景如下:
  • React + + Mobx + Webpack
  • React-Router 單頁 / bundle-loader動態載入 / 使用較大的webfont檔案
1、快取配置
  • 對靜態資原始檔進行如上的HTTP Headers快取配置;
  • 所有的靜態資原始檔通過Service Worker進行快取控制和離線化載入,示範如上不再贅述;
2、其他優化
以其中一個單頁為例,頁面效果如下:
Web靜態資源快取及優化

動態載入的js

這個單頁頁面會開啟幾個小的頁面(紅色圈部分),通過webpack打包之後大概這個樣子:
  • index.ef15ea073fbcadd2d690.js
  • static/js/0.1280b2229fe8e5582ec5.js
  • static/js/1.f3077ec7560cd38684db.js
  • static/js/2.39ecea8ad91ddda09dd0.js
  • static/js/3.d7ecc3abc72a136e8dc1.js
其中第一個index.js會在頁面初次載入,其他4個js會在路由切換時動態載入。考慮下這個頁面的業務場景,只要進入到這個頁面,其他幾個路由是一定會訪問到的。所以如果在頁面載入完成之後,趁戶思考之際就主動把剩下幾個js載入好,豈不完美。
在此選用了preload-webpack-plugin這個外掛,它可以打包將動態路由進行預載入。
webpackConfig.plugins.push(new PreloadWebpackPlugin({
    rel: `prefetch`,
}));複製程式碼

rel屬性還可以選擇preload / prefetch模式。打包出來是這樣:

Web靜態資源快取及優化

訪問頁面可以看到,在不影響dom載入的情況下,瀏覽器預先載入了另外幾個後面將會用到的js,當切換到對應路由時,也會直接從快取取,不從伺服器請求資源。

css檔案

非動態載入(路由)頁面的css會單獨打包,在html檔案中進行引用。除了使用一些打包外掛優化程式碼體積外,可將css更細粒度拆分,如首頁的css+彈窗css+頁面標籤切換的css等。除首頁css外的先預載入,然後動態獲取。但一般來說一個頁面的css大小在合理的程式碼情況下經過gzip壓縮後都不會過大,所以優化的效果並不會太明顯。

動態載入路由中css沒有單獨拆分而是在路由的js中,所以只能隨著js優化了。


webfont檔案

對於font檔案,除了減少檔案大小,設定快取時間之外,也可以通過預載入的方式提前讓瀏覽器下載來提高首屏渲染速度。預載入webfont需要與webpack的html-webpack-plugin結合,打包時將制定的字型插入到html中。網上找了一圈沒有找到現成的外掛,自己來寫一個。

1、寫外掛 

fontpreload-webpack-plugin

2、用外掛

  • 安裝外掛
npm install fontpreload-webpack-plugin --save-dev複製程式碼
  • 在webpack的config檔案的HtmlWebpackPlugin外掛之後增加:
const FontPreloadWebpackPlugin = require(`fontpreload-webpack-plugin`);複製程式碼
webpackConfig.plugins.push(new FontPreloadWebpackPlugin({
    rel: `prefetch`,
    fontNameList: [`fontawesome-webfont`],
    crossorigin: true,
}));複製程式碼

3、打包效果

Web靜態資源快取及優化

本文內容到此結束,如有錯誤歡迎指正。

相關文章