使用Service Worker做一個PWA離線網頁應用
在上一篇《我是怎樣讓網站用上HTML5 Manifest》介紹了怎么用Manifest做一個離線網頁應用,結果被廣大網友吐槽說這個東西已經被deprecated,移出web標準了,現在被Service Worker替代了,不管怎么樣,Manifest的一些思想還是可以借用的。筆者又將網站升級到了Service Worker,如果是用Chrome等瀏覽器就用Service Worker做離線緩存,如果是Safari瀏覽器就還是用Manifest,讀者可以打開這個網站https://fed.renren.com感受一下,斷網也是能正常打開。
1. 什么是Service Worker
Service Worker是谷歌發起的實現PWA(Progressive Web App)的一個關鍵角色,PWA是為了解決傳統Web APP的缺點:
(1)沒有桌面入口
(2)無法離線使用
(3)沒有Push推送
那Service Worker的具體表現是怎么樣的呢?如下圖所示:
Service Worker是在后臺啟動的一條服務Worker線程,上圖我開了兩個標簽頁,所以顯示了兩個Client,但是不管開多少個頁面都只有一個Worker在負責管理。這個Worker的工作是把一些資源緩存起來,然后攔截頁面的請求,先看下緩存庫里有沒有,如果有的話就從緩存里取,響應200,反之沒有的話就走正常的請求。具體來說,Service Worker結合Web App Manifest能完成以下工作(這也是PWA的檢測標準):
包括能夠離線使用、斷網時返回200、能提示用戶把網站添加一個圖標到桌面上等。
2. Service Worker的支持情況
Service Worker目前只有Chrome/Firfox/Opera支持:
Safari和Edge也在準備支持Service Worker,由于Service Worker是谷歌主導的一項標準,對于生態比較封閉的Safari來說也是迫于形勢開始準備支持了,在Safari TP版本,可以看到:
在實驗功能(Experimental Features)里已經有Service Worker的菜單項了,只是即使打開也是不能用,會提示你還沒有實現:
但不管如何,至少說明Safari已經準備支持Service Worker了。另外還可以看到在今年2017年9月發布的Safari 11.0.1版本已經支持WebRTC了,所以Safari還是一個上進的孩子。
Edge也準備支持,所以Service Worker的前景十分光明。
3. 使用Service Worker
Service Worker的使用套路是先注冊一個Worker,然后后臺就會啟動一條線程,可以在這條線程啟動的時候去加載一些資源緩存起來,然后監聽fetch事件,在這個事件里攔截頁面的請求,先看下緩存里有沒有,如果有直接返回,否則正常加載。或者是一開始不緩存,每個資源請求后再拷貝一份緩存起來,然后下一次請求的時候緩存里就有了。
(1)注冊一個Service Worker
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就能接管頁面的所有資源了。
(2)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://fed.renren.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();
(3)fetch資源后cache起來
如下代碼,監聽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,所以基本上不用擔心緩存會不夠用。
(4)cache html
上面第(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。用戶發表完評論后會觸發刷新頁面,刷新的時候緩存已經被刪了就會重新去請求了。
這樣就解決了實時更新的問題。
4. Http/Manifest/Service Worker三種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.
5. 使用Web App 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做離線應用。
原文鏈接:https://fed.renren.com/2017/10/04/service-worker/
【本文是51CTO專欄作者“人人網FED”的原創稿件,轉載請通過51CTO聯系原作者獲取授權】