離線化/長緩存方案探究與實踐
最近在做資源改造,所有靜態(tài)資源的url由服務(wù)端下發(fā),而且?guī)狭苏J證和過期時間等params,導致靜態(tài)資源優(yōu)化利器之一的HTTP緩存失效了:只要下發(fā)資源的url過期了資源就會重新請求下載,但實際上該資源并沒有變更。為了解緩存相關(guān)的問題,本文就從三個方面來探究離線化/長緩存:
HTTP緩存 離線緩存(Application Cache) Service Worker
HTTP緩存
引用我的另一篇文章:一文讀懂HTTP緩存機制[1]一句話概況:本地緩存請求到的資源,后續(xù)請求盡可能直接復用這些資源,減少HTTP請求,從而顯著提高網(wǎng)站和應(yīng)用程序的性能。那么什么時候緩存資源到本地?緩存資源什么時候過期?什么情況下使用這些緩存的資源呢?
緩存機制流程
從流程中可以看到,瀏覽器發(fā)起資源請求后,大致有三部分:強緩存校驗、協(xié)商緩存校驗、資源請求。本文主要講解強緩存和協(xié)商緩存模塊,資源請求部分就是正常的一次HTTP交互過程,但值得注意的是:因為一般只有GET請求才會被緩存,所以這里泛指一般的GET資源請求。
強緩存
不需要額外向服務(wù)端發(fā)送請求,直接使用本地緩存。在Chrome瀏覽器中本地強緩存分為兩類,一類是disk cache,一類是memory cache,查看devtools中的Networks會看到請求狀態(tài)為200,并且后面跟著from disk cache和from memory cache的請求就是使用了強緩存,如下面兩個圖。
本人也尚未了解Chrome瀏覽器如何控制兩種強緩存,故不展開了,以免誤導讀者,希望能有高手指出?。。。∵@里放上找到的Chrome官方文檔[2]中的描述,其大體意思是兩種強緩存策略與渲染進程的生命周期有關(guān),渲染進程的周期又大致與tab選項卡相對應(yīng):
Chrome employs two caches — an on-disk cache and a very fast in-memory cache. The lifetime of an in-memory cache is attached to the lifetime of a render process, which roughly corresponds to a tab. Requests that are answered from the in-memory cache are invisible to the web request API.
是否使用強緩存由HTTP的三個頭部字段來控制:Expires、Pragma、Cache-Control。
Expires
Exipres字段是http/1.0中的字段,其優(yōu)先級在三個緩存控制字段中最低。
如圖所示,響應(yīng)頭中Expires的值是一個時間戳,發(fā)起請求時,如果本地系統(tǒng)時間在這個時間戳之前,則緩存有效,否則緩存失效,進入?yún)f(xié)商緩存。若該響應(yīng)頭中Expires設(shè)置為無效的日期,比如 0, 則代表著過去的日期,即該資源已經(jīng)過期。
Cache-Control
Cache-Control是 HTTP/1.1 中規(guī)定的通用頭部字段,常用屬性如下:
no-store:禁止使用緩存,每次請求都去服務(wù)端拿最新的資源; no-cache:不使用強緩存,直接進入?yún)f(xié)商緩存模塊,向服務(wù)端請求校驗資源是否“新鮮”; private:私有緩存,中間代理服務(wù)端不可緩存資源 public:公共緩存,中間代理服務(wù)端可以緩存資源 max-age:單位:秒,緩存的最長有效時間。其起始時間為緩存時響應(yīng)頭中的Date字段,即有效期到responseDate + max-age,發(fā)起請求時超過該時間則緩存過期。 must-revalidate:緩存一旦過期,則必須重新向服務(wù)端驗證。
Pragma
Pragma是 HTTP/1.0 中規(guī)定的通用頭部字段,用于向后兼容只支持 HTTP/1.0 協(xié)議的緩存服務(wù)端。這個字段只有一個值:no-cache,其表現(xiàn)行為與Cache-Control: no-cache一致,但是HTTP的響應(yīng)頭沒有明確定義這個屬性,所以它不能拿來完全替代HTTP/1.1中定義的Cache-control頭。
如果Pragma 和 Cache-Control 兩個字段同時存在,Pragma的優(yōu)先級大于Cache-Control。
協(xié)商緩存
當強緩存過期或者請求頭字段設(shè)置不走強緩存,比如Cache-Control:no-cache和Pragma:no-cache,則進入?yún)f(xié)商緩存部分。協(xié)商緩存涉及兩對頭部字段,分別是Last-Modified/If-Modified-Since、和ETag/If-None-Match。若請求頭中攜帶If-Modified-Since或If-None-Match字段,則會發(fā)起去服務(wù)端校驗資源是否有變化,如果有變化,則未命中緩存,服務(wù)端返回200,瀏覽器計算響應(yīng)體資源是否緩存并使用資源;如果未變換,則命中緩存,返回304,瀏覽器根據(jù)響應(yīng)頭更新緩存頭部信息,延長有效期,并直接使用緩存。
Last-Modified/If-Modified-Since
Last-Modified/If-Modified-Since的值是資源修改時間。第一次請求資源時,服務(wù)端將資源的最后修改時間放到響應(yīng)頭的 Last-Modified 字段中,第二次請求該資源時,瀏覽器會自動將該資源上一次響應(yīng)頭中的Last-Modified的值放到第二次請求頭的If-Modified-Since字段中,服務(wù)端比較服務(wù)端資源的最后一次修改時間和請求頭中的If-Modified-Since 的值,如果相等,則命中緩存返回 304,否則,返回200。
ETag/If-None-Match
ETag/If-None-Match 的值是一串hash值(hash算法不統(tǒng)一),是資源的標識符,當資源內(nèi)容發(fā)生變化,其hash值也會改變。其過程與上面的相似,不過服務(wù)端是比較服務(wù)端資源的hash值和請求頭中的If-None-Match的值,但比較方式有所區(qū)別,因為ETag有兩種類型:
強校驗:資源hash值具有唯一性,一旦變化則hash也變化。 弱校驗:資源hash值以W/開頭,若資源變化較小,則同樣可能命中緩存。
例如下面這樣:
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4" ETag: W/"0815"
兩者區(qū)別
ETag/If-None-Match優(yōu)先級比Last-Modified/If-Modified-Since高; Last-Modified/If-Modified-Since有個1S問題,即服務(wù)端在1S內(nèi)修改文件,且再次受到請求時,會錯誤的返回304。
代理服務(wù)緩存
Vary是HTTP/1.1中的一個頭字字段,其值為請求頭中的字段,如上圖中的Accept-Encoding,可以是多個,以逗號分割,其記錄了代理服務(wù)器返回資源參考了哪些請求頭字段。代理服務(wù)器拿到源服務(wù)器的響應(yīng)報文,會根據(jù) Vary 里的字段列表,緩存不同版本的資源。當有資源請求再次訪問時,代理服務(wù)器會分析請求頭字段,返回正確的版本。
Application Cache(已廢棄)
雖然部分瀏覽器依然支持,但是W3C已經(jīng)廢棄該方案,推薦開發(fā)者使用Service Worker。
簡介
HTML5的離線存儲(Application Cache)是基于一個manifest文件(緩存清單文件,一般后綴為.appcache)的緩存機制(不是存儲技術(shù))。在該文件中定義需要緩存的文件,支持manifest的瀏覽器,會將按照manifest文件的規(guī)則,像文件保存在本地,之后當網(wǎng)絡(luò)在處于離線狀態(tài)時,瀏覽器會通過被離線存儲的數(shù)據(jù)進行頁面展示。主要應(yīng)用在內(nèi)容變動少、相對固定的場景下。其流程大致如下:
它具備以下優(yōu)勢:
離線瀏覽 - 用戶可在應(yīng)用離線時使用它們。 更快的速度 - 已緩存資源加載得更快。 減少服務(wù)器負載 - 瀏覽器將只從服務(wù)器下載更新過或更改過的資源。
文件配置
一個比較典型的manifest文件結(jié)構(gòu)如下:
CACHE MANIFEST
#version 1.0
CACHE:
/static/img/dalizhineng.c66247e.png
http://localhost:8080/static/img/setting-icon-hover.413c0d7.png
NETWORK:
*
FALLBACK:
/html5/ /404.html
第一行的CACHE MANIFEST是固定行,必須寫在前面。一般第二行是以 # 號開頭的注釋,當有緩存文件需要更新時,更改注釋內(nèi)容即可。可以是版本號,時間戳或者md5碼等。剩下內(nèi)容分為三個部分(可按任意順序排列,且每個部分均可在同一清單中重復出現(xiàn)):
CACHE(必填)
標識出哪些文件需要緩存,可以是相對路徑,也可以是絕對路徑。
NETWORK(可選)
標識出哪些文件必須經(jīng)過網(wǎng)絡(luò)請求。可以是相對路徑或絕對路徑,表示指定資源必須經(jīng)過網(wǎng)絡(luò)請求;也可以直接使用通配符*,表示除CACHE外的所有資源都需要網(wǎng)絡(luò)請求。比如下面的例子就是‘index.css’永遠不會被緩存,必須走網(wǎng)絡(luò)請求。
NETWORK:
index.css
FALLBACK(可選)
標識出指定資源無法訪問時,瀏覽器會使用fallback資源。其中每條記錄都列出兩個URI:第一個表示資源,第二個表示fallback資源。兩個 URI 都必須使用相對路徑并且與manifest文件同源??梢允褂猛ㄅ浞?,比如下面的例子就是頁面無法訪問時,使用404.html替代。
FALLBACK:
*.html /404.html
使用方法
在文檔的html標簽中設(shè)置manifest 屬性,引用manifest文件 ,可指向絕對網(wǎng)址或相對路徑,但絕對網(wǎng)址必須與相應(yīng)的網(wǎng)絡(luò)應(yīng)用同源,且必須要在服務(wù)器端正確的配置MIME-type,即“text/cache-manifest”。
<html lang="en" manifest="manifest.appcache">
訪問及操作緩存
部分瀏覽器提供了 window.applicationCache[3] 對象來訪問和操作離線緩存。
緩存狀態(tài)
window.applicationCache.status屬性表示當前緩存狀態(tài)。
| 狀態(tài) | 狀態(tài)值 | 描述 |
|---|---|---|
| UNCACHED | 0 | 無緩存, 即沒有與頁面相關(guān)的應(yīng)用緩存 |
| IDLE | 1 | 閑置,即應(yīng)用緩存未得到更新 |
| CHECKING | 2 | 檢查中,即正在下載描述文件并檢查更新 |
| DOWNLOADING | 3 | 下載中,即應(yīng)用緩存正在下載描述文件 |
| UPDATEREADY | 4 | 更新完成,所有資源都已下載完畢 |
| OBSOLETE | 5 | 廢棄,即應(yīng)用緩存的描述文件已經(jīng)不存在了,因此頁面無法再訪問應(yīng)用緩存 |
緩存事件
| 事件名 | 描述 |
|---|---|
| cached | 下載完成并且首次將應(yīng)用程序下載到緩存中時,瀏覽器會觸發(fā)“cached“事件 |
| checking | 每當應(yīng)用程序載入的時候,都會檢查該清單文件,也總會首先觸發(fā)“checking”事件 |
| downloading | 如果還未緩存應(yīng)用程序,或者清單文件有改動,那么瀏覽器會下載并緩存清單中的所有資源 ,觸發(fā)"downloading"事件,同時意味著下載過程開始 |
| error | 如果瀏覽器處于離線狀態(tài),檢查清單列表失敗,則會觸發(fā)“error“事件,當一個未緩存的應(yīng)用程序引用一個不存在的清單文件,也會觸發(fā)此事件 |
| noupdate | 如果沒有改動,同時應(yīng)用程序也已經(jīng)緩存了,“noupdate”事件被觸發(fā),整個過程結(jié)束 |
| obsolete | 如果一個緩存的應(yīng)用程序引用一個不存在的清單文件,會觸發(fā)“obsolete“,同時將應(yīng)用從緩存中移除之后不會從緩存而是通過網(wǎng)絡(luò)加載資源 |
| progress | 在下載過程中會間斷性觸發(fā)“progress”事件,通常是在每個文件下載完畢的時候 |
| updateready | 當下載完成并將緩存中的應(yīng)用程序更新后,瀏覽器會觸發(fā)”updaterady”事件 |
緩存方法
| 方法名 | 描述 |
|---|---|
| abort | 取消資源加載 |
| swapCache | 使用新緩存替換舊緩存,不過使用location.reload()更方便 |
| update | 更新緩存 |
注意事項
更新清單中列出的某個文件并不意味著瀏覽器會重新緩存該資源,清單文件本身必須進行更改。 瀏覽器對緩存數(shù)據(jù)的容量限制可能不太一樣(某些瀏覽器設(shè)置的限制是每個站點5MB)。 如果manifest文件,或者內(nèi)部列舉的某一個文件不能正常下載,整個更新過程都將失敗,瀏覽器繼續(xù)全部使用老的緩存。 引用manifest的html必須與manifest文件同源,在同一個域下。FALLBACK中的資源必須和manifest文件同源。 瀏覽器會自動緩存引用manifest文件的HTML文件,這就導致如果改了HTML內(nèi)容,也需要更新manifest 文件版本或者由程序來更新應(yīng)用緩存才能做到更新。
Service Worker
簡介
service worker也是一種web worker[4],額外擁有持久離線緩存的能力。宿主環(huán)境會提供單獨的線程來執(zhí)行其腳本,解決js中耗時間、耗資源的運算過程帶來的性能問題。從下圖可以看到除IE以外,支持度挺高的。
特點
獨立于JS引擎的主線程,在后臺運行的腳本,不影響頁面渲染 被install后就永遠存在,除非被手動卸載。手動卸載方式:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations()
.then(function (registrations) {
for (let registration of registrations) {
// 找到需要移除的SW
if (registration && registration.scope === 'https://xxx.com') {
registration.unregister();
}
}
});
}
可攔截請求和返回,緩存文件。sw可以通過fetch這個api,來攔截網(wǎng)絡(luò)和處理網(wǎng)絡(luò)請求,再配合cacheStorage來實現(xiàn)web頁面的緩存管理以及與前端postMessage通信。

不能直接操縱dom:因為sw是個獨立于網(wǎng)頁運行的腳本,所以在它的運行環(huán)境里,不能訪問窗口的window以及dom。 生產(chǎn)環(huán)境必須是https的協(xié)議才能使用。在本地調(diào)試時,http://localhost 和http://127.0.0.1 也可以使用,不過需要勾選Bypass for network,否則資源靜態(tài)資源都會被緩存(沒有hash值),導致無法調(diào)試。

異步實現(xiàn),sw大量使用promise。 根據(jù)文檔中[5]的描述,SW層面上cacheStorage容量不受限,但還是受到宿主環(huán)境 QuotaManager 的限制。
作用域
SW的作用域是一個 URL path 地址,表示SW能夠控制的頁面的范圍。比如下面就能控制http://localhost:8080/ehx-room/ 目錄下的所有頁面。默認的作用域就是注冊時候的 path,下面的例子就是./ehx-room/sw.js。
也可以在 navigator.serviceWorker.register() 方法中傳入 {scope: '/xxx/yyyy/'} 參數(shù)指定作用域,但是指定scope必須在SW注冊的path的目錄下,比如上面的sw注冊時加上,{scope: '/'}就會報錯。
生命周期
當我們注冊了Service Worker后,它會經(jīng)歷生命周期的各個階段,同時會觸發(fā)相應(yīng)的事件。整個生命周期包括了:installing --> installed --> activating --> activated --> redundant。當Service Worker安裝(installed)完畢后,會觸發(fā)install事件;而激活(activated)后,則會觸發(fā)activate事件。
Installing
該狀態(tài)發(fā)生在service worker注冊之后,表示開始安裝。在這個過程會觸發(fā)install事件,可以進行資源離線緩存。
在install回調(diào)事件函數(shù)中,可以調(diào)用event.waitUntil()方法并傳入一個promise,直到promise完成才會結(jié)束install。 也可以使用self.skipWaiting()方法直接進入activating狀態(tài),無需等待其他的Service worker被關(guān)閉
Installed
SW已經(jīng)完成了安裝,進入了waiting狀態(tài),等待其他的Service worker被關(guān)閉
Activating
在這個狀態(tài)下沒有被其他的SW控制的客戶端,允許當前的 worker 完成安裝,并且清除了其他的 worker 以及關(guān)聯(lián)緩存的舊緩存資源,等待新的 Service Worker 線程被激活。
Activated
在這個狀態(tài)會處理activate事件回調(diào),并提供處理功能性事件:fetch、sync、push。
除了支持event.waitUntil()方法以外,在activate回調(diào)事件函數(shù)中,還可以使用self.clients.claim()方法控制當前打開的網(wǎng)頁,且不需要刷新。
Redundant
這個狀態(tài)表示一個SW的生命周期結(jié)束,正在被另一個SW替代。
工作流程

在主線程成功注冊 Service Worker 之后,開始下載并解析執(zhí)行 Service Worker 文件,執(zhí)行過程中開始安裝 Service Worker,在此過程中會觸發(fā) worker 線程的 install 事件。 如果 install 事件回調(diào)成功執(zhí)行(在 install 回調(diào)中通常會做一些緩存讀寫的工作,可能會存在失敗的情況),則開始激活 Service Worker,在此過程中會觸發(fā) worker 線程的 activate 事件,如果 install 事件回調(diào)執(zhí)行失敗,則生命周期進入 Error 終結(jié)狀態(tài),終止生命周期。 完成激活之后,Service Worker 就能夠控制作用域下的頁面的資源請求,可以監(jiān)聽 fetch 事件。 如果在激活后 Service Worker 被 unregister 或者有新的 Service Worker 版本更新,則當前 Service Worker 生命周期完結(jié),進入 Terminated 終結(jié)狀態(tài)。
示例
// 在頁面onload事件回調(diào)中,注冊SW
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('service-worker.js')
.then(registration => {
// 注冊成功
})
.catch(err => {
// 注冊失敗
});
});
}
// service-worker.js
const CACHE_VERSION = 'unique_v1';
// 監(jiān)聽activate事件,激活后清除其他緩存
self.addEventListener('activate', event => {
const cachePromise = caches.keys().then(keys => {
return Promise.all(
keys.map(key => {
if (key !== CACHE_VERSION) {
return caches.delete(key);
}
})
);
});
event.waitUntil(cachePromise).then(() => {
// 通過clients.claim方法,讓新的SW獲得當前頁面的控制權(quán)
return self.clients.claim();
});
});
self.addEventListener('fetch', event => {
event.respondWith(
caches
.match(event.request, {
// 忽略url上的query部分
ignoreSearch: DEFAULT_CONFIG.ignoreURLParametersMatching,
})
.then(response => {
// 如果匹配到緩存里的資源,則直接返回
if (response) {
return response;
}
// 匹配失敗則繼續(xù)請求,拷貝原始請求
const request = event.request.clone();
const url = request.url;
if (matchOne(url, DEFAULT_CONFIG.exclude)) {
return fetch(request);
} else if (request.method === 'GET' && matchOne(url, DEFAULT_CONFIG.include)) {
return fetch(request).then(httpRes => {
// 正確請求才緩存
if (httpRes && [200, 304].includes(httpRes.status)) {
// 緩存資源
const responseClone = httpRes.clone();
caches.open(DEFAULT_CONFIG.cacheId).then(cache => {
cache.put(event.request, responseClone);
});
}
return httpRes;
});
} else {
return fetch(request);
}
}),
);
});
總結(jié)
| 方法\類別 | 顆粒度 | 是否需要聯(lián)網(wǎng) | 能否主動更新 | 大小限制 |
|---|---|---|---|---|
| HTTP緩存 | 單個資源 | 強緩存資源可離線使用 | 否 | 瀏覽器QuotaManager限制 |
| Application Cache | 整個應(yīng)用 | 否 | 是 | 一般5MB |
| Service Worker | 單個資源 | 否 | 否 | 瀏覽器QuotaManager限制 |
參考文檔
一文讀懂HTTP緩存機制[6]
借助Service Worker和cacheStorage緩存及離線開發(fā)[7]
Workbox webpack Plugins[8]讓你的WebApp離線可用[9]
HTML5 離線緩存-manifest簡介[10]
應(yīng)用緩存初級使用指南[11]
第4章 Service Worker · PWA 應(yīng)用實戰(zhàn)[12]
Service Worker離線緩存實踐[13]
參考資料
一文讀懂HTTP緩存機制: https://blog.csdn.net/sinat_36521655/article/details/106221905
[2]Chrome官方文檔: https://developer.chrome.com/docs/extensions/reference/webRequest/#Caching
[3]window.applicationCache: https://webplatform.github.io/docs/apis/appcache/ApplicationCache/
[4]web worker: https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Workers_API
[5]文檔中: https://juejin.cn/post/6844903485457055758
[6]一文讀懂HTTP緩存機制: https://juejin.cn/post/6844904163172679688
[7]借助Service Worker和cacheStorage緩存及離線開發(fā): https://www.zhangxinxu.com/wordpress/2017/07/service-worker-cachestorage-offline-develop/
[8]Workbox webpack Plugins: https://developers.google.com/web/tools/workbox/modules/workbox-webpack-plugin
[9]讓你的WebApp離線可用: https://pwa.alienzhou.com/3-rang-ni-de-webapp-li-xian-ke-yong
[10]HTML5 離線緩存-manifest簡介: http://igeekbar.com/igeekbar/post/306.htm
[11]應(yīng)用緩存初級使用指南: https://www.html5rocks.com/zh/tutorials/appcache/beginner/
[12]第4章 Service Worker · PWA 應(yīng)用實戰(zhàn): https://lavas-project.github.io/pwa-book/chapter04.html
[13]Service Worker離線緩存實踐: https://juejin.cn/post/6844903906670018568

往期推薦



最后
歡迎加我微信,拉你進技術(shù)群,長期交流學習...
歡迎關(guān)注「前端Q」,認真學前端,做個專業(yè)的技術(shù)人...


