在上一篇《我是怎樣讓網站用上HTML5 Manifest》介紹了怎麼用Manifest作一個離線網頁應用,結果被廣大網友吐槽說這個東西已經被deprecated,移出web標準了,如今被Service Worker替代了,無論怎麼樣,Manifest的一些思想仍是能夠借用的。筆者又將網站升級到了Service Worker,若是是用Chrome等瀏覽器就用Service Worker作離線緩存,若是是Safari瀏覽器就仍是用Manifest,讀者能夠打開這個網站www.rrfed.com感覺一下,斷網也是能正常打開。javascript
Service Worker是谷歌發起的實現PWA(Progressive Web App)的一個關鍵角色,PWA是爲了解決傳統Web APP的缺點:css
(1)沒有桌面入口html
(2)沒法離線使用前端
(3)沒有Push推送java
那Service Worker的具體表現是怎麼樣的呢?以下圖所示:nginx
Service Worker是在後臺啓動的一條服務Worker線程,上圖我開了兩個標籤頁,因此顯示了兩個Client,可是無論開多少個頁面都只有一個Worker在負責管理。這個Worker的工做是把一些資源緩存起來,而後攔截頁面的請求,先看下緩存庫裏有沒有,若是有的話就從緩存裏取,響應200,反之沒有的話就走正常的請求。具體來講,Service Worker結合Web App Manifest能完成如下工做(這也是PWA的檢測標準):web
包括可以離線使用、斷網時返回200、能提示用戶把網站添加一個圖標到桌面上等。json
Service Worker目前只有Chrome/Firfox/Opera支持:windows
Safari和Edge也在準備支持Service Worker,因爲Service Worker是谷歌主導的一項標準,對於生態比較封閉的Safari來講也是迫於形勢開始準備支持了,在Safari TP版本,能夠看到:api
在實驗功能(Experimental Features)裏已經有Service Worker的菜單項了,只是即便打開也是不能用,會提示你尚未實現:
但無論如何,至少說明Safari已經準備支持Service Worker了。另外還能夠看到在今年2017年9月發佈的Safari 11.0.1版本已經支持WebRTC了,因此Safari仍是一個上進的孩子。
Edge也準備支持,因此Service Worker的前景十分光明。
Service Worker的使用套路是先註冊一個Worker,而後後臺就會啓動一條線程,能夠在這條線程啓動的時候去加載一些資源緩存起來,而後監聽fetch事件,在這個事件裏攔截頁面的請求,先看下緩存裏有沒有,若是有直接返回,不然正常加載。或者是一開始不緩存,每一個資源請求後再拷貝一份緩存起來,而後下一次請求的時候緩存裏就有了。
Service Worker對象是在window.navigator裏面,以下代碼:
window.addEventListener("load", function() {
console.log("Will the service worker register?");
navigator.serviceWorker.register('/sw-3.js')
.then(function(reg){
console.log("Yes, it did.");
}).catch(function(err) {
console.log("No it didn't. This happened: ", err)
});
});複製代碼
在頁面load完以後註冊,註冊的時候傳一個js文件給它,這個js文件就是Service Worker的運行環境,若是不能成功註冊的話就會拋異常,如Safari TP雖然有這個對象,可是會拋異常沒法使用,就能夠在catch裏面處理。這裏有個問題是爲何須要在load事件啓動呢?由於你要額外啓動一個線程,啓動以後你可能還會讓它去加載資源,這些都是須要佔用CPU和帶寬的,咱們應該保證頁面能正常加載完,而後再啓動咱們的後臺線程,不能與正常的頁面加載產生競爭,這個在低端移動設備意義比較大。
還有一點須要注意的是Service Worker和Cookie同樣是有Path路徑的概念的,若是你設定一個cookie假設叫time的path=/page/A,在/page/B這個頁面是不可以獲取到這個cookie的,若是設置cookie的path爲根目錄/,則全部頁面都能獲取到。相似地,若是註冊的時候使用的js路徑爲/page/sw.js,那麼這個Service Worker只能管理/page路徑下的頁面和資源,而不可以處理/api路徑下的,因此通常把Service Worker註冊到頂級目錄,如上面代碼的"/sw-3.js",這樣這個Service Worker就能接管頁面的全部資源了。
註冊完以後,Service Worker就會進行安裝,這個時候會觸發install事件,在install事件裏面能夠緩存一些資源,以下sw-3.js:
const CACHE_NAME = "fed-cache";
this.addEventListener("install", function(event) {
this.skipWaiting();
console.log("install service worker");
// 建立和打開一個緩存庫
caches.open(CACHE_NAME);
// 首頁
let cacheResources = ["https://www.rrfed.com/?launcher=true"];
event.waitUntil(
// 請求資源並添加到緩存裏面去
caches.open(CACHE_NAME).then(cache => {
cache.addAll(cacheResources);
})
);
});複製代碼
經過上面的操做,建立和添加了一個緩存庫叫fed-cache,以下Chrome控制檯所示:
Service Worker的API基本上都是返回Promise對象避免堵塞,因此要用Promise的寫法。上面在安裝Service Worker的時候就把首頁的請求給緩存起來了。在Service Worker的運行環境裏面它有一個caches的全局對象,這個是緩存的入口,還有一個經常使用的clients的全局對象,一個client對應一個標籤頁。
在Service Worker裏面可使用fetch等API,它和DOM是隔離的,沒有windows/document對象,沒法直接操做DOM,沒法直接和頁面交互,在Service Worker裏面沒法得知當前頁面打開了、當前頁面的url是什麼,由於一個Service Worker管理當前打開的幾個標籤頁,能夠經過clients知道全部頁面的url。還有能夠經過postMessage的方式和主頁面互相傳遞消息和數據,進而作些控制。
install完以後,就會觸發Service Worker的active事件:
this.addEventListener("active", function(event) {
console.log("service worker is active");
});複製代碼
Service Worker激活以後就可以監聽fetch事件了,咱們但願每獲取一個資源就把它緩存起來,就不用像上一篇提到的Manifest須要先生成一個列表。
你可能會問,當我刷新頁面的時候不是又從新註冊安裝和激活了一個Service Worker?雖然又調了一次註冊,但並不會從新註冊,它發現"sw-3.js"這個已經註冊了,就不會再註冊了,進而不會觸發install和active事件,由於當前Service Worker已是active狀態了。當須要更新Service Worker時,如變成"sw-4.js",或者改變sw-3.js的文本內容,就會從新註冊,新的Service Worker會先install而後進入waiting狀態,等到重啓瀏覽器時,老的Service Worker就會被替換掉,新的Service Worker進入active狀態,若是不想等到從新啓動瀏覽器能夠像上面同樣在install裏面調skipWaiting:
this.skipWaiting();複製代碼
以下代碼,監聽fetch事件作些處理:
this.addEventListener("fetch", function(event) {
event.respondWith(
caches.match(event.request).then(response => {
// cache hit
if (response) {
return response;
}
return util.fetchPut(event.request.clone());
})
);
});複製代碼
先調caches.match看一下緩存裏面是否有了,若是有直接返回緩存裏的response,不然的話正常請求資源並把它放到cache裏面。放在緩存裏資源的key值是Request對象,在match的時候,須要請求的url和header都一致纔是相同的資源,能夠設定第二個參數ignoreVary:
caches.match(event.request, {ignoreVary: true})複製代碼
表示只要請求url相同就認爲是同一個資源。
上面代碼的util.fetchPut是這樣實現的:
let util = {
fetchPut: function (request, callback) {
return fetch(request).then(response => {
// 跨域的資源直接return
if (!response || response.status !== 200 || response.type !== "basic") {
return response;
}
util.putCache(request, response.clone());
typeof callback === "function" && callback();
return response;
});
},
putCache: function (request, resource) {
// 後臺不要緩存,preview連接也不要緩存
if (request.method === "GET" && request.url.indexOf("wp-admin") < 0
&& request.url.indexOf("preview_id") < 0) {
caches.open(CACHE_NAME).then(cache => {
cache.put(request, resource);
});
}
}
};複製代碼
須要注意的是跨域的資源不能緩存,response.status會返回0,若是跨域的資源支持CORS,那麼能夠把request的mod改爲cors。若是請求失敗了,如404或者是超時之類的,那麼也直接返回response讓主頁面處理,不然的話說明加載成功,把這個response克隆一個放到cache裏面,而後再返回response給主頁面線程。注意能放緩存裏的資源通常只能是GET,經過POST獲取的是不能緩存的,因此要作個判斷(固然你也能夠手動把request對象的method改爲get),還有把一些我的不但願緩存的資源也作個判斷。
這樣一旦用戶打開過一次頁面,Service Worker就安裝好了,他刷新頁面或者打開第二個頁面的時候就可以把請求的資源一一作緩存,包括圖片、CSS、JS等,只要緩存裏有了無論用戶在線或者離線都可以正常訪問。這樣咱們天然會有一個問題,這個緩存空間到底有多大?上一篇咱們提到Manifest也算是本地存儲,PC端的Chrome是5Mb,其實這個說法在新版本的Chrome已經不許確了,在Chrome 61版本能夠看到本地存儲的空間和使用狀況:
其中Cache Storage是指Service Worker和Manifest佔用的空間大小和,上圖能夠看到總的空間大小是20GB,幾乎是unlimited,因此基本上不用擔憂緩存會不夠用。
上面第(3)步把圖片、js、css緩存起來了,可是若是把頁面html也緩存了,例如把首頁緩存了,就會有一個尷尬的問題——Service Worker是在頁面註冊的,可是如今獲取頁面的時候是從緩存取的,每次都是同樣的,因此就致使沒法更新Service Worker,如變成sw-5.js,可是PWA又要求咱們能緩存頁面html。那怎麼辦呢?谷歌的開發者文檔它只是提到會存在這個問題,但並無說明怎麼解決這個問題。這個的問題的解決就要求咱們要有一個機制能知道html更新了,從而把緩存裏的html給替換掉。
Manifest更新緩存的機制是去看Manifest的文本內容有沒有發生變化,若是發生變化了,則會去更新緩存,Service Worker也是根據sw.js的文本內容有沒有發生變化,咱們能夠借鑑這個思想,若是請求的是html並從緩存裏取出來後,再發個請求獲取一個文件看html更新時間是否發生變化,若是發生變化了則說明發生更改了,進而把緩存給刪了。因此能夠在服務端經過控制這個文件從而去更新客戶端的緩存。以下代碼:
this.addEventListener("fetch", function(event) {
event.respondWith(
caches.match(event.request).then(response => {
// cache hit
if (response) {
//若是取的是html,則看發個請求看html是否更新了
if (response.headers.get("Content-Type").indexOf("text/html") >= 0) {
console.log("update html");
let url = new URL(event.request.url);
util.updateHtmlPage(url, event.request.clone(), event.clientId);
}
return response;
}
return util.fetchPut(event.request.clone());
})
);
});複製代碼
經過響應頭header的content-type是否爲text/html,若是是的話就去發個請求獲取一個文件,根據這個文件的內容決定是否須要刪除緩存,這個更新的函數util.updateHtmlPage是這麼實現的:
let pageUpdateTime = {
};
let util = {
updateHtmlPage: function (url, htmlRequest) {
let pageName = util.getPageName(url);
let jsonRequest = new Request("/html/service-worker/cache-json/" + pageName + ".sw.json");
fetch(jsonRequest).then(response => {
response.json().then(content => {
if (pageUpdateTime[pageName] !== content.updateTime) {
console.log("update page html");
// 若是有更新則從新獲取html
util.fetchPut(htmlRequest);
pageUpdateTime[pageName] = content.updateTime;
}
});
});
},
delCache: function (url) {
caches.open(CACHE_NAME).then(cache => {
console.log("delete cache " + url);
cache.delete(url, {ignoreVary: true});
});
}
};複製代碼
代碼先去獲取一個json文件,一個頁面會對應一個json文件,這個json的內容是這樣的:
{"updateTime":"10/2/2017, 3:23:57 PM","resources": {img: [], css: []}}複製代碼
裏面主要有一個updateTime的字段,若是本地內存沒有這個頁面的updateTime的數據或者是和最新updateTime不同,則從新去獲取 html,而後放到緩存裏。接着須要通知頁面線程數據發生變化了,你刷新下頁面吧。這樣就不用等用戶刷新頁面才能生效了。因此當刷新完頁面後用postMessage通知頁面:
let util = {
postMessage: async function (msg) {
const allClients = await clients.matchAll();
allClients.forEach(client => client.postMessage(msg));
}
};
util.fetchPut(htmlRequest, false, function() {
util.postMessage({type: 1, desc: "html found updated", url: url.href});
});複製代碼
並規定type: 1就表示這是一個更新html的消息,而後在頁面監聽message事件:
if("serviceWorker" in navigator) {
navigator.serviceWorker.addEventListener("message", function(event) {
let msg = event.data;
if (msg.type === 1 && window.location.href === msg.url) {
console.log("recv from service worker", event.data);
window.location.reload();
}
});
}複製代碼
而後當咱們須要更新html的時候就更新json文件,這樣用戶就能看到最新的頁面了。或者是當用戶從新啓動瀏覽器的時候會致使Service Worker的運行內存都被清空了,即存儲頁面更新時間的變量被清空了,這個時候也會從新請求頁面。
須要注意的是,要把這個json文件的http cache時間設置成0,這樣瀏覽器就不會緩存了,以下nginx的配置:
location ~* .sw.json$ {
expires 0;
}複製代碼
由於這個文件是須要實時獲取的,不能被緩存,firefox默認會緩存,Chrome不會,加上http緩存時間爲0,firefox也不會緩存了。
還有一種更新是用戶更新的,例如用戶發表了評論,須要在頁面通知service worker把html緩存刪了從新獲取,這是一個反過來的消息通知:
if ("serviceWorker" in navigator) {
document.querySelector(".comment-form").addEventListener("submit", function() {
navigator.serviceWorker.controller.postMessage({
type: 1,
desc: "remove html cache",
url: window.location.href}
);
}
});
}複製代碼
Service Worker也監聽message事件:
const messageProcess = {
// 刪除html index
1: function (url) {
util.delCache(url);
}
};
let util = {
delCache: function (url) {
caches.open(CACHE_NAME).then(cache => {
console.log("delete cache " + url);
cache.delete(url, {ignoreVary: true});
});
}
};
this.addEventListener("message", function(event) {
let msg = event.data;
console.log(msg);
if (typeof messageProcess[msg.type] === "function") {
messageProcess[msg.type](msg.url);
}
});
複製代碼
根據不一樣的消息類型調不一樣的回調函數,若是是1的話就是刪除cache。用戶發表完評論後會觸發刷新頁面,刷新的時候緩存已經被刪了就會從新去請求了。
這樣就解決了實時更新的問題。
要緩存可使用三種手段,使用Http Cache設置緩存時間,也能夠用Manifest的Application Cache,還能夠用Service Worker緩存,若是三者都用上了會怎麼樣呢?
會以Service Worker爲優先,由於Service Worker把請求攔截了,它最早作處理,若是它緩存庫裏有的話直接返回,沒有的話正常請求,就至關於沒有Service Worker了,這個時候就到了Manifest層,Manifest緩存裏若是有的話就取這個緩存,若是沒有的話就至關於沒有Manifest了,因而就會從Http緩存裏取了,若是Http緩存裏也沒有就會發請求去獲取,服務端根據Http的etag或者Modified Time可能會返回304 Not Modified,不然正常返回200和數據內容。這就是整一個獲取的過程。
因此若是既用了Manifest又用Service Worker的話應該會致使同一個資源存了兩次。可是可讓支持Service Worker的瀏覽器使用Service Worker,而不支持的使用Manifest.
注意這裏說的是另一個Manifest,這個Manifest是一個json文件,用來放網站icon名稱等信息以便在桌面添加一個圖標,以及製造一種打開這個網頁就像打開App同樣的效果。上面一直說的Manifest是被廢除的Application Cache的Manifest。
這個Maifest.json文件能夠這麼寫:
{
"short_name": "人人FED",
"name": "人人網FED,專一於前端技術",
"icons": [
{
"src": "/html/app-manifest/logo_48.png",
"type": "image/png",
"sizes": "48x48"
},
{
"src": "/html/app-manifest/logo_96.png",
"type": "image/png",
"sizes": "96x96"
},
{
"src": "/html/app-manifest/logo_192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/html/app-manifest/logo_512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/?launcher=true",
"display": "standalone",
"background_color": "#287fc5",
"theme_color": "#fff"
}複製代碼
icon須要準備多種規格,最大須要512px * 512px的,這樣Chrome會自動去選取合適的圖片。若是把display改爲standalone,從生成的圖標打開就會像打開一個App同樣,沒有瀏覽器地址欄那些東西了。start_url指定打開以後的入口連接。
而後添加一個link標籤指向這個manifest文件:
<link rel="manifest" href="/html/app-manifest/manifest.json">複製代碼
這樣結合Service Worker緩存:把start_url指向的頁面用Service Worker緩存起來,這樣當用戶用Chrome瀏覽器打開這個網頁的時候,Chrome就會在底部彈一個提示,詢問用戶是否把這個網頁添加到桌面,若是點「添加」就會生成一個桌面圖標,從這個圖標點進去就像打開一個App同樣。感覺以下:
比較尷尬的是Manifest目前只有Chrome支持,而且只能在安卓系統上使用,IOS的瀏覽器沒法添加一個桌面圖標,由於IOS沒有開放這種API,可是自家的Safari卻又是能夠的。
綜上,本文介紹了怎麼用Service Worker結合Manifest作一個PWA離線Web APP,主要是用Service Worker控制緩存,因爲是寫JS,比較靈活,還能夠與頁面進行通訊,另外經過請求頁面的更新時間來判斷是否須要更新html緩存。Service Worker的兼容性不是特別好,可是前景比較光明,瀏覽器都在準備支持。現階段能夠結合offline cache的Manifest作離線應用。
相關閱讀: