跳到主要内容

静态资源哈希与长期缓存

1. 核心思想

文件名含内容 hash → 内容变 = hash 变 = 新 URL → 浏览器 / CDN 视为新资源
内容不变 → hash 不变 → URL 不变 → 永久命中缓存

配合 Cache-Control: public, max-age=31536000, immutable,1 年强缓存 + 不发验证请求。

2. 构建工具输出

2.1 Vite

// vite.config.ts
export default {
build: {
rollupOptions: {
output: {
// 默认就有 hash
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
}
}
}
}

2.2 Webpack

output: {
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].js',
}

2.3 Next.js

默认 _next/static/ 下全部 hash。无需配置。

3. hash 类型

类型含义推荐
[hash]整次构建同一 hash不推荐(任何文件变全量失效)
[chunkhash]按 chunk(webpack)还行
[contenthash]按文件内容推荐

contenthash 最精确:只有这个文件内容变了它的 hash 才变。

4. 缓存配置

# hash 文件:永久缓存
location ~* \.(js|css|woff2?|png|jpg|svg|avif|webp|ico)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}

# HTML(入口,无 hash):协商缓存
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}

5. 发版流程

1. 构建新版本 → app.abc123.js(新 hash)
2. 上传到 CDN / 服务器(保留旧文件)
3. 刷新 index.html(引用新 hash)
4. 用户下次访问 → 拿到新 HTML → 请求新 JS
5. 旧 JS 仍在服务器(给还缓存旧 HTML 的用户)
6. 7 天后清理旧版本

关键:保留旧版本文件一段时间。CDN 缓存的旧 HTML 引用旧 hash JS,如果旧 JS 删了 = 部分用户白屏。

6. Vendor 拆分

变化慢的依赖(React、lodash)单独 chunk,hash 变化少 → 长期命中缓存:

// vite.config.ts
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
ui: ['@radix-ui/react-dialog', '@radix-ui/react-popover'],
}
}
}
}

7. 动态导入分割

// 按路由分割
const Dashboard = React.lazy(() => import('./pages/Dashboard'))

// Vite / Webpack 自动命名
// → assets/Dashboard-abc123.js

用户只加载当前路由 chunk,其余按需。

8. 验证缓存生效

# 第一次
curl -I https://cdn.example.com/assets/app-abc123.js
# Cache-Control: public, max-age=31536000, immutable
# Status: 200

# 第二次(浏览器有缓存)
# DevTools 显示 (disk cache) / (memory cache)
# 不发请求(immutable)

Chrome DevTools → Network → Size 列看 (disk cache) 即命中强缓存。

9. Service Worker 冲突

SW 可能缓存旧 HTML → 引用旧 JS:

// SW 更新策略
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
)
self.clients.claim()
})

或用 workbox StaleWhileRevalidate 对 HTML。

10. 常见反模式

  • JS 不带 hash + 长缓存:发版后用户永远看不到新版
  • HTML 带 hash:用户怎么知道新 HTML 叫什么名
  • hash 用 [hash]:全量 hash 一个文件变全部缓存失效
  • 发版立即删旧文件:CDN 缓存旧 HTML 的用户白屏
  • immutable 不加:浏览器仍可能发 if-modified-since
  • CDN 按 query string 区分缓存但 hash 在文件名:无意义的 ?t=xxx 不应该加
  • vendor chunk 不拆:业务代码变一行,用户重下 200KB vendor

11. 延伸阅读