PWA 替身術:ServiceWorker - caches

ServiceWorker

PWA 偽裝術:manifest.json 我們透過 manifest.json 來讓 Web 可以在桌面上產生捷徑,並在執行時隱藏了不需要的網址列,讓外觀上跟一般 App 已無太大差異,接下來我們要解決 Web App 的另一大難題-離線機制,雖然說現在的系統幾乎都是透過網路與後端資料做即時交換,可說如果沒有網路的話過半數的 App 大概都會失去它的功用,但是與網頁不同的是在沒有網路狀態下仍然有畫面,而網頁則會顯示瀏覽器預設的警示畫面,相較之下對於一般使用者來說較不友善,而起好一點的 App 都會將網路下載下來的資訊儲存在本機端,當離線時雖然無法再與後端做資料交換,但是仍然可以提供之前獲取的資料,雖然 Web 也提供了離線儲存機制,但是扣除安全性不說,開發上就比較困難,尤其是使用者(或是瀏覽器擴充功能)還可以禁止相關功能。

ServiceWorker 是可以運行在瀏覽器後台的一種腳本,接下來我們要練習的是如何透過 ServiceWorker 的快取機制來解決網路離線時 Web App 會遇到的問題, ServiceWorker 到底在做什麼事?筆者可以說的就是 簾窺壁聽、偷天換日

運作流程

因為 ServiceWorker 可以監控前端的任何存取,所以被要求必須在 HTTPS 下運行,唯一例外是本機端,為了方便開發,localhost 也會被瀏覽器視為安全的傳輸,接下來我們開始逐步來實作 ServiceWorker。

我們接續 PWA 偽裝術:manifest.json 的程式來當範例。
first-app_2017-09-25.zip

若要下載請記得先透過指令 npm install 來重新安裝 package。
這邊我們透過 ng build --prod -oh 指令來編譯,讓輸出檔名不會包含雜湊值。
-oh:--output-hashing 縮寫。

參考文件:
MDN - 使用 Service Workers
Google Developers - Service Workers: an Introduction

註冊

首先我們在專案目錄下建立 sw.js 當作 ServiceWorker 腳本,並建立一個 register_sw.js 負責註冊這個腳本,語法如下:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('./sw.js')
    .then(reg => {
      // registration worked
      console.log('[Service Worker] Registration succeeded. Scope is ' + reg.scope);
    }).catch(error => {
      // registration failed
      console.log('[Service Worker] Registration failed with ' + error);
    });
}

我們可以從 Is Serviceworker Ready? 網站查看各家瀏覽器的支援度。

接著在 首頁(index.html) 內加入 register_sw.js

<!doctype html>
<html lang="zh">
...
<body oncontextmenu="return false">
  <app-root></app-root>
  <script type="text/javascript" src="register_sw.js"></script>
</body>

</html>

我們也可以省略 register_sw.js 檔,直接將註冊語法寫在網頁內,不過不要將 ServiceWorker 腳本-sw.js(目前還是空白) 也移到網頁內,因為瀏覽器會在後台以獨立的執行緒(Thread)來運行 sw.js,這樣意味著 ServiceWorker 無法干預前端網頁內容

透過 Chrome 擴充功能-Web Server for Chrome 來執行,可以看到雖然 sw.js 是空白的,但是 ServiceWorker 已經在運行了。

manifest.json 一樣我們將 register_sw.jssw.js 加到 .angular-cli.jsonassets,這樣建置時才會一併複製。

加入事件

開啟 sw.js 並加入3個事件:install(安裝)、activate(啟動)、fetch(存取),並透過 console.log 來確認事件是否觸發。

self.addEventListener('install', event => {
  console.log('[ServiceWorker] Install');
});

self.addEventListener('activate', event => {
  event.waitUntil(clients.claim());
});

self.addEventListener('fetch', event => {
  console.log('[ServiceWorker] fetch', event.request);
});

直接在原本的瀏覽器視窗重新整理,可以發現瀏覽器很聰明的發現 ServiceWorker 腳本(sw.js) 有變更過,因此多個一個 #251 的流水號,但是仔細觀看可以發現它的狀態是在等待被啟動,目前啟動的版本仍然是之前的版本,這是因為
當瀏覽器前端有運行這一支 Web 應用程式時,瀏覽器預設不會終止目前的腳本,腳本更新會等到不再需要 ServiceWorker 時(例如關閉目前的瀏覽器視窗)才會更新為新的腳本。

關閉目前瀏覽器視窗再用新的視窗開啟網頁,可以發現目前啟動 ServiceWork 其流水號為 #251,切換到 Console 視窗也可看到相關事件被觸發了。

如果希望異動過的程式能夠立即更新,可以在 install 事件內透過 skipWaiting 方法來立即啟用。

參考資料:MDN - ServiceWorkerGlobalScope.skipWaiting()


由上圖也可以了解 ServiceWork 的處理順序為
install(安裝) => activate(啟動) => fetch(存取)
installactivate 只會觸發一次,fetch 則是只要前端對後台發出 request 就會觸發。

靜態快取

開啟腳本(src\sw.js),先建立檔案快取清單-filesToCache,並將要快取的檔案加入期內,接著在 install 事件內將快取清單加入至快取內。
接著在 fetch 事件內去透過 respondWith 方法來阻止瀏覽器使用預設存取模式,並比對快取是否有該 request 請求的資料,若有則直接從快取提取,否則就使用預設模式存取, 相關代碼如下:

const cacheVersion = 'v1';
const filesToCache = [
  '0.chunk.js',
  'favicon.ico',
  'index.html',
  'inline.bundle.js',
  'main.bundle.js',
  'polyfills.bundle.js',
  'register_sw.js',
  'styles.bundle.css',
  'vendor.bundle.js',
  'assets/images/android_048.png',
  'assets/images/android_057.png',
  'assets/images/android_072.png',
  'assets/images/android_076.png',
  'assets/images/android_096.png',
  'assets/images/android_114.png',
  'assets/images/android_120.png',
  'assets/images/android_144.png',
  'assets/images/android_152.png',
  'assets/images/android_167.png',
  'assets/images/android_180.png',
  'assets/images/android_192.png',
  'assets/images/android_512.png'
];

self.addEventListener('install', event => {
  console.log('[ServiceWorker] Install');
  event.waitUntil(
    caches.open(cacheVersion)
    .then(cache => {
      console.log('[ServiceWorker] Caching app shell');
      return cache.addAll(filesToCache);
    })
  );
});

self.addEventListener('activate', event => {
  console.log('[ServiceWorker] Activate');
});

self.addEventListener('fetch', event => {
  console.log('[ServiceWorker] fetch', event.request);
  event.respondWith(
    caches.match(event.request)
    .then(response => response || fetch(event.request))
  );
});

開啟瀏覽器 ServerWork 的離線(Offline)選項或者直接停用 Web Server,重新整理後可以發現網頁依然存在,基本的離線機制已經完成了。

快取版本

sw.js 內的最上方我們宣告了一個常數當作快取名稱
const cacheVersion = 'v1';
到目前為止我們只做到將資料加到快取內,但是我們可能會隨著功能的變更讓網站結構調整,當然快取清單也必然會配合修改,這時候就會發生快取可能會殘留舊有而不需要的資料,或者是快取累計的資料過於龐大,因此我們需要一個方式可以移除這些不必要的資料。

我們可以透過開發者工具來查看目前快取的資料。

在這邊我們以最簡單的方式來處理,當快取規則與以往不同時我們透過變更 cacheVersion 來產生全新的快取區域,並將就的快取直接刪除。
開啟腳本並在 activate 事件添加刪除快取的程式碼,並將快取版本(cacheVersion)改為 v2

const cacheVersion = 'v2';
const filesToCache = [
  ...
];
...
self.addEventListener('activate', event => {
  console.log('[ServiceWorker] Activate');
  event.waitUntil(
    caches.keys()
    .then(keyList => {
      return Promise.all(keyList.map(key => {
        if (key !== cacheVersion) {
          return caches.delete(key);
        }
      }));
    })
  );
});
...

重新執行,可以發現快取名稱已經變成 v2,而原本的 v1 已經被移除了。

API 快取

因為筆者目前沒有建立 WebAPI 環境,所以在此不做練習,但是我們可以參考 MDN 網站 使用 Service Workers 的範例,由範例程式碼可以看到它的作法與我們上面的方法類似,當快取內沒有相關資料時就改從網路去後台抓取,差別就在於它抓取資料後會先存入快取內,這樣下次就可以直接從快取提取資料。

目前的機制看起來與其說我們在建立快取,不如說更像在實作一個 Web App 專屬的代理伺服器(Proxy Server),最重要的是這種架構讓開發人員可以專注在連線模式的系統運作,完全不需要思考離線的問題
但是目前也衍生出一些問題:

  • 有些 API 雖然參數一樣,但是在不同時間會回傳不一樣的內容,例如:最新消息,系統公告…等等,這時候若都從快取抓取資料便會讓使用者有資料沒有更新的假象,因此我們需要另一個機制,在有網路時透過網路去後端抓取資料,當離線時才改從快取讀取資料。
  • 我們不只有查詢需求,還有對資料作新增、修改、刪除的問題,雖然可以藉由快取讓離線時仍然有畫面,但是對於資料異動又要如何處理?
  • 就像大部分人常用的 Line ,這種由後端伺服器主動推撥訊息至用戶端的模式又要如何在 Web 上達到?

這些議題後續有機會再做討論。

first-app_2017-09-26.zip