簡單介紹
PWA(Progressive Web App)漸進(jìn)式Web APP,它并不是單只某一項技術(shù),而是一系列技術(shù)綜合應(yīng)用的結(jié)果,其中主要包含的相關(guān)技術(shù)就是Service Worker、Cache Api、Fetch Api、Push API、Notification API 和 postMessage API。使用PWA可以給我們帶來什么好處呢?主要體現(xiàn)在如下幾方面
1 離線緩存
2 web頁面添加桌面快速入口
3 消息推送
相關(guān)知識
Service Worker
簡單來說,Service Worker 是一個可編程的 Web Worker,它就像一個位于瀏覽器與網(wǎng)絡(luò)之間的客戶端代理,可以攔截、處理、響應(yīng)流經(jīng)的 HTTP 請求。它沒有調(diào)用 DOM 和其他頁面 api 的能力,但他可以攔截網(wǎng)絡(luò)請求,包括頁面切換,靜態(tài)資源下載,ajax請求所引起的網(wǎng)絡(luò)請求。Service Worker 是一個獨(dú)立于JavaScript主線程的瀏覽器線程。Service Worker有如下特性:
- 必須在 HTTPS 環(huán)境下才能工作(在開發(fā)模式下http://localhost也可以工作)
- 不能直接操作 DOM,(但是可以通過postMessage發(fā)送某些信號,主進(jìn)程根據(jù)信號類型,進(jìn)行不同的操作)
- 一個獨(dú)立的 worker 線程,獨(dú)立于當(dāng)前網(wǎng)頁進(jìn)程,有自己獨(dú)立的 worker context。
- 運(yùn)行于瀏覽器后臺,可以控制打開的作用域范圍下所有的頁面請求
- Service Worker 必須要在主線中進(jìn)行注冊
- 一旦被 install,就永遠(yuǎn)存在,除非被手動 unregister
- 用到的時候可以直接喚醒,不用的時候自動睡眠
注冊Service Work
我們需要在主線程中注冊Service Worker,并且一般是在頁面觸發(fā)load事件之后進(jìn)行注冊。當(dāng)Service Worker注冊成功后便會進(jìn)入其生命周期。scope代表Service Worker控制該路徑下的所有請求,如果請求路徑不是在該路徑之下,則請求不會被攔截。
// 注冊service worker
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js', {scope: '/'})
.then(function (registration) {
// 注冊成功
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch(function (err) {
// 注冊失敗:(
console.log('ServiceWorker registration failed: ', err);
});
});
Service Worker生命周期
Service Worker生命周期大致如下
install -> installed -> actvating -> Active -> Activated -> Redundant

在Service Worker注冊成功之后就會觸發(fā)install事件,在觸發(fā)install事件后,我們就可以開始緩存一些靜態(tài)資。waitUntil方法確保所有代碼執(zhí)行完畢后,Service Worker 才會完成Service Worker的安裝。需要注意的是只有CACHE_LIST中的資源全部安裝成功后,才會完成安裝,否則失敗,進(jìn)入redundant狀態(tài),所以這里的靜態(tài)資源最好不要太多。如果 sw.js 文件的內(nèi)容有改動,當(dāng)訪問網(wǎng)站頁面時瀏覽器獲取了新的文件,它會認(rèn)為有更新,于是會安裝新的文件并觸發(fā) install 事件。但是此時已經(jīng)處于激活狀態(tài)的舊的 Service Worker 還在運(yùn)行,新的 Service Worker 完成安裝后會進(jìn)入 waiting 狀態(tài)。直到所有已打開的頁面都關(guān)閉,舊的 Service Worker 自動停止,新的 Service Worker 才會在接下來打開的頁面里生效。為了能夠讓新的Service Worker及時生效,我們使用skipWaiting直接使Service Worker跳過等待時期,從而直接進(jìn)入下一個階段。
const CACHE_NAME = 'cache_v' + 2;
const CACGE_LIST = [
'/',
'/index.html',
'/main.css',
'/app.js',
'/icon.png'
];
function preCache() {
// 安裝成功后操作 CacheStorage 緩存,使用之前需要先通過 caches.open() 打開對應(yīng)緩存空間。
return caches.open(CACHE_NAME).then(cache => {
// 通過 cache 緩存對象的 addAll 方法添加 precache 緩存
return cache.addAll(CACGE_LIST);
})
}
// 安裝
self.addEventListener('install', function (event) {
// 等待promise執(zhí)行完
event.waitUntil(
// 如果上一個serviceWorker不銷毀 需要手動skipWaiting()
preCache().then(skipWaiting)
);
});
在安裝成功后,便會觸發(fā)activate事件,在進(jìn)入這個生命周期后,我們一般會刪除掉之前已經(jīng)過期的版本(因為默認(rèn)情況下瀏覽器是不會自動刪除過期的版本的),并更新客戶端Service Worker(使用當(dāng)前處于激活狀態(tài)的Service Worker)。
// 刪除過期緩存
function clearCache() {
return caches.keys().then(keys => {
return Promise.all(keys.map(key => {
if (key !== CACHE_NAME) {
return caches.delete(key);
}
}))
})
}
// 激活 activate 事件中通常做一些過期資源釋放的工作
self.addEventListener('activate', function (e) {
e.waitUntil(
Promise.all([
clearCache(),
self.clients.claim()
])
);
});
在這里還有一個問題就是sw.js文件有可能會被瀏覽器緩存,所以我們一般需要設(shè)置sw.js不緩存或者較短的緩存時間
更多詳細(xì)參考 如何優(yōu)雅的為 PWA 注冊 Service Worker
Service Worker 攔截請求
之前說過,Service Worker 是可以攔截請求的,那么一定就會存在一個攔截請求的事件fetch。我們需要在sw.js去監(jiān)聽這個事件。
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(function (response) {
// 如果 Service Worker 有自己的返回,就直接返回,減少一次 http 請求
if (response) {
console.log('cache 緩存', event.request.url, response);
return response;
} else {
if (navigator.online) {
return fetch(event.request).then(function(response) {
console.log('network', event.request.url, response);
// 由于響應(yīng)是一個JavaScript或者HTML,會認(rèn)為這個響應(yīng)為一個流,而流是只能被消費(fèi)一次的,所以只能被讀一次
// 第二次就會報錯 參考文章https://jakearchibald.com/2014/reading-responses/
cache.put(event.request, response.clone());
return response;
}).catch(function(error) {
console.error('請求失敗', error);
throw error;
});
} else {
// 斷網(wǎng)處理
offlineRequest(fetchRequest);
}
}
});
})
);
});
這里我們在fetch事件中監(jiān)聽請求事件,我們通過cache.match來進(jìn)行請求的比較,如果存再這個請求的響應(yīng)我們就直接返回緩存結(jié)果,否則就去請求。在這里我們通過cache.add來添加新的緩存,他實際上內(nèi)部是包含了fetch請求過程的(注意:Cache.put, Cache.add和Cache.addAll只能在GET請求下使用)。在match的時候,需要請求的url和header都一致才是相同的資源,可以設(shè)定第二個參數(shù)ignoreVary:true。caches.match(event.request, {ignoreVary: true})
表示只要請求url相同就認(rèn)為是同一個資源。另外需要提到一點(diǎn),F(xiàn)etch 請求默認(rèn)是不附帶 Cookies 等信息的,在請求靜態(tài)資源上這沒有問題,而且節(jié)省了網(wǎng)絡(luò)請求大小。但對于動態(tài)頁面,則可能會因為請求缺失 Cookies 而存在問題。此時可以給 Fetch 請求設(shè)置第二個參數(shù)。示例:fetch(fetchRequest, { credentials: 'include' } );
Cache API
Cache API 不僅在Service Worker中可以使用,在主頁面中也可以使用。我們通過 caches.open(cacheName)來打開一個緩存空間,在,默認(rèn)情況下,如果我們不手動去清除這個緩存空間,這個緩存會一直存在,不會過期。在使用Cache API之前,我們都需要通過caches.open先去打開這個緩存空間,然后在使用相應(yīng)的Cache方法。這里有幾個注意點(diǎn):
- Cache.put, Cache.add和Cache.addAll只能在GET請求下使用
- 自Chrome 46版本起,Cache API只保存安全來源的請求,即那些通過HTTPS服務(wù)的請求。
- Cache API不支持HTTP緩存頭
在使用cache.add和cache.addAll的時候,是先根據(jù)url獲取到相應(yīng)的response,然后再添加到緩存中。過程類似于調(diào)用 fetch(), 然后使用 Cache.put() 將response添加到cache中
Fetch API
Fetch API不僅可以在主線程中進(jìn)行使用,也可以在Service Worker中進(jìn)行使用。fetch 和 XMLHttpRequest有兩種方式不同:
當(dāng)接收到一個代表錯誤的 HTTP 狀態(tài)碼時,從 fetch()返回的 Promise 不會被標(biāo)記為 reject, 即使該 HTTP 響應(yīng)的狀態(tài)碼是 404 或 500。相反,它會將 Promise 狀態(tài)標(biāo)記為 resolve (但是會將 resolve 的返回值的 ok 屬性設(shè)置為 false ),僅當(dāng)網(wǎng)絡(luò)故障時或請求被阻止時,才會標(biāo)記為 reject。
默認(rèn)情況下,fetch 不會從服務(wù)端發(fā)送或接收任何 cookies, 如果站點(diǎn)依賴于用戶 session,則會導(dǎo)致未經(jīng)認(rèn)證的請求(要發(fā)送 cookies,必須設(shè)置 credentials 選項)
// Example POST method implementation:
postData('http://example.com/answer', {answer: 42})
.then(data => console.log(data)) // JSON from `response.json()` call
.catch(error => console.error(error))
function postData(url, data) {
// Default options are marked with *
return fetch(url, {
body: JSON.stringify(data), // must match 'Content-Type' header
cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
credentials: 'same-origin', // include(始終攜帶), same-origin(同源攜帶cookie), omit(始終不攜帶)
headers: {
'user-agent': 'Mozilla/4.0 MDN Example',
'content-type': 'application/json'
},
method: 'POST', // *GET, POST, PUT, DELETE, etc.
mode: 'cors', // no-cors, cors, *same-origin
redirect: 'follow', // manual, *follow, error
referrer: 'no-referrer', // *client, no-referrer
})
.then(response => response.json()) // parses response to JSON
}
更多信息請查閱:使用 Fetch
Notification
Notification API 用來進(jìn)行瀏覽器通知,當(dāng)用戶允許時,瀏覽器就可以彈出通知。這個API在主頁面和Service Worker中都可以使用,MDN文檔
- 在主頁面中使用
// 先檢查瀏覽器是否支持
if (!("Notification" in window)) {
alert("This browser does not support desktop notification");
}
// 檢查用戶是否同意接受通知
else if (Notification.permission === "granted") {
// If it's okay let's create a notification
new Notification(title, {
body: desc,
icon: '/icon.png',
requireInteraction: true
});
}
// 否則我們需要向用戶獲取權(quán)限
else if (Notification.permission !== 'denied') {
Notification.requestPermission(function (permission) {
// 如果用戶同意,就可以向他們發(fā)送通知
if (permission === "granted") {
new Notification(title, {
body: desc,
icon: '/icon.png',
requireInteraction: true
});
} else {
console.warn('用戶拒絕通知');
}
});
}
- 在Service Worker中使用
// 發(fā)送 Notification 通知
function sendNotify(title, options={}, event) {
if (Notification.permission !== 'granted') {
console.log('Not granted Notification permission.');
// 通過post一個message信號量,來在主頁面中詢問用戶獲取頁面通知權(quán)限
postMessage({
type: 'applyNotify'
})
} else {
// 在Service Worker 中 觸發(fā)一條通知
self.registration.showNotification(title || 'Hi:', Object.assign({
body: '這是一個通知示例',
icon: '/icon.png',
requireInteraction: true
}, options));
}
}
我們可以看見當(dāng)我們在Service Worker中進(jìn)行消息提示時,用戶可能關(guān)閉了消息提示的功能,所以我們首先要再次詢問用戶是否開啟消息提示的功能,但是在Service Worker中是不能夠直接詢問用戶的,我們必須要在主頁面中去詢問,這個時候我們可以通過postMessage去發(fā)送一個信號量,根據(jù)這個信號量的類型,來做響應(yīng)的處理(例如:詢問消息提示的權(quán)限,DOM操作等等)
function postMessage(data) {
self.clients.matchAll().then(clientList => {
clientList.forEach(client => {
// 當(dāng)前打開的標(biāo)簽頁發(fā)送消息
if (client.visibilityState === 'visible') {
client.postMessage(data);
}
})
})
}
在這里我們只向打開的標(biāo)簽頁發(fā)送該信號量,避免重復(fù)詢問
message 事件
由于Service Worker是一個單獨(dú)的瀏覽器線程,與JavaScript主線程互不干擾,但是我們還是可以通過postMessage實現(xiàn)通信,而且可以通過post特定的消息,從而讓主線程去進(jìn)行相應(yīng)的DOM操作,實現(xiàn)間接操作DOM的方式。
-
頁面發(fā)送消息給Service Worker
在頁面上通過 navigator.serviceWorker.controller 獲得 ServiceWorker 的句柄。但只有 ServiceWorker 注冊成功后該句柄才會存在。
function sendMsg(msg) {
const controller = navigator.serviceWorker.controller;
if (!controller) {
return;
}
controller.postMessage(msg, []);
}
// 在 serviceWorker 注冊成功后,頁面上即可通過 navigator.serviceWorker.controller 發(fā)送消息給它
navigator.serviceWorker
.register('/test/sw.js', {scope: '/test/'})
.then(registration => console.log('ServiceWorker 注冊成功!作用域為: ', registration.scope))
.then(() => sendMsg('hello sw!'))
.catch(err => console.log('ServiceWorker 注冊失敗: ', err));
在 ServiceWorker 內(nèi)部,可以通過監(jiān)聽 message 事件即可獲得消息:
self.addEventListener('message', function(ev) {
console.log(ev.data);
});
- Service Worker發(fā)送消息給頁面
// self.clients.matchAll方法獲取當(dāng)前serviceWorker實例所接管的所有標(biāo)簽頁,注意是當(dāng)前實例 已經(jīng)接管的
self.clients.matchAll().then(clientList => {
clientList.forEach(client => {
client.postMessage('Hi, I am send from Service worker!');
})
});
在主頁面中監(jiān)聽
navigator.serviceWorker.addEventListener('message', event => {
console.log(event.data);
});
manifest
3 manifest.json 作用
PWA 添加至桌面的功能實現(xiàn)依賴于 manifest.json,也就是說如果要實現(xiàn)添加至主屏幕這個功能,就必須要有這個文件
{
"short_name": "短名稱",
"name": "這是一個完整名稱",
"icons": [
{
"src": "icon.png",
"type": "image/png",
"sizes": "144x144"
}
],
"start_url": "index.html"
}
<link rel="manifest" href="path-to-manifest/manifest.json">
name —— 網(wǎng)頁顯示給用戶的完整名稱
short_name —— 當(dāng)空間不足以顯示全名時的網(wǎng)站縮寫名稱
description —— 關(guān)于網(wǎng)站的詳細(xì)描述
start_url —— 網(wǎng)頁的初始 相對 URL(比如 /)
scope —— 導(dǎo)航范圍。比如,/app/的scope就限制 app 在這個文件夾里。
background-color —— 啟動屏和瀏覽器的背景顏色
theme_color —— 網(wǎng)站的主題顏色,一般都與背景顏色相同,它可以影響網(wǎng)站的顯示
orientation —— 首選的顯示方向:any, natural, landscape, landscape-primary, landscape-secondary, portrait, portrait-primary, 和 portrait-secondary。
display —— 首選的顯示方式:fullscreen, standalone(看起來像是native app),minimal-ui(有簡化的瀏覽器控制選項) 和 browser(常規(guī)的瀏覽器 tab)
icons —— 定義了 src URL, sizes和type的圖片對象數(shù)組。
相關(guān)問題
- 對于不同的資源,我們可能有不同的緩存策略,怎么方便的去實現(xiàn)這些復(fù)雜的場景
使用workbox,如果使用webpack進(jìn)行項目打包,我們可以使用workbox-webpack-plugin插件
- 為什么不適用其他的本地緩存方案
Web Storage(例如 LocalStorage 和 SessionStorage)是同步的,不支持網(wǎng)頁工作線程,并對大小和類型(僅限字符串)進(jìn)行限制。 Cookie 具有自身的用途,但它們是同步的,缺少網(wǎng)頁工作線程支持,同時對大小進(jìn)行限制。WebSQL 不具有廣泛的瀏覽器支持,因此不建議使用它。File System API 在 Chrome 以外的任意瀏覽器上都不受支持。目前正在 File and Directory Entries API 和 File API 規(guī)范中改進(jìn) File API,但該 API 還不夠成熟也未完全標(biāo)準(zhǔn)化,因此無法被廣泛采用。
同步的問題 就是負(fù)擔(dān)大,如果有大量請求緩存在本地緩存中,如果是同步,可能負(fù)擔(dān)重
- 在將相應(yīng)存在cache中并返回給瀏覽器報錯
resulted in a network error response: a Response whose "body" is locked cannot be used to respond to a request
這是因為在使用put的時候,是流的一個pipe操作,流是只能被消費(fèi)一次的。我們可以clone這個response或者reques參考文章
- 在經(jīng)過webpack打包后,所有的靜態(tài)資源都會帶有hash值,怎么辦
使用某些webpack插件,例如offline-plugin或者webpack-workbox-plugin
代碼示例
參考資料
最后(歡迎大家關(guān)注我)
博客GitHub地址(歡迎star)