使用Service Worker做一個PWA離線網頁應用

人人網FED發表於2017-10-04

在上一篇《我是怎樣讓網站用上HTML5 Manifest》介紹了怎麼用Manifest做一個離線網頁應用,結果被廣大網友吐槽說這個東西已經被deprecated,移出web標準了,現在被Service Worker替代了,不管怎麼樣,Manifest的一些思想還是可以借用的。筆者又將網站升級到了Service Worker,如果是用Chrome等瀏覽器就用Service Worker做離線快取,如果是Safari瀏覽器就還是用Manifest,讀者可以開啟這個網站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做離線應用。


相關閱讀:

  1. 為什麼要把網站升級到HTTPS
  2. 怎樣把網站升級到http/2
  3. 我是怎樣讓網站用上HTML5 Manifest


相關文章