title: PWA 替身術:ServiceWorker - caches
date: 2017-09-26 08:00
categories: Training
keywords:
在 PWA 偽裝術:manifest.json 我們透過 manifest.json
來讓 Web 可以在桌面上產生捷徑,並在執行時隱藏了不需要的網址列,讓外觀上跟一般 App 已無太大差異,接下來我們要解決 Web App 的另一大難題-離線機制,雖然說現在的系統幾乎都是透過網路與後端資料做即時交換,可說如果沒有網路的話過半數的 App 大概都會失去它的功用,但是與網頁不同的是在沒有網路狀態下仍然有畫面,而網頁則會顯示瀏覽器預設的警示畫面,相較之下對於一般使用者來說較不友善,而起好一點的 App 都會將網路下載下來的資訊儲存在本機端,當離線時雖然無法再與後端做資料交換,但是仍然可以提供之前獲取的資料,雖然 Web 也提供了離線儲存機制,但是扣除安全性不說,開發上就比較困難,尤其是使用者(或是瀏覽器擴充功能)還可以禁止相關功能。
ServiceWorker 是可以運行在瀏覽器後台的一種腳本,接下來我們要練習的是如何透過 ServiceWorker 的快取機制來解決網路離線時 Web App 會遇到的問題, ServiceWorker 到底在做什麼事?筆者可以說的就是 簾窺壁聽、偷天換日。
因為 ServiceWorker 可以監控前端的任何存取,所以被要求必須在 HTTPS 下運行,唯一例外是本機端,為了方便開發,localhost
也會被瀏覽器視為安全的傳輸,接下來我們開始逐步來實作 ServiceWorker。
我們接續 PWA 偽裝術:manifest.json 的程式來當範例。
{% img /images/download.png 36 %}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
負責註冊這個腳本,語法如下:
{% codeblock register_sw.js lang: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);
});
}
{% endcodeblock %}
我們可以從 Is Serviceworker Ready? 網站查看各家瀏覽器的支援度。
接著在 首頁(index.html
) 內加入 register_sw.js
。
{% codeblock index.html lang:html %}
{% endcodeblock %}
我們也可以省略
register_sw.js
檔,直接將註冊語法寫在網頁內,不過不要將 ServiceWorker 腳本-sw.js
(目前還是空白) 也移到網頁內,因為瀏覽器會在後台以獨立的執行緒(Thread)來運行sw.js
,這樣意味著 ServiceWorker 無法干預前端網頁內容。
透過 Chrome 擴充功能-Web Server for Chrome 來執行,可以看到雖然 sw.js
是空白的,但是 ServiceWorker 已經在運行了。
與
manifest.json
一樣我們將register_sw.js
與sw.js
加到.angular-cli.json
的assets
,這樣建置時才會一併複製。
開啟 sw.js
並加入3個事件:install
(安裝)、activate
(啟動)、fetch
(存取),並透過 console.log
來確認事件是否觸發。
{% codeblock sw.js lang:js %}
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);
});
{% endcodeblock %}
直接在原本的瀏覽器視窗重新整理,可以發現瀏覽器很聰明的發現 ServiceWorker 腳本(sw.js
) 有變更過,因此多個一個 #251 的流水號,但是仔細觀看可以發現它的狀態是在等待被啟動,目前啟動的版本仍然是之前的版本,這是因為
當瀏覽器前端有運行這一支 Web 應用程式時,瀏覽器預設不會終止目前的腳本,腳本更新會等到不再需要 ServiceWorker 時(例如關閉目前的瀏覽器視窗)才會更新為新的腳本。
關閉目前瀏覽器視窗再用新的視窗開啟網頁,可以發現目前啟動 ServiceWork 其流水號為 #251,切換到 Console 視窗也可看到相關事件被觸發了。
如果希望異動過的程式能夠立即更新,可以在
install
事件內透過skipWaiting
方法來立即啟用。
參考資料:MDN - ServiceWorkerGlobalScope.skipWaiting()
由上圖也可以了解 ServiceWork 的處理順序為
install
(安裝) =>activate
(啟動) =>fetch
(存取)install
、activate
只會觸發一次,fetch
則是只要前端對後台發出 request 就會觸發。
開啟腳本(src\sw.js
),先建立檔案快取清單-filesToCache
,並將要快取的檔案加入期內,接著在 install
事件內將快取清單加入至快取內。
接著在 fetch
事件內去透過 respondWith
方法來阻止瀏覽器使用預設存取模式,並比對快取是否有該 request 請求的資料,若有則直接從快取提取,否則就使用預設模式存取, 相關代碼如下:
{% codeblock sw.js lang:js %}
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))
);
});
{% endcodeblock %}
開啟瀏覽器 ServerWork 的離線(Offline)選項或者直接停用 Web Server,重新整理後可以發現網頁依然存在,基本的離線機制已經完成了。
在 sw.js
內的最上方我們宣告了一個常數當作快取名稱const cacheVersion = 'v1';
到目前為止我們只做到將資料加到快取內,但是我們可能會隨著功能的變更讓網站結構調整,當然快取清單也必然會配合修改,這時候就會發生快取可能會殘留舊有而不需要的資料,或者是快取累計的資料過於龐大,因此我們需要一個方式可以移除這些不必要的資料。
我們可以透過開發者工具來查看目前快取的資料。
在這邊我們以最簡單的方式來處理,當快取規則與以往不同時我們透過變更 cacheVersion
來產生全新的快取區域,並將就的快取直接刪除。
開啟腳本並在 activate
事件添加刪除快取的程式碼,並將快取版本(cacheVersion
)改為 v2
。
{% codeblock sw.js lang:js %}
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);
}
}));
})
);
});
…
{% endcodeblock %}
重新執行,可以發現快取名稱已經變成 v2
,而原本的 v1
已經被移除了。
因為筆者目前沒有建立 WebAPI 環境,所以在此不做練習,但是我們可以參考 MDN 網站 使用 Service Workers 的範例,由範例程式碼可以看到它的作法與我們上面的方法類似,當快取內沒有相關資料時就改從網路去後台抓取,差別就在於它抓取資料後會先存入快取內,這樣下次就可以直接從快取提取資料。
目前的機制看起來與其說我們在建立快取,不如說更像在實作一個 Web App 專屬的代理伺服器(Proxy Server),最重要的是這種架構讓開發人員可以專注在連線模式的系統運作,完全不需要思考離線的問題。
但是目前也衍生出一些問題:
有些 API 雖然參數一樣,但是在不同時間會回傳不一樣的內容,例如:最新消息,系統公告…等等,這時候若都從快取抓取資料便會讓使用者有資料沒有更新的假象,因此我們需要另一個機制,在有網路時透過網路去後端抓取資料,當離線時才改從快取讀取資料。
我們不只有查詢需求,還有對資料作新增、修改、刪除的問題,雖然可以藉由快取讓離線時仍然有畫面,但是對於資料異動又要如何處理?
就像大部分人常用的 Line ,這種由後端伺服器主動推撥訊息至用戶端的模式又要如何在 Web 上達到?
這些議題後續有機會再做討論。
{% img /images/download.png 36 %}first-app_2017-09-26.zip