跳至主要內容
Skip to content

快取策略實戰:靜態資源與 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一週快取
HTMLno-cache每次驗證
API (公開)max-age=60一分鐘
API (私有)private, max-age=300五分鐘
API (敏感)no-store不快取

> **黃金法則**:

  • 使用內容雜湊命名靜態資源
  • HTML 永遠設為 no-cache
  • API 根據資料特性選擇策略

進階挑戰

  1. 設計一個多層快取架構:瀏覽器 → CDN → 應用層快取 → 資料庫。
  2. 實作 Service Worker 的快取策略(Cache First、Network First)。
  3. 研究如何監控快取命中率,並優化策略。

延伸閱讀與資源