# 前端效能優化 Day18 X Service Workers Cache Part 2 ###### tags: `前端效能優化` :::info :::spoiler Reference <br /> * [今晚,我想來點 Web 前端效能優化大補帖! Day18 X Service Workers Cache](https://ithelp.ithome.com.tw/articles/10276666) ::: # 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](https://hackmd.io/_uploads/HJY3IdnBC.png) ![image](https://hackmd.io/_uploads/rJv8d_3B0.png) ### 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. ```javascript= // 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 :::spoiler **Index Script: Register Service Worker** <br /> ```javascript!= import { Workbox } from 'workbox-window'; if ('serviceWorker' in navigator) { const wb = new Workbox('/sw.js'); wb.register(); } ``` ::: <br /> :::spoiler **Service Worker Script** <br /> https://gist.github.com/jeffposnick/fc761c06856fa10dbf93e62ce7c4bd57 ```javascript!= 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(); } }); ``` ::: <br /> :::spoiler **Webpack Config: workbox-webpack-plugin** https://gist.github.com/jeffposnick/fc761c06856fa10dbf93e62ce7c4bd57 <br /> ```javascript!= 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)載入其他重要資源 ```javascript= // 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 * https://developer.chrome.com/docs/workbox/modules/workbox-window#important_service_worker_lifecycle_moments * 當你更新了 SW script,卻發現 Browser 的運作不如預期,可能是先前版本的 SW 還在運行 * 自動檢查常見錯誤並 Log ![image](https://hackmd.io/_uploads/Bkokj-pHR.png) :::warning 為什麼 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 ```javascript= if ("serviceWorker" in navigator) { navigator.serviceWorker.getRegistration("/").then((registration) => { registration?.unregister(); }); } ``` * More about `Handling service worker updates` * https://developer.chrome.com/docs/workbox/service-worker-lifecycle#handling_service_worker_updates ::: ## Part 2: Self, Runtime Caching https://developer.chrome.com/docs/workbox/caching-resources-during-runtime ### Encapsulate logics :::spoiler **Comparison** <br /> ```javascript= // 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 ```javascript= // 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 ```javascript= new Route( handler: RouteMatchHandler ) ``` * RouteMatchHandler ```javascript= ({ 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 ```javascript= new CacheFirst({ cacheName: 'scripts', plugins: [ new ExpirationPlugin({ maxEntries: 50, }), new CacheableResponsePlugin({ statuses: [0, 200] }) ] }) ``` * `cacheName`:Separate Cache instances using the cacheName option ![image](https://hackmd.io/_uploads/ByWWlzTB0.png) * `plugins` * `new ExpirationPlugin()`:Setting an expiry for cache entries * `new CacheableResponsePlugin()`:Deal with **opaque responses** <br /> :::warning 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` ```html= <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? * https://developer.chrome.com/docs/workbox/service-worker-lifecycle#handling_service_worker_updates ![截圖 2024-06-17 上午9.58.01](https://hackmd.io/_uploads/H1_VufTHR.png) <br /> :::spoiler **Comparison** <br /> ```javascript= // 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()` ```javascript= // 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"], }), ], }; ``` :::spoiler **Result** <br /> ![截圖 2024-06-17 上午10.25.36](https://hackmd.io/_uploads/S18AAf6HC.png) ![截圖 2024-06-17 上午10.25.48](https://hackmd.io/_uploads/BJjACMaS0.png) ::: ### Config and create service worker script: `generateSW()` * 使用 `runtimeCaching` 來實踐 Running Cache ```javascript= // - 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: ..., }, }, }], }), ], }; ``` :::spoiler **Result** <br /> ![截圖 2024-06-17 上午10.40.29](https://hackmd.io/_uploads/rydIGm6BC.png) ::: # 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 ```javascript= // 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(); ```