快取策略實戰:靜態資源與 API 回應
了解快取原理後,讓我們來設計實際的快取策略。不同類型的資源需要不同的處理方式。
一、 策略概覽
1.1 快取策略矩陣
| 資源類型 | 更新頻率 | 推薦策略 |
|---|---|---|
| 帶 hash 的 JS/CSS | 永不 | max-age=1y, immutable |
| 圖片/字體 | 很少 | max-age=1y |
| HTML | 每次都可能 | no-cache |
| API(公開資料) | 可配置 | max-age=60 |
| API(私有資料) | 即時 | no-store 或 no-cache |
1.2 決策流程
二、 靜態資源策略
2.1 帶 Hash 的資源
現代打包工具會在檔名加入 hash:
app.a1b2c3d4.js
styles.e5f6g7h8.css策略:長期快取 + immutable
javascript
// Express
app.use(
"/assets",
express.static("dist/assets", {
maxAge: "1y",
immutable: true,
})
);nginx
# Nginx
location ~* \.[a-f0-9]{8,}\.(js|css)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}2.2 不帶 Hash 的靜態資源
javascript
// 圖片、字體等
app.use(
"/images",
express.static("public/images", {
maxAge: "1d",
etag: true,
})
);2.3 HTML 文件
HTML 是應用的入口,必須保持最新:
javascript
app.get("*.html", (req, res, next) => {
res.set("Cache-Control", "no-cache");
next();
});
// 或
app.get("/", (req, res) => {
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.sendFile("index.html");
});2.4 完整設定範例
javascript
const express = require("express");
const path = require("path");
const app = express();
// 帶 hash 的資源:長期快取
app.use(
"/assets",
express.static("dist/assets", {
maxAge: "1y",
immutable: true,
etag: false, // 不需要 ETag
})
);
// 圖片:中期快取
app.use(
"/images",
express.static("public/images", {
maxAge: "7d",
etag: true,
})
);
// Service Worker:不快取
app.get("/sw.js", (req, res) => {
res.set("Cache-Control", "no-cache");
res.sendFile(path.join(__dirname, "public/sw.js"));
});
// HTML:每次驗證
app.use(
express.static("public", {
maxAge: 0,
setHeaders: (res, path) => {
if (path.endsWith(".html")) {
res.set("Cache-Control", "no-cache");
}
},
})
);三、 API 快取策略
3.1 公開資料(可快取)
javascript
// 產品列表:短期快取
app.get("/api/products", async (req, res) => {
res.set("Cache-Control", "public, max-age=60");
res.json(await Product.find());
});
// 配置資訊:帶 ETag
app.get("/api/config", async (req, res) => {
const config = await Config.findOne();
const etag = `"${config.version}"`;
res.set("ETag", etag);
res.set("Cache-Control", "public, no-cache");
if (req.headers["if-none-match"] === etag) {
return res.status(304).end();
}
res.json(config);
});3.2 私有資料
javascript
// 用戶專屬資料:私有快取
app.get("/api/profile", authenticate, (req, res) => {
res.set("Cache-Control", "private, max-age=300");
res.json(req.user);
});
// 敏感資料:不快取
app.get("/api/account/balance", authenticate, (req, res) => {
res.set("Cache-Control", "no-store");
res.json({ balance: account.balance });
});3.3 分頁資料
javascript
app.get("/api/posts", async (req, res) => {
const { page = 1, limit = 10 } = req.query;
const posts = await Post.find()
.skip((page - 1) * limit)
.limit(limit);
// 第一頁可能更新頻繁
if (page === 1) {
res.set("Cache-Control", "public, max-age=60");
} else {
// 後面的頁面較穩定
res.set("Cache-Control", "public, max-age=300");
}
res.json(posts);
});3.4 即時資料
javascript
// 股票價格:不快取
app.get("/api/stocks/:symbol", (req, res) => {
res.set("Cache-Control", "no-store");
res.json(stockPrice);
});
// 或使用極短的快取
app.get("/api/weather", (req, res) => {
res.set("Cache-Control", "public, max-age=5"); // 5 秒
res.json(weather);
});四、 進階策略
4.1 Stale-While-Revalidate
背景更新,用戶不用等:
javascript
app.get("/api/dashboard", async (req, res) => {
res.set("Cache-Control", "public, max-age=60, stale-while-revalidate=3600");
res.json(await getDashboardData());
});4.2 Stale-If-Error
錯誤時使用過期快取:
javascript
app.get("/api/data", (req, res) => {
res.set("Cache-Control", "public, max-age=60, stale-if-error=86400");
res.json(data);
});如果伺服器返回 5xx,可以使用最多 1 天前的快取。
4.3 Vary 標頭
根據請求標頭區分快取:
javascript
// 根據 Accept-Language 快取不同版本
app.get("/api/content", (req, res) => {
res.set("Vary", "Accept-Language");
res.set("Cache-Control", "public, max-age=3600");
const lang = req.headers["accept-language"]?.split(",")[0] || "en";
res.json(getContent(lang));
});
// 根據認證狀態
app.get("/api/page", (req, res) => {
res.set("Vary", "Authorization");
// ...
});五、 快取失效策略
5.1 版本化 URL
javascript
// 前端:使用版本化的 URL
const API_VERSION = "v1";
fetch(`/api/${API_VERSION}/users`);
// 或使用查詢參數
const CACHE_BUSTER = Date.now();
fetch(`/api/config?v=${CACHE_BUSTER}`);5.2 Surrogate-Key(CDN)
javascript
app.get("/api/products/:id", async (req, res) => {
const product = await Product.findById(req.params.id);
// Fastly、Varnish 等支援
res.set(
"Surrogate-Key",
`product-${product.id} category-${product.categoryId}`
);
res.set("Cache-Control", "public, max-age=86400");
res.json(product);
});
// 當產品更新時,清除相關快取
async function invalidateProduct(productId) {
await cdn.purgeByKey(`product-${productId}`);
}5.3 Cache-Tag(Cloudflare)
javascript
app.get("/api/posts/:id", async (req, res) => {
const post = await Post.findById(req.params.id);
res.set("Cache-Tag", `post-${post.id}, author-${post.authorId}`);
res.set("Cache-Control", "public, max-age=86400");
res.json(post);
});六、 完整範例
6.1 Express 配置
javascript
const express = require("express");
const compression = require("compression");
const etag = require("etag");
const app = express();
// 壓縮
app.use(compression());
// 靜態資源快取配置
const staticCacheOptions = {
setHeaders: (res, path) => {
// 帶 hash 的資源
if (/\.[a-f0-9]{8,}\.(js|css)$/.test(path)) {
res.set("Cache-Control", "public, max-age=31536000, immutable");
}
// Service Worker
else if (path.endsWith("sw.js")) {
res.set("Cache-Control", "no-cache");
}
// HTML
else if (path.endsWith(".html")) {
res.set("Cache-Control", "no-cache");
}
// 其他靜態資源
else {
res.set("Cache-Control", "public, max-age=86400");
}
},
};
app.use(express.static("public", staticCacheOptions));
// API 快取中介軟體
function apiCache(options = {}) {
const { maxAge = 60, isPublic = true, useEtag = false } = options;
return (req, res, next) => {
const cacheControl = [isPublic ? "public" : "private", `max-age=${maxAge}`];
res.set("Cache-Control", cacheControl.join(", "));
if (useEtag) {
const send = res.send.bind(res);
res.send = (body) => {
const etagValue = etag(body);
res.set("ETag", etagValue);
if (req.headers["if-none-match"] === etagValue) {
return res.status(304).end();
}
send(body);
};
}
next();
};
}
// 使用
app.get("/api/products", apiCache({ maxAge: 60 }), getProducts);
app.get("/api/config", apiCache({ maxAge: 0, useEtag: true }), getConfig);
app.get("/api/profile", apiCache({ maxAge: 300, isPublic: false }), getProfile);6.2 Nginx 配置
nginx
server {
listen 443 ssl http2;
server_name example.com;
# 帶 hash 的靜態資源
location ~* \.[a-f0-9]{8,}\.(js|css|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# 圖片
location ~* \.(jpg|jpeg|png|gif|webp|svg)$ {
expires 7d;
add_header Cache-Control "public";
}
# HTML
location ~* \.html$ {
add_header Cache-Control "no-cache";
}
# API
location /api/ {
proxy_pass http://backend;
# 允許後端設定 Cache-Control
proxy_hide_header Cache-Control;
add_header Cache-Control $upstream_http_cache_control;
}
}總結
| 資源 | Cache-Control | 說明 |
|---|---|---|
| JS/CSS (hash) | max-age=1y, immutable | 永久快取 |
| 圖片 | max-age=7d | 一週快取 |
| HTML | no-cache | 每次驗證 |
| API (公開) | max-age=60 | 一分鐘 |
| API (私有) | private, max-age=300 | 五分鐘 |
| API (敏感) | no-store | 不快取 |
> **黃金法則**:
- 使用內容雜湊命名靜態資源
- HTML 永遠設為
no-cache - API 根據資料特性選擇策略
進階挑戰
- 設計一個多層快取架構:瀏覽器 → CDN → 應用層快取 → 資料庫。
- 實作 Service Worker 的快取策略(Cache First、Network First)。
- 研究如何監控快取命中率,並優化策略。