作者:京東云開發者-CHO 張鵬程
本文并不是介紹如何將一個網頁配置成離線應用并支持安裝下載的。研究 PWA 的目的僅僅是為了保證用戶的資源可以直接從本地加載,來忽略全國或者全球網絡質量對頁面加載速度造成影響。當然,如果頁面上所需的資源,除了資源文件外并不需要任何的網絡請求,那它除了不支持安裝到桌面,已經算是一個離線應用了。
什么是 PWA
PWA(Progressive Web App)是一種結合了網頁和原生應用程序功能的新型應用程序開發方法。PWA 通過使用現代 Web 技術,例如 Service Worker 和 Web App Manifest,為用戶提供了類似原生應用的體驗。 從用戶角度來看,PWA 具有以下特點: 1.可離線訪問:PWA 可以在離線狀態下加載和使用,使用戶能夠在沒有網絡連接的情況下繼續瀏覽應用; 2.可安裝:用戶可以將 PWA 添加到主屏幕,就像安裝原生應用一樣,方便快捷地訪問; 3.推送通知:PWA 支持推送通知功能,可以向用戶發送實時更新和提醒; 4.響應式布局:PWA 可以適應不同設備和屏幕大小,提供一致的用戶體驗。
從開發者角度來看,PWA 具有以下優勢: 1.跨平臺開發:PWA 可以在多個平臺上運行,無需單獨開發不同的應用程序; 2.更新便捷:PWA 的更新可以通過服務器端更新 Service Worker 來實現,用戶無需手動更新應用; 3.可發現性:PWA 可以通過搜索引擎進行索引,增加應用的可發現性; 4.安全性:PWA 使用 HTTPS 協議傳輸數據,提供更高的安全性。 總之,PWA 是一種具有離線訪問、可安裝、推送通知和響應式布局等特點的新型應用開發方法,為用戶提供更好的體驗,為開發者帶來更高的效率。 我們從 PWA 的各種能力中,聚焦下其可離線訪問的能力。
Service Worker
離線加載本質上是頁面所需的各種js、css以及頁面本身的html,都可以緩存到本地,不再從網絡上請求。這個能力是通過Service Worker來實現的。 Service Worker 是一種在瀏覽器背后運行的腳本,用于處理網絡請求和緩存數據。它可以攔截和處理網頁請求,使得網頁能夠在離線狀態下加載和運行。Service Worker 可以緩存資源,包括 HTML、CSS、JavaScript 和圖像等,從而提供更快的加載速度和離線訪問能力。它還可以實現推送通知和后臺同步等功能,為 Web 應用帶來更強大的功能和用戶體驗。 某些情況下,Service Worker 和瀏覽器插件的 background 很相似,但在功能和使用方式上有一些區別:
功能差異:Service Worker 主要用于處理網絡請求和緩存數據,可以攔截和處理網頁請求,實現離線訪問和資源緩存等功能。而瀏覽器插件的 background 主要用于擴展瀏覽器功能,例如修改頁面、攔截請求、操作 DOM 等。
運行環境:Service Worker 運行在瀏覽器的后臺,獨立于網頁運行。它可以在網頁關閉后繼續運行,并且可以在多個頁面之間共享狀態。而瀏覽器插件的 background 也在后臺運行,但是它的生命周期與瀏覽器窗口相關,關閉瀏覽器窗口后插件也會被終止。
權限限制:由于安全考慮,Service Worker 受到一定的限制,無法直接訪問 DOM,只能通過 postMessage () 方法與網頁進行通信。而瀏覽器插件的 background 可以直接操作 DOM,對頁面有更高的控制權。
總的來說,Service Worker 更適合用于處理網絡請求和緩存數據,提供離線訪問和推送通知等功能;而瀏覽器插件的 background 則更適合用于擴展瀏覽器功能,操作頁面 DOM,攔截請求等。
注冊
注冊一個 Service Worker 其實是非常簡單的,下面舉個簡單的例子
Service Worker 示例
// service-worker.js // 定義需要預緩存的文件列表 const filesToCache =[ '/', '/index.html', '/styles.css', '/script.js', '/image.jpg' ]; // 安裝Service Worker時進行預緩存 self.addEventListener('install',function(event){ event.waitUntil( caches.open('my-cache') .then(function(cache){ return cache.addAll(filesToCache); }) ); }); // 激活Service Worker self.addEventListener('activate',function(event){ event.waitUntil( caches.keys().then(function(cacheNames){ return Promise.all( cacheNames.filter(function(cacheName){ return cacheName !=='my-cache'; }).map(function(cacheName){ return caches.delete(cacheName); }) ); }) ); }); // 攔截fetch事件并從緩存中返回響應 self.addEventListener('fetch',function(event){ event.respondWith( caches.match(event.request) .then(function(response){ return response ||fetch(event.request); }) ); });上述示例中,注冊 Service Worker 的邏輯包含在 HTML 文件的 同時調整下sw的攔截邏輯。
// 新增runtime緩存
const runtimeCacheName ='runtime-cache-'+ version;
// 符合條件也是緩存優先,但是每次都重新發起網絡請求更新緩存
constisStaleWhileRevalidate =(request)=>{
const url = request.url;
const index =['http://127.0.0.1:5500/mock.js'].indexOf(url);
return index !==-1;
};
self.addEventListener('fetch',function(event){
event.respondWith(
// 嘗試從緩存中獲取響應
caches.match(event.request).then(function(response){
var fetchPromise =fetch(event.request).then(function(networkResponse){
// 符合匹配條件才克隆響應并將其添加到緩存中
if(isStaleWhileRevalidate(event.request)){
var responseToCache = networkResponse.clone();
caches.open(runtimeCacheName).then(function(cache){
cache.put(event.request, responseToCache.clone());
});
}
return networkResponse;
});
// 返回緩存的響應,然后更新緩存中的響應
return response || fetchPromise;
})
);
});
現在每次用戶打開新的頁面,
優先從緩存中獲取資源,同時發起一個網絡請求
有緩存則直接返回緩存,沒有則返回一個fetchPromise
fetchPromise內部更新符合緩存條件的請求
用戶下一次打開新頁面或刷新當前頁面,就會展示最新的內容
通過修改isStaleWhileRevalidate中 url 的匹配條件,就能夠控制是否更新緩存。在上面的示例中,我們可以將index.html從precache列表中移除,放入runtime中,或者專門處理下index.html的放置規則,去更新precache中的緩存。最好不要出現多個緩存桶中存在同一個request的緩存,那樣就不知道走的到底是哪個緩存了。 一般來說,微前端的應用,資源文件都有個固定的存放位置,文件本身通過在文件名上增加hash或版本號來進行區分。我們在isStaleWhileRevalidate函數中匹配存放資源位置的路徑,這樣用戶在第二次打開頁面時,就可以直接使用緩存了。如果是內嵌頁面,可以與平臺溝通,是否可以在應用冷起的時候,偷偷訪問一個資源頁面,提前進行預加載,這樣就能在首次打開的時候也享受本地緩存了。
緩存過期
即使我們緩存了一些資源文件,例如 Iconfont、字體庫等只會更新自身內容,但不會變化名稱的文件。僅使用Stale-While-Revalidate其實也是可以的。用戶會在第二次打開頁面時看到最新的內容。 但為了提高一些體驗,例如,用戶半年沒打開頁面了,突然在今天打開了一下,展示歷史的內容就不太合適了,這時候可以增加一個緩存過期的策略。 如果我們使用的是Workbox,通過使用ExpirationPlugin來實現的。ExpirationPlugin是Workbox中的一個緩存插件,它允許為緩存條目設置過期時間。示例如下所示
import{ registerRoute }from'workbox-routing';
import{ CacheFirst, StaleWhileRevalidate }from'workbox-strategies';
import{ ExpirationPlugin }from'workbox-expiration';
// 設置緩存的有效期為一小時
const cacheExpiration ={
maxAgeSeconds:60*60,// 一小時
};
// 使用CacheFirst策略,并應用ExpirationPlugin
registerRoute(
({ request })=> request.destination ==='image',
newCacheFirst({
cacheName:'image-cache',
plugins:[
newExpirationPlugin(cacheExpiration),
],
})
);
// 使用StaleWhileRevalidate策略,并應用ExpirationPlugin
registerRoute(
({ request })=> request.destination ==='script',
newStaleWhileRevalidate({
cacheName:'script-cache',
plugins:[
newExpirationPlugin(cacheExpiration),
],
})
);
或者我們可以實現一下自己的緩存過期策略。首先是增加緩存過期時間。在原本的更新緩存的基礎上,設置自己的cache-control,然后再放入緩存中。示例中直接刪除了原本的cache-control,真正使用中,需要判斷下,比如no-cache類型的資源,就不要使用緩存了。 每次命中緩存時,都會判斷下是否過期,如果過期,則直接返回從網絡中獲取的最新的請求,并更新緩存。
self.addEventListener('fetch',function(event){
event.respondWith(
// 嘗試從緩存中獲取響應
caches.match(event.request).then(function(response){
var fetchPromise =fetch(event.request).then(function(networkResponse){
if(isStaleWhileRevalidate(event.request)){
// 檢查響應的狀態碼是否為成功
if(networkResponse.status ===200){
// 克隆響應并將其添加到緩存中
var clonedResponse = networkResponse.clone();
// 在存儲到緩存之前,設置正確的緩存頭部
var headers =newHeaders(networkResponse.headers);
headers.delete('cache-control');
headers.append('cache-control','public, max-age=3600');// 設置緩存有效期為1小時
// 創建新的響應對象并存儲到緩存中
var cachedResponse =newResponse(clonedResponse.body,{
status: networkResponse.status,
statusText: networkResponse.statusText,
headers: headers,
});
caches.open(runtimeCacheName).then((cache)=>{
cache.put(event.request, cachedResponse);
});
}
}
return networkResponse;
});
// 檢查緩存的響應是否存在且未過期
if(response &&!isExpired(response)){
return response;// 返回緩存的響應
}
return fetchPromise;
})
);
});
functionisExpired(response){
// 從響應的headers中獲取緩存的有效期信息
var cacheControl = response.headers.get('cache-control');
if(cacheControl){
var maxAgeMatch = cacheControl.match(/max-age=(d+)/);
if(maxAgeMatch){
var maxAgeSeconds =parseInt(maxAgeMatch[1],10);
var requestTime = Date.parse(response.headers.get('date'));
var expirationTime = requestTime + maxAgeSeconds *1000;
// 檢查當前時間是否超過了緩存的有效期
if(Date.now()< expirationTime){
returnfalse;// 未過期
}
}
}
returntrue;// 已過期
}
從 Service Worker 發起的請求,可能會被瀏覽器自身的內存緩存或硬盤緩存捕獲,然后直接返回。
精確清理緩存
下面的內容,默認為微前端應用。 隨著微前端應用的更新,會逐漸出現失效的資源文件一直出現在緩存中,時間長了可能會導致緩存溢出。
定時更新
例如以半年為期限,定期更新sw文件的版本號,每次更新都會一刀切的將上一個版本中的動態緩存干掉,此操作會導致下次加載變慢,因為會重新通過網絡請求的方式加載來創建緩存。但如果更新頻率控制得當,并且資源拆分合理,用戶感知不會很大。
處理不常用緩存
上文中的緩存過期策略,并不適用于此處。因為微服務中資源文件中,只要文件名不變,內容就應該不變。我們只是期望刪除超過一定時間沒有使用的條目,防止緩存溢出。這里也使用Stale-While-Revalidate的原因是為了幫助我們識別長期不使用的js文件,方便刪除。 本來可以使用self.registration.periodicSync.register來創建一個周期性任務,但是由于兼容性問題,放棄了。需要的可自行研究,附上網址。 這里我們換一個條件。每當有網絡請求被觸發時,啟動一個延遲 20s 的debounce函數,來處理緩存問題。先把之前的清除舊版本緩存的函數改名成clearOldResources。然后設定緩存過期時間為 10s,刷新兩次頁面來觸發網路請求,20s 之后,runtime緩存中的mock.js就會被刪除了。真實場景下,延遲函數和緩存過期都不會這么短,可以設置成 5min 和 3 個月。
functiondebounce(func, delay){
let timerId;
returnfunction(...args){
clearTimeout(timerId);
timerId =setTimeout(()=>{
func.apply(this, args);
}, delay);
};
}
const clearOutdateResources =debounce(function(){
cache
.open(runtimeCacheName)
.keys()
.then(function(requests){
requests.forEach(function(request){
cache.match(request).then(function(response){
// response為匹配到的Response對象
if(isExpiredWithTime(response,10)){
cache.delete(request);
}
});
});
});
});
functionisExpiredWithTime(response, time){
var requestTime = Date.parse(response.headers.get('date'));
if(!requestTime){
returnfalse;
}
var expirationTime = requestTime + time *1000;
// 檢查當前時間是否超過了緩存的有效期
if(Date.now()< expirationTime){
returnfalse;// 未過期
}
returntrue;// 已過期
}
重新總結下微前端應用下的緩存配置: 1.使用版本號,并初始化preCache和runtimeCache 2.preCache中預緩存基座數據,使用Cache First策略,sw不更新則基座數據不更新 3.runtimeCache使用Stale-While-Revalidate策略負責動態緩存業務資源的數據,每次訪問頁面都動態更新一次 4.使用debounce函數,每次訪問頁面都會延遲清除過期的緩存 5.如果需要更新preCache中的基座數據,則需要升級版本號并重新安裝sw文件。新服務激活后會刪除上一個版本的數據 6.runtimeCache和preCache不能同時存儲一個資源,否則可能導致混亂。
最終示例
下面是最終的sw.js,我刪除掉了緩存過期的邏輯,如有需要請自行從上文代碼中獲取。順便我增加了一點點喪心病狂的錯誤處理邏輯。 理論上,index.html應該放入預緩存的列表里,但我懶得寫在Stale-While-Revalidate里分別更新preCache和runtimeCache了,相信看完上面內容的你,一定可以自己實現對應邏輯。 如果你用了下面的文件,每次刷新完頁面的 20s 后,runtime 的緩存就會被清空,因為我們過期時間只設置了 10s。而每次發起請求后的 20s 后就會進行過期判斷。 在真實的驗證過程中,有部分
const version ='v1';
const preCacheName ='pre-cache-'+ version;
const runtimeCacheName ='runtime-cache';// runtime不進行整體清除
const filesToCache =[];// 這里將index.html放到動態緩存里了,為了搭自動更新的便車。這個小項目也沒別的需要預緩存的了
const maxAgeSeconds =10;// 緩存過期時間,單位s
const debounceClearTime =20;// 延遲清理緩存時間,單位s
// 符合條件也是緩存優先,但是每次都重新發起網絡請求更新緩存
constisStaleWhileRevalidate =(request)=>{
const url = request.url;
const index =[`${self.location.origin}/mock.js`,`${self.location.origin}/index.html`].indexOf(url);
return index !==-1;
};
/*********************上面是配置代碼***************************** */
constaddResourcesToCache =async()=>{
return caches.open(preCacheName).then((cache)=>{
return cache.addAll(filesToCache);
});
};
// 安裝Service Worker時進行預緩存
self.addEventListener('install',function(event){
event.waitUntil(
addResourcesToCache().then(()=>{
self.skipWaiting();
})
);
});
// 刪除上個版本的數據
asyncfunctionclearOldResources(){
return caches.keys().then(function(cacheNames){
return Promise.all(
cacheNames
.filter(function(cacheName){
return![preCacheName, runtimeCacheName].includes(cacheName);
})
.map(function(cacheName){
return caches.delete(cacheName);
})
);
});
}
// 激活Service Worker
self.addEventListener('activate',function(event){
event.waitUntil(
clearOldResources().finally(()=>{
self.clients.claim();
clearOutdateResources();
})
);
});
// 緩存優先
constisCacheFirst =(request)=>{
const url = request.url;
const index = filesToCache.findIndex((u)=> url.includes(u));
return index !==-1;
};
functionaddToCache(cacheName, request, response){
try{
caches.open(cacheName).then((cache)=>{
cache.put(request, response);
});
}catch(error){
console.error('add to cache error =>', error);
}
}
asyncfunctioncacheFirst(request){
try{
return caches
.match(request)
.then((response)=>{
if(response){
return response;
}
returnfetch(request).then((response)=>{
// 檢查是否成功獲取到響應
if(!response || response.status !==200){
return response;// 返回原始響應
}
var clonedResponse = response.clone();
addToCache(runtimeCacheName, request, clonedResponse);
return response;
});
})
.catch(()=>{
console.error('match in cacheFirst error', error);
returnfetch(request);
});
}catch(error){
console.error(error);
returnfetch(request);
}
}
// 緩存優先,同步更新
asyncfunctionhandleFetch(request){
try{
clearOutdateResources();
// 嘗試從緩存中獲取響應
return caches.match(request).then(function(response){
var fetchPromise =fetch(request).then(function(networkResponse){
// 檢查響應的狀態碼是否為成功
if(!networkResponse || networkResponse.status !==200){
return networkResponse;
}
// 克隆響應并將其添加到緩存中
var clonedResponse = networkResponse.clone();
addToCache(runtimeCacheName, request, clonedResponse);
return networkResponse;
});
// 返回緩存的響應,然后更新緩存中的響應
return response || fetchPromise;
});
}catch(error){
console.error(error);
returnfetch(request);
}
}
self.addEventListener('fetch',function(event){
const{ request }= event;
if(isCacheFirst(request)){
event.respondWith(cacheFirst(request));
return;
}
if(isStaleWhileRevalidate(request)){
event.respondWith(handleFetch(request));
return;
}
});
functiondebounce(func, delay){
let timerId;
returnfunction(...args){
clearTimeout(timerId);
timerId =setTimeout(()=>{
func.apply(this, args);
}, delay);
};
}
const clearOutdateResources =debounce(function(){
try{
caches.open(runtimeCacheName).then((cache)=>{
cache.keys().then(function(requests){
requests.forEach(function(request){
cache.match(request).then(function(response){
const isExpired =isExpiredWithTime(response, maxAgeSeconds);
if(isExpired){
cache.delete(request);
}
});
});
});
});
}catch(error){
console.error('clearOutdateResources error => ', error);
}
}, debounceClearTime *1000);
functionisExpiredWithTime(response, time){
var requestTime = Date.parse(response.headers.get('date'));
if(!requestTime){
returnfalse;
}
var expirationTime = requestTime + time *1000;
// 檢查當前時間是否超過了緩存的有效期
if(Date.now()< expirationTime){
returnfalse;// 未過期
}
returntrue;// 已過期
}
注意
在真實的驗證過程中,有部分資源獲取不到date這個數據,因此為了保險,我們還是在存入緩存時,自己補充一個存入時間
// 克隆響應并將其添加到緩存中
var clonedResponse = networkResponse.clone();
// 在存儲到緩存之前,設置正確的緩存頭部
var headers =newHeaders(networkResponse.headers);
headers.append('sw-save-date', Date.now());
// 創建新的響應對象并存儲到緩存中
var cachedResponse =newResponse(clonedResponse.body,{
status: networkResponse.status,
statusText: networkResponse.statusText,
headers: headers,
});
在判斷過期時,取我們自己寫入的key即可。
functionisExpiredWithTime(response, time){
var requestTime =Number(response.headers.get('sw-save-date'));
if(!requestTime){
returnfalse;
}
var expirationTime = requestTime + time *1000;
// 檢查當前時間是否超過了緩存的有效期
if(Date.now()< expirationTime){
returnfalse;// 未過期
}
returntrue;// 已過期
}
不可見響應
還記得上面為了安全考慮,在存入緩存時,對響應的狀態做了判斷,非 200 的都不緩存。然后就又發現異常場景了。
// 檢查是否成功獲取到響應
if(!response || response.status !==200){
return response;// 返回原始響應
}
opaque響應通常指的是跨源請求(CORS)中的一種情況,在該情況下,瀏覽器出于安全考慮,不允許訪問服務端返回的響應內容。opaque響應通常發生在服務工作者(Service Workers)進行的跨源請求中,且沒有 CORS 頭部的情況下。 opaque響應的特征是:
響應的內容無法被 JavaScript 訪問。
響應的大小無法確定,因此 Chrome 開發者工具中會顯示為 (opaque)。
響應的狀態碼通常是 0,即使實際上服務器可能返回了不同的狀態碼。
因此我們需要做一些補充動作。不單是補充cors模式,還得同步設置下credentials。
const newRequest =
request.url ==='index.html'
? request
:newRequest(request,{ mode:'cors', credentials:'omit'});
在 Service Workers 發起網絡請求時,如果頁面本身需要認證,那就像上面代碼那樣,對頁面請求做個判斷。request.url === 'index.html'是我寫的示例,真實請求中,需要拼出完整的 url 路徑。而對于資源文件,走非認證的cors請求即可。將請求的request改為我們變更后的newRequest,請求資源就可以正常的被緩存了。
var fetchPromise =fetch(newRequest).then(function(networkResponse)
銷毀
離線緩存用得好升職加薪,用不好就刪庫跑路。除了上面的一點點的防錯邏輯,整體的降級方案一定要有。 看到這里,應該已經忘了 Service Worker 是如何被注冊上的吧。沒事,我們看個新的腳本。在原本的基礎上,我們加了個變量SW_FALLBACK,如果離線緩存出問題了,趕緊到管理后臺,把對應的值改成true。讓用戶多刷新兩次就好了。只要不是徹底的崩潰導致html無法更新,這個方案就沒問題。
// 如果有問題,將此值改成true
SW_FALLBACK=false;
if('serviceWorker'in navigator){
if(!SW_FALLBACK){
navigator.serviceWorker
.register('/eemf-service-worker.js')
.then((registration)=>{
console.log('Service Worker 注冊成功!');
})
.catch((error)=>{
console.log('Service Worker 注冊失敗:', error);
});
}else{
navigator.serviceWorker.getRegistration('/').then((reg)=>{
reg && reg.unregister();
if(reg){
window.location.reload();
}
});
}
}
對于沒有管理后臺配置html的項目,可以將上面的腳本移動到sw-register.js的腳本中,在html以script的形式加載該腳本,并將該文件緩存設置為no-cache,也不要在sw中緩存該文件。這樣出問題后,覆寫下該文件即可。
總結
所有要說的,在上面都說完了。PWA 的離線方案,是一種很好的解決方案,但是也有其局限性。本項目所用的 demo 已經上傳到了github,可自行查看。
審核編輯:黃飛
-
Web
+關注
關注
2文章
1303瀏覽量
74255 -
網絡連接
+關注
關注
0文章
96瀏覽量
11588 -
HTML
+關注
關注
0文章
280瀏覽量
47872
發布評論請先 登錄
什么是PWA?PWA離線方案研究分析
評論