使用 Service Worker 讓首頁秒開

我們可以用 Stale-While-Revalidate 加速頁面訪問,策略分 3 步

  1. 在收到頁面請求時首先檢查緩存,如果命中緩存就直接從緩存中返回給用戶

  2. 將緩存返回用戶的同時,在後臺異步發起網絡請求,嘗試獲取資源的最新版本

  3. 獲取成功後更新緩存,下次使用

而這一切的幕後功臣便是 Service Worker,作爲一個後臺代理在網絡與緩存之間搭建橋樑,提供了豐富的緩存管理和資源控制能力,從而實現這一高效策略

爲了實現這一策略,需要首先了解一下 Service Worker 的核心 API

Service 基礎概念

Service Worker 基礎概念可以在這裏瞭解

此處爲語雀內容卡片,點擊鏈接查看:www.yuque.com/sunluyong/f…[1]

攔截修改 Response 對象

使用 event.respondWith 可以在 fetch 事件中攔截網絡請求並提供自定義響應,一旦調用瀏覽器會等待提供的 Promise 解析,並將其結果作爲響應返回給發起請求的代碼

self.addEventListener('fetch'event ={
  event.respondWith(
    /* 自定義響應邏輯 */
  );
});

比如實現攔截特定請求,可以首先嚐試從緩存中獲取資源,如果緩存命中則返回緩存內容,否則從網絡獲取資源並緩存

self.addEventListener('fetch'event ={
  // 過濾非頁面請求
  const url = new URL(event.request.url);
  if (!url.pathname.startsWith('/page/')) return;
  
  event.respondWith(
    caches.match(event.request) // 嘗試匹配緩存
      .then(cachedResponse ={
        if (cachedResponse) {
          return cachedResponse; // 緩存命中,返回緩存內容
        }
        // 緩存未命中,從網絡獲取
        return fetch(event.request);
      })
  );
});

必須在 fetch 事件監聽器內部的第一時間調用 event.respondWith,否則瀏覽器將繼續使用默認的網絡請求處理方式

clone Response 對象緩存

在 Service Worker 中處理網絡請求和緩存時,經常會遇到需要 clone 響應對象

const responseToCache = networkResponse.clone();

這是由於 Response 對象是一個可讀流,而流具有以下特性

當讀取 Response 的 body 返回給瀏覽器後,Stream 會被讀取並關閉,之後無法再次讀取用於緩存。通過 clone Response 對象,可以創建一個獨立的副本,確保每個副本的 Stream 都可單獨消費

fetch(event.request).then(networkResponse ={
  // 克隆響應用於緩存
  const responseToCache = networkResponse.clone();

  // 返回給客戶端
  event.respondWith(networkResponse);

  // 緩存克隆的響應
  caches.open(CACHE_NAME).then(cache ={
    cache.put(event.request, responseToCache);
  });
});

event.waitUntil 確保異步任務完成

Service Worker 事件都是異步的,瀏覽器可能在這些異步操作完成之前終止 Service Worker,導致關鍵任務(如緩存資源或清理舊緩存)無法正確完成

通過調用 event.waitUntil(promise),可以告訴瀏覽器要 “等待” 某個 Promise 完成之後,才認爲事件處理完成,這確保了瀏覽器不會在關鍵異步操作完成之前終止 Service Worker

比如在激活階段,通常需要清理舊的緩存

self.addEventListener('activate'event ={
  console.log('[Service Worker] Activate Event');
  const cacheWhitelist = ['my-cache-v2'];

  caches.keys().then(cacheNames ={
    return Promise.all(
      cacheNames.map(cacheName ={
        if (!cacheWhitelist.includes(cacheName)) {
          console.log(`[Service Worker] Deleting old cache: ${cacheName}`);
          return caches.delete(cacheName);
        }
      })
    );
  })
});

瀏覽器可能在異步緩存清理任務完成之前終止激活過程,導致舊緩存可能未被正確刪除,使用 event.waitUntil 可以確保所有清理操作完成

self.addEventListener('activate'event ={
  console.log('[Service Worker] Activate Event');
  const cacheWhitelist = ['my-cache-v2'];
  
  event.waitUntil(
    caches.keys().then(cacheNames ={
      return Promise.all(
        cacheNames.map(cacheName ={
          if (!cacheWhitelist.includes(cacheName)) {
            console.log(`[Service Worker] Deleting old cache: ${cacheName}`);
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

Stale-While-Revalidate 實現

1. 創建目錄結構

.
├── app
│   └── index.js
├── package.json
└── public
    ├── favicon.ico
    ├── index.html
    └── sw.js

因爲 Service Worker 需要服務端配合,爲了簡單使用 express 演示

npm install --save express

2. 提供 web 服務

修改 app/index.js,public 目錄對外服務,爲了演示緩存更新效果,添加了一個帶有頁面版本號的自定義響應頭 x-page-version

const express = require('express');
const path = require('path');

const app = express();
const port = 3000;

app.use(express.static(path.join(__dirname, '../public'){
  setHeaders: (res) ={
    // mock,每隔 5s version 就發生變化
    res.set('x-page-version', Math.ceil(Date.now() / 5000));
  }
}));

// 啓動服務器
app.listen(port, () ={
  console.log(`Example app listening at http://localhost:${port}`);
});

3. Service Worker 實現

首先是最基礎的安裝、激活,代碼量並不大,主要是添加了很多 log 方便觀測 Service Worker 執行過程

const CACHE_NAME = 'HOMEPAGE_CACHE_v1'; // 緩存 key,sw.js 更新了可以升級版本

// 配置需要緩存的資源,demo 中只緩存主文檔,靜態資源瀏覽器自己就會緩存
const urlsToCache = [
  '/',
];

// 安裝事件:預緩存一些關鍵資源
self.addEventListener('install'(event) ={
  console.log('[Service Worker] Install Event');
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) ={
      console.log('[Service Worker] Caching pre-defined resources');
      return cache.addAll(urlsToCache);
    }).catch((error) ={
      console.error('[Service Worker] Failed to cache resources during install:', error);
    })
  );
});

// 激活事件:清理舊版本的緩存
self.addEventListener('activate'(event) ={
  console.log('[Service Worker] Activate Event');
  const cacheWhitelist = [CACHE_NAME];
  event.waitUntil(
    caches.keys().then((cacheNames) ={
      return Promise.all(
        cacheNames.map((cacheName) ={
          if (!cacheWhitelist.includes(cacheName)) {
            console.log(`[Service Worker] Deleting old cache: ${cacheName}`);
            return caches.delete(cacheName);
          }
        })
      );
    }).then(() => self.clients.claim()) // 確保 SW 控制所有客戶端
  );
});

4. 劫持頁面請求

// 獲取事件:實現 "Stale-While-Revalidate" 策略
self.addEventListener('fetch'(event) ={
  const requestUrl = new URL(event.request.url);
  // 僅處理需要緩存的請求
  if (!urlsToCache.includes(requestUrl.pathname)) return;

  // 處理 fetch 事件
  event.respondWith(
    caches.match(event.request).then((cachedResponse) ={
      if (cachedResponse) {
        // 如果緩存存在,立即返回緩存內容
        console.log(`[Service Worker] Serving from cache: ${event.request.url}`);

        // 後臺發起網絡請求以更新緩存
        event.waitUntil(
          fetch(event.request).then((networkResponse) ={
            if (networkResponse && networkResponse.status === 200) {
              return caches.open(CACHE_NAME).then((cache) ={
                // 緩存最新內容,下次使用
                cache.put(event.request, networkResponse.clone());
                console.log(`[Service Worker] Fetched and cached (background)${event.request.url}`);
              });
            }
          }).catch((error) ={
            console.error(`[Service Worker] Background fetch failed for${event.request.url}`, error);
          })
        );

        return cachedResponse; // 立即返回緩存內容
      }

      // 如果緩存不存在,從網絡獲取最新資源
      return fetch(event.request).catch((error) ={
        console.error(`[Service Worker] Fetch failed for${event.request.url}`, error);
      });
    })
  );
});

這樣就基本實現了 Stale-While-Revalidate

5. 註冊 Service Worker

在主線程激活 Service Worker

if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js").then(registration ={
    console.log(`Service Worker registered with scope: ${registration.scope}`);
  }).catch(error ={
    console.log(`Service Worker registration failed: ${error}`);
  });
}

更進一步

可以對上面 demo 改進一下,當獲取到最新版本頁面後和緩存對比,如果發現頁面版本已更新,可以給主線程發送通知,讓頁面重新發請求,獲取最新版本的緩存

更新 fetch 事件處理

// 獲取事件:實現 "Stale-While-Revalidate" 策略
self.addEventListener('fetch'(event) ={
  const requestUrl = new URL(event.request.url);
  // 僅處理需要緩存的請求
  if (!urlsToCache.includes(requestUrl.pathname)) return;

  // 處理 fetch 事件
  event.respondWith(
    caches.match(event.request).then((cachedResponse) ={
      if (cachedResponse) {
        // 如果緩存存在,立即返回緩存內容
        console.log(`[Service Worker] Serving from cache: ${event.request.url}`);

        // 後臺發起網絡請求以更新緩存
        event.waitUntil(
          fetch(event.request).then((networkResponse) ={
            if (networkResponse && networkResponse.status === 200 && networkResponse.type === 'basic') {
              // 獲取緩存響應中的版本
              const cachedVersion = cachedResponse.headers.get('x-page-version');
              // 獲取網絡響應中的版本
              const networkVersion = networkResponse.headers.get('x-page-version');
              console.log(`[Service Worker] Cached Version: ${cachedVersion}`);
              console.log(`[Service Worker] Network Version: ${networkVersion}`);
              // 如果頁面版本已更新
              if (networkVersion !== cachedVersion) {
                return caches.open(CACHE_NAME).then((cache) ={
                  cache.put(event.request, networkResponse.clone());
                  console.log(`[Service Worker] Fetched and cached (background)${event.request.url}`);

                  // 通知客戶端刷新,展示最新內容
                  return sendMessage({
                    version: networkVersion,
                    action: 'update',
                    url: event.request.url,
                  });
                });
              }
            }
          }).catch((error) ={
            console.error(`[Service Worker] Background fetch failed for${event.request.url}`, error);
          })
        );

        return cachedResponse; // 立即返回緩存內容
      }

      // 如果緩存不存在,從網絡獲取最新資源
      return fetch(event.request).catch((error) ={
        console.error(`[Service Worker] Fetch failed for${event.request.url}`, error);
      });
    })
  );
});

// 輔助函數:發送消息給客戶端
function sendMessage(data) {
  return self.clients.matchAll().then((clients) ={
    clients.forEach((client) ={
      client.postMessage(data);
    });
  });
}

更新主線程,添加接收來自 Service Worker 消息事件

navigator.serviceWorker.addEventListener("message"event ={
  console.log('Received a message from Service Worker:', event.data);
  if (event.data.action === "update") {
    if (event.data.url === window.location.href ) {
      console.log('load lasted version');
      location.href = event.data.url;
    }
  }
});

這就是 alibaba.com[2] 秒開的祕籍

作者:謙行

https://juejin.cn/post/7443159206318686246

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/oa92SRlPNXqbpp57pAXwJw