Try   HackMD

前端效能優化 Day18 X Service Workers Cache Part 2

tags: 前端效能優化

Outline

  • Caching

    • Recap Cache Strategies
    • Caching layers: Service Worker Caching vs HTTP Caching
    • Runtime Caching vs Precaching
    • Implement Cache Strategies
  • Workbox

  • Strongly recommend to read all the references in the ariticle before you implement service worker caching in your project.

Caching

Recap Cache Strategies

  • Cache Only
  • Network Onlly
  • Cache First
  • Network First
  • Stale While Revalidation

Caching layers: Service Worker Caching vs HTTP Caching

https://web.dev/articles/service-worker-caching-and-http-caching

image

image

Features and Comparisons

Features HTTP Caching SW Cahcing
HTTP Caching 只有「有跟沒有」 只有 Cache Only, Network Only 還有 Cache First, Network First, Stale While Revalidaiont
HTTP Caching 只有在有真實的 request 發生時才會作用 Offline feature NOT available Offline feature available
SW Caching 後才是 HTTP Caching SW Caching 準行的 request 可能是 HTTP Caching 的結果,這也是為什麼一起設定 SW Caching、HTTP Caching,可以有更彈性的 Caching 機制
SW Caching 是透過 JavaScript 運行的 無法解析 Opaque response

Different cache expiry logic at the SW Caching and HTTP Caching

Example

Strategy Service worker cache TTL HTTP cache max-age
Network First 1 day 10 mins
  1. When a cached resource is valid in the service worker cache (<= 1 day): The service worker goes to the network for the resource. The browser returns the resource from the HTTP cache if it's there (updated every 10 mins). If the network is down, the service worker returns the resource from the service worker cache

  2. When a cached resource is expired in the service worker cache (> 1 day): The service worker goes to the network to fetch the resource. The browser fetches the resources over the network as the cached version in its HTTP cache is expired.

Pros and cons

Pro

  1. When the network is unstable or down, the service worker returns cached resources immediately.
  2. More efficient if the resource need not be that realtime with HTTP Caching set.

Con

  1. The service worker requires additional cache-busting to override the HTTP Cache and make "Network first" requests.

(Runtime) Caching Strategies

https://developer.chrome.com/docs/workbox/caching-strategies-overview

Cache interface

  • high-level cache driven by a JavaScript API.
  • Important API methods
    • CacheStorage.open to create a new Cache instance.
    • Cache.add and Cache.put to store network responses in a service worker cache.
    • Cache.match to locate a cached response in a Cache instance.
    • Cache.delete to remove a cached response from a Cache instance.

Fetch Event

Notice the important API methods just mentioned.

// Establish a cache name const cacheName = 'MyFancyCacheName_v1'; self.addEventListener('install', (event) => { event.waitUntil(caches.open(cacheName)); }); self.addEventListener('fetch', async (event) => { // Is this a request for an image? if (event.request.destination === 'image') { // Open the cache event.respondWith(caches.open(cacheName).then((cache) => { // Respond with the image from the cache or from the network return cache.match(event.request).then((cachedResponse) => { return cachedResponse || fetch(event.request.url).then((fetchedResponse) => { // Add the network response to the cache for future visits. // Note: we need to make a copy of the response to save it in // the cache and use the original as the request response. cache.put(event.request, fetchedResponse.clone()); // Return the network response return fetchedResponse; }); }); })); } else { return; } });

Implement Caching Strategy

Cache Only

https://developer.chrome.com/docs/workbox/caching-strategies-overview#cache_only

Network Onlly

https://developer.chrome.com/docs/workbox/caching-strategies-overview#network_only

Cache First

https://developer.chrome.com/docs/workbox/caching-strategies-overview#cache_first_falling_back_to_network

Network First

https://developer.chrome.com/docs/workbox/caching-strategies-overview#network_first_falling_back_to_cache

Stale While Revalidation

https://developer.chrome.com/docs/workbox/caching-strategies-overview#stale-while-revalidate

Runtime Caching vs Precaching

https://developer.chrome.com/docs/workbox/service-worker-overview#precaching_and_runtime_caching

Precaching

  • Cache assets ahead of time, typically during a service worker's installation.
  • Key static assets and materials needed for offline access.
  • Able to improves page speed to subsequent pages.

Runtime caching

  • For assets as they are requested from the network during runtime.
  • Guarantees offline access to pages and assets.

Workbox

Example: Demo and Source code

Index Script: Register Service Worker
import { Workbox } from 'workbox-window'; if ('serviceWorker' in navigator) { const wb = new Workbox('/sw.js'); wb.register(); }

Service Worker Script

https://gist.github.com/jeffposnick/fc761c06856fa10dbf93e62ce7c4bd57

import { CacheableResponsePlugin } from 'workbox-cacheable-response/CacheableResponsePlugin'; import { CacheFirst } from 'workbox-strategies/CacheFirst'; import { ExpirationPlugin } from 'workbox-expiration/ExpirationPlugin'; import { precacheAndRoute } from 'workbox-precaching/precacheAndRoute'; import { registerRoute } from 'workbox-routing/registerRoute'; // Precaching with workbox-build precacheAndRoute(self.__WB_MANIFEST); // Running Caching registerRoute( /^https:\/\/mylibrary\.io\/graphql\?.+cache%22:1/, new CacheFirst({ cacheName: 'short-cache', matchOptions: { ignoreVary: true }, plugins: [ new ExpirationPlugin({ maxEntries: 500, maxAgeSeconds: 300, purgeOnQuotaError: true, }), new CacheableResponsePlugin({ statuses: [0, 200] }), ] })); registerRoute( /^https:\/\/mylibrary\.io\/graphql\?.+cache%22:5/, new CacheFirst({ cacheName: 'medium-cache', matchOptions: { ignoreVary: true, }, plugins: [ new ExpirationPlugin({ maxEntries: 500, maxAgeSeconds: 86400, purgeOnQuotaError: true, }), new CacheableResponsePlugin({ statuses: [0, 200] }), ], }) ); registerRoute( /^https:\/\/mylibrary\.io\/graphql\?.+cache%22:9/, new CacheFirst({ cacheName: 'max-cache', matchOptions: { ignoreVary: true, }, plugins: [ new ExpirationPlugin({ maxEntries: 500, maxAgeSeconds: 63072e3, purgeOnQuotaError: true, }), new CacheableResponsePlugin({ statuses: [0, 200] })] }) ); // Customized parts self.addEventListener('message', (event) => { if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } });

Webpack Config: workbox-webpack-plugin

https://gist.github.com/jeffposnick/fc761c06856fa10dbf93e62ce7c4bd57


const path = require('path'); const { InjectManifest } = require('workbox-webpack-plugin'); module.exports = { mode: 'production', entry: './src/index.js', output: { path: path.resolve(__dirname, 'dist'), filename: 'index.bundle.js' }, plugins: [ new InjectManifest({ swSrc: './src-service-worker.js', swDest: './service-worker.js', // Any other config if needed. }), ], };

Part 1: Client, Register Service Worker

https://developer.chrome.com/docs/workbox/modules/workbox-window

  • 想像在建立 Service Worker Script 之後,仍需要在 Browser (windox context) 註冊並運行它
  • workbox-window 將各種情境及會遇到的 edge cases 封裝成可複用的函式,降低 developer 的使用門檻。
    • 在 Load 事件註冊 SW 以避免延遲(Block)載入其他重要資源
      ​​​​​​​​// Previously ​​​​​​​​window.addEventListener('load', () => { ​​​​​​​​ if ('serviceWorker' in navigator) { ​​​​​​​​ navigator.serviceWorker.register('/sw.js') ​​​​​​​​ .then(() => { ​​​​​​​​ ... ​​​​​​​​ }) ​​​​​​​​ .catch((error) => { ​​​​​​​​ ... ​​​​​​​​ }) ​​​​​​​​ } ​​​​​​​​}); ​​​​​​​​// With workbox-window ​​​​​​​​import { Workbox } from 'workbox-window'; ​​​​​​​​if ('serviceWorker' in navigator) { ​​​​​​​​ const wb = new Workbox('/sw.js'); ​​​​​​​​ wb.register(); ​​​​​​​​}
    • 指引更新新版本的 SW
    • 自動檢查常見錯誤並 Log
      image

為什麼 workbox-window 沒有 unregister() logic?

https://stackoverflow.com/questions/46424367/how-to-unregister-and-remove-old-service-worker

  • 退一步想為什麼要 unregister()?

    • Completely remove existed SW.
    • Update existed SW.
  • workbox-window deal with the updatation logic for you

  • 當你要完全移除 SW,你需要在 main.js 中 explicitly write something like

if ("serviceWorker" in navigator) { navigator.serviceWorker.getRegistration("/").then((registration) => { registration?.unregister(); }); }

Part 2: Self, Runtime Caching

https://developer.chrome.com/docs/workbox/caching-resources-during-runtime

Encapsulate logics

Comparison
// Apply CacheFirst strategy on image resources. // Previously self.addEventListener('fetch', async (event) => { if (event.request.destination === 'image') { event.respondWith( caches .open(cacheName) .then((cache) => cache .match(event.request) .then((cachedResponse) => cachedResponse || fetch(event.request.url) .then((fetchedResponse) => { cache.put(event.request, fetchedResponse.clone()); return fetchedResponse; }), ), ) ); } }); // With workbox-routing, workbox-strategies import { registerRoute } from 'workbox-routing'; import { CacheFirst } from 'workbox-strategies'; registerRoute( ({ request }) => request.destination === 'image', new CacheFirst() );

Applying caching strategies with route matching

// sw.js import { registerRoute, Route } from 'workbox-routing'; import { CacheFirst } from 'workbox-strategies'; // Register the new route registerRoute( // A new route that matches same-origin image requests and handles // them with the cache-first, falling back to network strategy: new Route(({ request, sameOrigin }) => { return sameOrigin && request.destination === 'image' }), new CacheFirst({ cacheName: 'Foo' }) );

workbox-routing 提供 registerRoute() 包含兩個參數

  1. Route

    • Route
      ​​​​​​​​new Route( handler: RouteMatchHandler )
    • RouteMatchHandler
      ​​​​​​​({ request, sameOrigin }) => { ​​​​​​​​ return sameOrigin && request.destination === 'image' ​​​​​​​​}
    • string
    • RegExp
  2. Route Handler

    • 可以直接使用 workbox-strategies 提供的 Stategies
      • new CacheOnly()
      • new CacheFirst()
      • new NetworkOnly()
      • new NetworkFirst()
      • new StaleWhileRevalidate()
    • param Options
      ​​​​​​​​new CacheFirst({ ​​​​​​​​ cacheName: 'scripts', ​​​​​​​​ plugins: [ ​​​​​​​​ new ExpirationPlugin({ ​​​​​​​​ maxEntries: 50, ​​​​​​​​ }), ​​​​​​​​ new CacheableResponsePlugin({ ​​​​​​​​ statuses: [0, 200] ​​​​​​​​ }) ​​​​​​​​ ] ​​​​​​​​})
      • cacheName:Separate Cache instances using the cacheName option

        image

      • plugins

        • new ExpirationPlugin():Setting an expiry for cache entries
        • new CacheableResponsePlugin():Deal with opaque responses

        Opaque responses

        https://developer.chrome.com/docs/workbox/caching-resources-during-runtime#opaque_responses

        • When making a cross-origin request in no-cors mode, the response can be stored in a service worker cache and even be used directly by the browser. However, the response body itself can't be read via JavaScript. This is known as an opaque response.
        • Opaque responses can NOT be manipulated in SW because they can't be read via JavaScript.
        • Remember to add crossorigin as anonymous
        ​​​​​​​​​​​​<link crossorigin="anonymous" rel="stylesheet" href="https://example.com/path/to/style.css">
        • Workbox do not cache opaque responses by default.

Part 3: Self, Precaching

https://developer.chrome.com/docs/workbox/modules/workbox-precaching

  • workbox-precaching 將各種情境及會遇到的 edge cases 封裝成可複用的函式,降低 developer 的使用門檻。

    • Precaching 顧名思義需要

      • 在 request 發生前儲存 Cache:Intall 事件
      • 在 request 發生時使用 Cache:Fetch 事件
    • 如何在資源更新時,更新 SW?


      Comparison
      ​​​​​​​​// Precache assets ​​​​​​​​// Previously ​​​​​​​​import { registerRoute } from 'workbox-routing'; ​​​​​​​​import { CacheOnly } from 'workbox-strategies'; ​​​​​​​​const assets = [ ​​​​​​​​ // Hard to cache file without hash in file name: '/index.html' ​​​​​​​​ '/css/global.ced4aef2.css', ​​​​​​​​ '/js/home.109defa4.js', ​​​​​​​​]; ​​​​​​​​self.addEventListener('install', (event) => { ​​​​​​​​ const cacheKey = 'MyFancyCacheName_v2'; ​​​​​​​​ event.waitUntil(caches.open(cacheKey).then((cache) => { ​​​​​​​​ // Add all the assets in the array to the 'MyFancyCacheName_v2' ​​​​​​​​ // `Cache` instance for later use. ​​​​​​​​ return cache.addAll(assets); ​​​​​​​​ })); ​​​​​​​​}); ​​​​​​​​self.addEventListener('activate', (event) => { ​​​​​​​​ // Specify allowed cache keys ​​​​​​​​ const cacheAllowList = ['MyFancyCacheName_v2']; ​​​​​​​​ // Get all the currently active `Cache` instances. ​​​​​​​​ event.waitUntil(caches.keys().then((keys) => { ​​​​​​​​ // Delete all caches that aren't in the allow list: ​​​​​​​​ return Promise.all(keys.map((key) => { ​​​​​​​​ if (!cacheAllowList.includes(key)) { ​​​​​​​​ return caches.delete(key); ​​​​​​​​ } ​​​​​​​​ })); ​​​​​​​​ })); ​​​​​​​​}); ​​​​​​​​for (const asset of assets) { ​​​​​​​​ registerRoute( ​​​​​​​​ asset, ​​​​​​​​ new CacheOnly(), ​​​​​​​​ ); ​​​​​​​​} ​​​​​​​​// With workbox-routing, workbox-strategies ​​​​​​​​import { precacheAndRoute } from 'workbox-precaching'; ​​​​​​​​precacheAndRoute([ ​​​​​​​​ {url: '/index.html', revision: '383676'}, ​​​​​​​​ {url: '/css/global.ced4aef2.css', revision: null}, ​​​​​​​​ {url: '/js/home.109defa4.js', revision: null}, ​​​​​​​​ // ... other entries ... ​​​​​​​​]);
  • workbox-precaching 提供 precacheAndRoute()

    • param url
    • param revision

Workbox Build: Config and Create Service Worker Script

Pain points

  • 目前大多專案會使用 Webpack 等 bundler,build 出來的 file name 是自動產生的,難道我每次 build 時都要手動更改 SW script 嗎?

  • 大多數的使用情境很單純,是不是可以有自動創建 SW script 的工具呢?

  • workbox-build 提供 injectManifest()generateSW()

https://developer.chrome.com/docs/workbox/modules/workbox-build

Insert precaching logic in exsiting SW script: injectManifest()

// 1. Insert entry in source SW file. // src/sw.js import { ... } from '...'; precacheAndRoute(self.__WB_MANIFEST); ... // - 2. Run `injectManifest()` after you build your bundles. // - Either run a customized node file, // package.json { "scripts": { "build": "react-scripts build && node ./build-sw.mjs", ... }, ... } // build-sw.mjs import { injectManifest } from "workbox-build"; injectManifest({ swSrc: "./src/sw.js", swDest: "./build/sw.js", globDirectory: "./build", globPatterns: ["**/*.js", "**/*.css", "**/*.svg"], }); // - or even employ 'workbox-webpack-plugin' in Webpack config. // webpack.config.js const path = require('path'); const { InjectManifest } = require('workbox-webpack-plugin'); module.exports = { ... plugins: [ new InjectManifest({ swSrc: './src/sw.js', swDest: './build/sw.js', globDirectory: "./build", globPatterns: ["**/*.js", "**/*.css", "**/*.svg"], }), ], };
Result

截圖 2024-06-17 上午10.25.36

截圖 2024-06-17 上午10.25.48

Config and create service worker script: generateSW()

  • 使用 runtimeCaching 來實踐 Running Cache
// - 1. Run `injectManifest()` after you build your bundles. // - Either run a customized node file, // package.json { "scripts": { "build": "react-scripts build && node ./build-sw.mjs", ... }, ... } // build-sw.mjs import { generateSW } from "workbox-build"; generateSW({ swDest: "./build/sw.js", globDirectory: "./build", globPatterns: ["**/*.js", "**/*.css", "**/*.svg"], runtimeCaching: [{ urlPattern: ({ request }) => request.destination === 'image', handler: 'CacheFirst', options: { cacheName: '...', expiration: { maxEntries: ..., }, }, }], }); // - or even employ 'workbox-webpack-plugin' in Webpack config. // webpack.config.js const path = require('path'); const { GenerateSW } = require('workbox-webpack-plugin'); module.exports = { ... plugins: [ new GenerateSW({ swDest: './build/sw.js', globDirectory: "./build", globPatterns: ["**/*.js", "**/*.css", "**/*.svg"], runtimeCaching: [{ urlPattern: ({ request }) => request.destination === 'image', handler: 'CacheFirst', // 使用 string 來 reference workbox-strategies for ejs-cjs issue. options: { cacheName: '...', expiration: { maxEntries: ..., }, }, }], }), ], };
Result

截圖 2024-06-17 上午10.40.29

Misc.

workbox-recipe

https://developer.chrome.com/docs/workbox/modules/workbox-recipes

A number of common patterns, especially around routing and caching, are common enough that they can be standardized into reusable recipes.

Example: Page cache

https://developer.chrome.com/docs/workbox/modules/workbox-recipes#page_cache

// Previously import { registerRoute } from "workbox-routing"; import { NetworkFirst } from "workbox-strategies"; import { CacheableResponsePlugin } from "workbox-cacheable-response"; const cacheName = "pages"; const matchCallback = ({ request }) => request.mode === "navigate"; const networkTimeoutSeconds = 3; registerRoute( matchCallback, new NetworkFirst({ networkTimeoutSeconds, cacheName, plugins: [ new CacheableResponsePlugin({ statuses: [0, 200], }), ], }) ); // workbox-recipes import { pageCache } from "workbox-recipes"; pageCache();