← Back

🚀 Service Workers 設定與應用指南

前言

最近公司希望減少 CDN 的資源消耗,因為每個月的流量費用實在太高,光是 CDN 使用量就動輒 10TB 起跳,帶來不少額外的支出。因此,我被指派尋找解決方案,希望能降低成本,同時不影響使用者體驗。

於是,我決定透過 Service Worker 來做快取管理,讓常用的靜態資源可以直接從本地快取讀取,而不是每次都從 CDN 拉取。

經過這次優化,我們的 CDN 使用量從每月 10TB 降到 5TB~7TB 左右,成功省下了一大筆費用,還讓網站載入速度變得更快。

這篇文章就來分享如何在 Next.js 設定 Service Worker,並運用快取策略來減少對 CDN 的依賴,讓網站跑得更快、更省!

為什麼要使用 Service Workers?

Service Worker 是一種運行於瀏覽器後台的 Web Worker,可以攔截和處理網路請求,實現資源快速獲取與離線瀏覽。

Service Worker 主要功能:

  • 資源快取:減少 API 重複請求,提高頁面載入速度
  • CDN 內容管理:透過 ETag 或 Last-Modified 確保快取資源的更新
  • 離線支援:即使使用者斷網,也能瀏覽部分頁面
  • 後台同步:當網路恢復時,自動同步資料

此篇文章中,我將介紹如何正確設定 Service Worker 來管理靜態資源和 CDN 資源,並示範如何使用 Next.js 進行註冊。

Service Worker 設定

Next.js 會自動提供 public 目錄內的靜態文件,因此 Service Worker 檔案應放在 public 目錄內。

建立 service-worker.js

const DOMAIN = self.origin; // 取得專案 domain
const CACHE_NAME = `v1-${new Date().toISOString()}`; // 自動產生版本名稱
const urlsToCache = []; // 預設要被快取的資源
// const apiBaseUrl = ['https://api.example.com']; // API 路徑
const cdnBaseUrls = [
  `${DOMAIN}/imgs/`, // 定義需要攔截的 CDN 路徑
  `${DOMAIN}/_next/static/media/`, // 定義需要攔截的 CDN 路徑
];

// 安裝事件
self.addEventListener('install', (event) => {
  self.skipWaiting(); // 強制等待中的 Service Worker 被啟動
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(urlsToCache);
    })
  );
});

// 啟動事件,清理舊快取
self.addEventListener('activate', (event) => {
  const cacheWhitelist = [CACHE_NAME];
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (!cacheWhitelist.includes(cacheName)) {
            console.log('🗑️ Deleting old cache:', cacheName);
            return caches.delete(cacheName);
          }
        })
      );
    }).then(() => self.clients.claim()) // 確保新 Service Worker 立即控制所有客戶端
  );
});

// 放入快取
const putInCache = async (request, response) => {
  try {
    const cache = await caches.open(CACHE_NAME);
    await cache.put(request, response);
  } catch (error) {
    console.error('❌ Failed to put in cache:', error);
  }
};

// 優先從快取取得資源
const cacheFirst = async ({ request }) => {
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }
  // 如果快取內沒資料,則從網路取得後存進快取
  try {
    const responseFromNetwork = await fetch(request);
    putInCache(request, responseFromNetwork.clone());
    return responseFromNetwork;
  } catch (error) {
    return new Response('Network error happened', {
      status: 408,
      headers: { 'Content-Type': 'text/plain' },
    });
  }
};

// 處理 CDN 資源
const handleCdnRequest = async (event) => {
  const cachedResponse = await caches.match(event.request);
  if (cachedResponse) {
    const cachedHeaders = cachedResponse.headers;
    const cachedEtag = cachedHeaders.get('ETag'); // 取得圖片的 ETag
    const cachedLastModified = cachedHeaders.get('Last-Modified'); // 取得圖片的 Last-Modified

    const headers = new Headers();
    if (cachedEtag) headers.append('If-None-Match', cachedEtag);
    if (cachedLastModified) headers.append('If-Modified-Since', cachedLastModified);

    const fetchRequest = new Request(event.request, { headers });

    return fetch(fetchRequest)
      .then((networkResponse) => {
        // 如果圖片沒有更新,則回傳快取的圖片
        if (networkResponse.status === 304) {
          return cachedResponse;
        } else {
          putInCache(event.request, networkResponse.clone());
          return networkResponse;
        }
      })
      .catch(() => cachedResponse);
  } else {
    return fetch(event.request).then((networkResponse) => {
      putInCache(event.request, networkResponse.clone());
      return networkResponse;
    });
  }
};

// 處理 API 資源
const handleApiRequest = async (event) => {
  return fetch(event.request)
    .then((networkResponse) => {
      putInCache(event.request, networkResponse.clone());
      return networkResponse;
    })
    .catch(() => caches.match(event.request));
};

// 檢查 URL 是否匹配任何 CDN 路徑
const isCdnRequest = (url) => {
  return cdnBaseUrls.some((baseUrl) => {
    return !baseUrl.includes('http://localhost:3000/') && url.startsWith(baseUrl);
  });
};

// 單一 fetch 事件監聽器,分派到不同的處理邏輯
self.addEventListener('fetch', (event) => {
  const requestUrl = event.request.url;
  // CDN 請求
  if (isCdnRequest(requestUrl)) {
    event.respondWith(handleCdnRequest(event));
  }
  // API 請求
  else if (requestUrl.startsWith(apiBaseUrl)) {
    event.respondWith(handleApiRequest(event));
  }
  // 其他請求
  else {
    event.respondWith(fetch(event.request));
  }
});

如何應用 Next.js 中註冊 Service Worker?

在 Next.js 中,我們可以在 _app.jslayout.tsx (如果使用 App Router) 註冊 Service Worker。

_app.jslayout.tsx 註冊

'use client';

import { useEffect } from 'react';

const registerServiceWorker = () => {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker
      .register('/service-worker.js')
      .then((registration) => {
        console.log('✅ Service Worker 註冊成功:', registration.scope);
      })
      .catch((error) => {
        console.log('❌ Service Worker 註冊失敗:', error);
      });
  }
};

const MyApp = ({ Component, pageProps }) => {
  useEffect(() => {
    registerServiceWorker();
  }, []);

  return <Component {...pageProps} />;
};

export default MyApp;

Service Worker 的最佳實現

使用 Service Worker 需要注意以下幾點,以確保應用程式的穩定性與最佳效果:

  • 動態快取更新
  • 使用 ETag 或 Last-Modified 查詢資源是否可更改,以避免不必要下載資源
  • 每次部署修改 CACHE_NAME,確保舊版本已清除
  • 非必要資源不快取
  • CDN 靜態資源可設定長期快取策略,減少網路請求
  • API 回應資料建議使用 stale-while-revalidate 模式,避免逾時
  • 正確的快取策略:
    • 靜態頁面 (/about, /contact) 使用 cacheFirst,減少伺服器負載
    • 動態統計資料 (API) 直接透過網路獲取,避免延遲取得統計數據

結語

Service Worker 是提升前端效能與離線體驗的強大工具。如果你有更深入的需求,例如 PWA (Progressive Web Apps)、背景同步或推播通知,也可以進一步研究 Service Worker 的其他應用程式。