前端静态资源版本管理:文件指纹与增量更新

为什么版本管理是缓存的前提

《浏览器缓存完全指南》 里反复提到一句话——“文件名带 hash 的资源可以放心设一年强缓存”

但问题来了:hash 怎么生成?怎么保证改一个文件只更新一个文件?要不要给每个图片也打 hash?CDN 缓存怎么刷新?

这些就是前端静态资源版本管理要回答的问题。

Content Hash:版本管理的基石

什么是 content hash

content hash 是根据文件内容生成的唯一指纹。内容不变,hash 不变;内容变了,hash 必变。

// 构建产物示例
dist/
├── index.html              # 不带 hash
├── assets/
│   ├── main.a3b8f9e7.js    # content hash
│   ├── vendor.c4d5e6f8.js  # content hash
│   └── style.7c2d1e3f.css  # content hash

这样你就可以放心地在 Nginx 上配一年强缓存了——具体怎么配可以看 《Nginx 静态资源缓存配置实战》

Webpack 配置

// webpack.config.js
module.exports = {
  output: {
    filename: 'assets/[name].[contenthash:8].js',
    chunkFilename: 'assets/[name].[contenthash:8].chunk.js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'assets/[name].[contenthash:8].css'
    })
  ]
};

Vite 配置

// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        entryFileNames: 'assets/[name]-[hash:8].js',
        chunkFileNames: 'assets/[name]-[hash:8].js',
        assetFileNames: 'assets/[name]-[hash:8][extname]'
      }
    }
  }
});

代码拆分:不要把所有东西打成一个文件

之前遇到过一个项目,把所有 JS 打进一个 bundle.js改一行文案整个文件 hash 都变了。用户每次更新都得下载全部代码。

正确的做法是按需拆分:

1. 第三方库单独打包(vendor)

// Webpack
splitChunks: {
  cacheGroups: {
    vendor: {
      test: /[\\/]node_modules[\\/]/,
      name: 'vendor',
      chunks: 'all'
    }
  }
}

Vite 默认就帮你做了这个,不用额外配。

2. 路由级别的懒加载

// React
const Dashboard = React.lazy(() => import('./pages/Dashboard'));

// Vue
const Dashboard = () => import('./pages/Dashboard.vue');

每个路由页面打成独立 chunk,用户只访问首页就只下载首页的代码。

3. 业务公共模块

splitChunks: {
  cacheGroups: {
    common: {
      minChunks: 2,
      minSize: 20000,
      name: 'common',
      chunks: 'all'
    }
  }
}

被至少两个页面引用的模块提取到 common.js,避免重复下载。

图片资源也要管理

很多人只给 JS/CSS 加 hash,图片直接扔静态目录。但你想想:

  • 一张 logo.png 换了新图,文件名没变,用户浏览器里还是旧图
  • CDN 缓存不刷新,即使 Nginx 配了过期时间也无济于事

解决方式有两种:

方式一:构建工具自动加 hash

// Webpack
{
  test: /\.(png|jpg|gif|svg)$/,
  type: 'asset',
  generator: {
    filename: 'assets/[name]-[hash:8][ext]'
  }
}

方式二:文件名直接版本号

对于 Logo、ICON 这种极少变动的资源:

logo-v2.png
favicon-v3.ico

manifest:版本映射清单

构建工具可以生成一份 manifest 文件,记录原始路径和带 hash 的路径之间的关系:

{
  "main.js": "assets/main.a3b8f9e7.js",
  "style.css": "assets/style.7c2d1e3f.css",
  "logo.png": "assets/logo-e4f5a6b7.png"
}

服务端渲染时读这个 manifest,自动注入正确的资源路径。

const manifest = require('./dist/manifest.json');

function assetPath(name) {
  return manifest[name] || name;
}

纯静态站点可以用构建插件把资源路径替换掉,比如 Webpack 的 HtmlWebpackPlugin 或 Vite 自身的资源处理。

版本回滚:hash 的好处

有时候上线后发现问题需要回滚,如果你的资源文件名不带 hash,回滚意味着要重新部署旧代码,还要清 CDN 缓存。整个过程可能要好几分钟。

但如果你用了 content hash:

  1. 旧的 hash 文件还留在服务器上
  2. 回滚只需把 index.html 恢复成旧版本
  3. 旧版 HTML 会自动引用旧的 hash 文件
  4. CDN 缓存天然命中(路径没变)

回滚就在一瞬间。

CDN 缓存刷新策略

就算你配好了 hash 和强缓存,上线后 CDN 上可能还存着老版本的资源。两种做法:

策略一:hash 文件不主动刷新

因为 hash 变了就是新路径,CDN 不会有缓存冲突。你只需要刷新 index.html 的 CDN 缓存

# 以阿里云 CDN 为例
aliyun cdn RefreshObjectCaches \
  --ObjectPath "https://example.com/index.html" \
  --ObjectType File

策略二:批量清理 CDN 目录

# 清理整个 /assets/ 目录下的缓存
aliyun cdn RefreshObjectCaches \
  --ObjectPath "https://example.com/assets/" \
  --ObjectType Directory

一个完整的工作流

本地开发 → 构建 → 生成带 hash 的资源
    ↓
上传到 CDN / 服务器
    ↓
部署 index.html(更新资源引用)
    ↓
刷新 index.html 的 CDN 缓存
    ↓
用户请求 → 新 HTML → 引用新 hash 文件 → 缓存命中 ✅

总结

策略 作用 最佳配合
Content Hash 内容不变 hash 不变,实现长期缓存 Nginx immutable 一年强缓存
代码拆分 按需加载,增量更新 路由懒加载 + vendor 分包
Manifest 映射 服务端/构建工具自动匹配路径 SSR 或 SSG 场景
CDN 缓存刷新 更新后让用户立即获取新资源 只刷新 HTML,不刷 hash 资源

做好资源版本管理,你的缓存策略才算真正落地。原理层面想深入了解,推荐看 《浏览器缓存完全指南》;服务器配置层面,《Nginx 静态资源缓存配置实战》 里有完整的配置模板。