iT邦幫忙

2021 iThome 鐵人賽

DAY 18
3

如果你聽過 PWA,那麼對今天的主題ㄧ定不陌生,因為今天要講的 Service Worker 就是 PWA 的一個重要元件。不過 PWA 這個主題本身就已經足夠寫好幾本書了,所以今天並不會聚焦在 PWA 上,而是會專注於 Service Worker 的快取功能上,完全沒聽過 Service Workers 的讀者也不要擔心,在進入正題前會先對 Service Workers 做個簡單的介紹,那我們就直接進入快取之旅的第二站吧!

(當然如果懂 PWA 的基本概念的話會更好,如果完全不知道 PWA 的讀者建議可以先看看這篇文章。)

什麼是 Service Workers ?

Service Workers 嚴格來說屬於 Web Workers(系列文第 22 天時會介紹) 的一種,我們都知道開發時所撰寫的 JavaScript 是運行在 Main Thread(或稱 UI Thread)上的,Service Workers 則是運行在不同於 main thread 的 thread 上,因此執行可以不被畫面的渲染或運算 block 住,並且具有可以在瀏覽器關閉時繼續在背景執行的能力。

而 Service Workers 又跟一般的 Web Workers 不太ㄧ樣,它是一層在瀏覽器與 network 層級之間的 proxy,擁有攔截使用者發出請求的能力(透過監聽 fetch 事件),另外 Service Workers 也提供了快取的功能,可以在攔截使用者發出的請求後決定要不要回傳快取的內容。

在昨天有提到各種快取的優先級別,Service Workers 的 Cache 的優先級別是高於 HTTP Caching 的,如果排除掉由各家瀏覽器自己實作且沒有明確規範的 memory cache 的話,Service Workers Cache 就是前端開發者可控範圍內快取的第一道防線。

有了 Service Workers 的幫助,網頁應用可以做到許多以往做不到或是不容易實現的功能,例如:

  • 透過 Service Workers 的 Cache 實現離線瀏覽功能
  • 像 Native App 一樣的推播功能
  • Background Sync

雖然看似強大,前幾年也一直有 PWA 將大為流行甚至可以取代 Native App 的輿論與勢頭,不過礙於一些作業系統的限制,例如 Web App manifest、Launching screen、Installation prompt API、Push notification、Background Sync、In-App Browser 等 PWA 重要的功能在我寫這篇文章的當下 ios 都是沒有支援的,因此 PWA 也許沒有我們想的那麼順利美好。

不過這並不代表跟 PWA 相關的 features 就沒有學習與使用的價值了,今天的主角 Service Workers Cache 就是一個仍然值得我們投入的技術,學會善用它不僅有機會增加使用者體驗,同樣也有機會優化網頁的效能。

Service Workers 的生命週期

Service Workers(SW)擁有一個完全獨立於 Web App 的生命週期,Service Workers 有一個特性是開發者對它有很高的控制權,可以細粒度的去決定它的行為,今天主要要介紹的 Service Workers Cache 就是在 Service Worker 的 Lifecycle 中透過撰寫 JavaScript 的方式來達成,這稍後會再說明,這邊需要注意一點,雖然說 Service Worker 也是靠 JavaScript 來操作其行為,但它不同於一般我們寫的 code,在 SW 中沒有辦法操作 DOM,並且需要依賴 postMessage 來與頁面溝通。

在進入 SW 的生命週期前,需要先檢查當前瀏覽器有沒有支援 SW,如果有支援的話,還得先註冊寫好的 SW 檔案,例如以下的 JavaScript 判斷式:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker
    .register('./some path to sw/sw.js',  { scope: '/ironman' }) // 註冊 Service Worker
    .then(function(reg) {
      console.log('Registration succeeded. SW working scope is ' + reg.scope); 
    })
    .catch(function(error) {
      console.log('Registration failed with ' + error); // 註冊失敗
    });

要使用 SW 有一個限制是網站必須支援 HTTPS 或是 localhost,再來注意到 register 中有傳入第二個 scope 參數,這個 scope 代表 SW 可以作用的範圍,沒有傳入的話預設會是根目錄,以上圖來說的話就是 (my-domain | localhost)/ironman/ 之下都屬於 SW 作用的範圍。

至於要如何操控 SW 的生命週期呢?答案是靠事件監聽機制。

Install

剛剛有提到瀏覽器會先註冊我們寫好的 SW 檔案,註冊成功後瀏覽器會在 background 啟動一個 Service Worker 並開始安裝,這邊對應到的就是 install 這個事件,通常在安裝階段會快取一些靜態資源,以供離線瀏覽時使用,如果指定的檔案都快取成功,就會進入到下一個 Activate 階段。

如果有資源快取失敗,則整個安裝過程都會失敗,Service Worker 會進入 Error 狀態,並等待下次重新 install。

this.addEventListener('install', function(event) {
 // waitUntil 確保 SW 在安裝完成後才會去快取這些資源
  event.waitUntil(
    // 指定快取的版本號
    caches.open('v1').then(function(cache) {
      // 指定要快取的資源
      return cache.addAll(['/ironman.js', '/ironman.css']);
    }),
  );

Service Worker 處理 cache 的寫法是使用 Cache Web API,想了解更多的讀者可以再自行去深入了解一下。

Activate

完成安裝後 SW 就會接著啟動進入 Activate 的狀態並接管在自己 scope 下的頁面,在這個階段開發者可以指定 SW 做一些事,例如清除舊的快取...等等。

this.addEventListener('activate', function(event) {
  // do some work
});

SW 進入 activate 並接管頁面後,如果頁面沒有被使用時,Service Worker 會進入停止(Terminated)的狀態,以節省記憶體的耗費。

Message

使用 postMessage 跟頁面做訊息的傳遞,因為不是本篇重點,這邊就不再贅述了。

this.addEventListener('message', function(e) {
  e.source.postMessage('Message: ' + e.data);
});

function sendingMsg(msg) {
  return new Promise(function(resolve, reject) {
    const messageChannel = new MessageChannel();
    
    messageChannel.port1.onmessage = function(e) {
      if (e.data.error) {
        reject(e.data.error);
      } else {
        resolve(e.data);
      }
    };
    navigator.serviceWorker.controller.postMessage(msg, [messageChannel.port2]);
  });
}

sendingMsg('message testing');

Fetch

Fetch 是今天的重頭戲,在本篇的開頭有提到,SW 是在瀏覽器與伺服器之間的一個 proxy,擁有攔截使用者發出的請求的能力,而這個攔截的能力就來自於 fetch 事件。

在 fetch 事件中,最基本的應用是可以去快取看看有沒有使用者想要的資源,如果有的話就從快取回傳,如果沒有的話就再發出網路請求

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request).then(function(response) {
      // Cache 中有找到就直接回傳,不然就發出網路請求去要資料
      return response || fetch(event.request);
    })
  );
});

Why Service Worker Cache ?

等等...昨天不是才介紹完 HTTP Caching 嗎?看起來 HTTP Caching 已經十分強大,那為什麼我們還需要 Service Worker 的 Caching 機制呢?

  • 對快取有更細粒度的控制權

    從剛剛 fetch 生命週期的介紹讀者應該就可以感受到這一點了,我們可以透過程式碼來決定攔截到網路請求後要做什麼處理,基本上只要遵守 SW 的限制,大部分想得到的需求都能夠做到。

    如果對快取稍微了解的讀者,應該知道快取有許多不同的 strategies,例如 Cache First 與 Network First...等等,透過 SW 我們也可以實作出不同的 strategies 來對應自己的需求,這部分等等再介紹。

    相較於變化性沒那麼大的 HTTP Caching,SW Caching 可以對快取做到更細粒度的控制,可謂非常的強大。

  • Offline 離線瀏覽功能

    Offline 是 SW 提供的一個重要的功能,而得以實現離線瀏覽的關鍵就在於 Service Worker 的 Cache。透過把一些重要的資源放到 SW 的快取裡,使用者在失去網路連線時還能夠看到快取的資源,而不是顯示網路連線錯誤的畫面,大大提升了使用者體驗。

    你可能會有這樣的疑問:「為什麼 HTTP Caching 沒辦法做到離線瀏覽呢?」原因如下:

    • Cache-Control 本身的設計就不是針對離線瀏覽
    • HTTP Caching 需要經過伺服器與瀏覽器的共同協商,如果在離線前沒有造訪過一些頁面,就不會有那些頁面的快取,也就沒辦法實現離線瀏覽。而 Service Worker 則是可以透過程式決定要快取哪些資源,不一定要造訪過頁面才能將資源存到快取。
    • 每種瀏覽器的行為不一樣,就算瀏覽器已經有該資源的快取,也沒辦法保證它ㄧ定會從快取拿而不是發出網路請求,例如在某些條件下,瀏覽器就不會從快取載入資源。

不同的 Cache Strategies

關於實作離線瀏覽的 cache 機制,其實還有分成很多不同的 strategies,今天會介紹較常見的 5 種 strategies:

  • cache only
  • network only
  • cache falling back to network
  • network falling back to cache
  • cache then network

cache only

無論如何只回傳快取版本。一般來說不會直接採用這個方式,在快取沒有資源時回到 network 是比較常見也比較好的方式。如果真的要使用,比較適合用在一些與 codebase 結合的靜態資源。

self.addEventListener('fetch', function(event) {
  event.respondWith(caches.match(event.request));
});

network only

無論如何都會直接發出網路請求。同樣一般來說不會直接使用這個方式,真的要使用的話比較適合一些不適合在 offline 使用的資源,例如一些 non-GET 的 request。

self.addEventListener('fetch', function(event) {
  event.respondWith(fetch(event.request));
});

cache falling back to network

這應該是蠻常見的一種模式,先去快取找看看有沒有,有就回傳,沒有就發出 network request,如果是 non-GET 的 requests 因為不能被快取,所以會直接發出請求。

如果希望網頁可以達到 offline first,這就會是一個蠻適合的 strategy。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request).then(function(response) {
      return response || fetch(event.request);
    })
  );
});

network falling back to cache

如果資源的變化頻率很高,例如文章列表、分數排行榜..等等,就適合以 network request 為優先以確保拿到最新的資料,如果使用者失去網路連線,可以先回傳快取的舊資料,使用者體驗會比顯示 network error 好很多。

不過這種策略的缺點是當使用者沒有完全 offline 但網路連線非常微弱緩慢時,需要等待網路請求完成,這個等待會很漫長並嚴重影響使用者體驗。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    fetch(event.request).catch(function() {
      return caches.match(event.request);
    })
  );
});

cache then network

又稱作 stale while revalidate,同樣也十分適合變化頻率高的內容,當使用者對資源發出請求時,先直接回傳快取的版本,同時去網路抓取最新的內容,當有新的資料回傳時再更新快取,在這個策略下使用者可以迅速的拿到資源,提升使用者的體驗,並且後續的請求都可以拿到更新後的資料。

self.addEventListener('fetch', event => {
  const cached = caches.match(event.request);
  const fetched = fetch(event.request);
  const fetchedCopy = fetched.then(resp => resp.clone());

  // 用 Promise.race 看 cache 跟網路請求誰先回傳(通常是 cache),如果 cache 沒資料就等 network
  event.respondWith(
    Promise.race([fetched.catch(_ => cached), cached])
      .then(resp => resp || fetched)
      .catch(_ => new Response(null, {status: 404}))
  );

  // 用 fetch 回來的資料更新快取
  event.waitUntil(
    Promise.all([fetchedCopy, caches.open('cache-v1')])
      .then(([response, cache]) => cache.put(event.request, response))
      .catch(_ => {/* eat any errors */})
  );
});

Service Workers Cache 搭配 HTTP Cache

雖然 Service Workers Cache 與 HTTP Cache 有些相同的特性與功能,不過嚴格上來說兩者的目標並不一樣,所以如何將兩者搭配使用就很重要了。如果配合得好的話網站就像有雙層快取防護一樣,第一層 cache miss 了不要怕,也許第二層會幫你守住防線。

至於要如何搭配?就是得靠設置快取的 Expire Time。如果 SW Caching 與 HTTP Caching 的過期時限都設置成一樣的話,基本上 HTTP Caching 會等於沒什麼用處,因為如果 SW Cache 失守了,來到 HTTP Cache 這層同樣快取的資料也已經過期了,所以ㄧ定會回到網路請求中。

因此比較建議的方式是兩者的過期時間設定成不一樣的時間,搭配不同的快取策略,可以達到不同的效果。

因為這個主題比較深入了一點,就不在這邊說明。關於這點 Google 有發表了一篇很棒的文章,有興趣的讀者可以看看。

Workbox

自己寫 Service Workers 是一件很累也很複雜的事,有可能需要寫大量的 boilerplate code,所幸 Google 推出了 workbox 這個服務,可以讓開發者不用親自寫 Service Workers 的程式就能輕鬆實作出大部分的功能。除非需要很客製化的功能,不然非常建議要做 PWA 或是一些 Service Worker 可以實作的功能時優先考慮 workbox 這個解決方案。

本日小結

今天進入 Cache 章節的第二篇,介紹了 Service Workers 的基本概念與快取,有了 Service Workers 的幫助,我們可以更細粒度的去操作快取,並實作適合的 Cache Strategies,甚至還可以配合 HTTP Caching,建立雙層的快取保護網,充份利用快取功能來提昇網頁效能。

今天的內容一直不斷提到 Service Workers 讓離線瀏覽成為可能,我們也知道要達到離線瀏覽得依賴 Service Workers 的快取機制,不過為了實現離線瀏覽並提供最佳的使用者體驗,到底要快取哪些資源才好呢?我們明天揭曉!

Reference & 圖片來源

https://bitsofco.de/web-workers-vs-service-workers-vs-worklets/

https://blog.sessionstack.com/how-javascript-works-service-workers-their-life-cycle-and-use-cases-52b19ad98b58

https://developers.google.com/web/ilt/pwa/caching-files-with-service-worker

https://gist.github.com/surma/eb441223daaedf880801ad80006389f1


上一篇
Day17 X 初探快取 & HTTP Caching
下一篇
Day19 X Application Shell Architecture
系列文
今晚,我想來點 Web 前端效能優化大補帖!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言