🚀 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.js 或 layout.tsx (如果使用 App Router) 註冊 Service Worker。
在 _app.js 或 layout.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 的其他應用程式。