官网每次发版后,最常见的几类客诉是:"我看到的还是旧页面""排版和图片全乱了""按钮点了没反应"。
这些问题大多不是新代码没传上去,而是缓存配错了:入口 HTML 和它引用的 JS、CSS 用了同一套缓存策略。本文给一套可以直接落地的发版缓存方案——HTML 每次校验、静态资源带内容指纹永久强缓存、新旧版本并存,附构建配置、nginx 配置和发版检查清单。
先补两条缓存基础。强缓存通过 Cache-Control 的 max-age 控制,命中时浏览器不向服务器发请求,直接用本地副本;协商缓存通过 ETag 或 Last-Modified 校验,服务器返回 304 时复用本地副本。
发版事故基本是下面三种:
关键认知是:HTML 和静态资源应当使用相反的缓存策略。
index.html 是一份"入口清单",它决定加载哪些 JS、CSS,必须尽量拿到最新,因此设为 no-cache,即每次都向服务器协商校验,而不是长期强缓存。
JS、CSS、图片在内容确定后,文件名里带上内容指纹 contenthash,例如 app.3f9a2c1b.js。内容不变,hash 就不变;内容一改,hash 立即变。这类文件可以放心设一年强缓存并加 immutable 标记。
这样发版后,新 HTML 引用新 hash 文件名,旧用户手里的旧 HTML 引用旧 hash 文件名,两套文件同时存在,谁都不会 404,也不会出现新旧混用。这就是缓存破坏(cache busting):不靠通知浏览器"缓存过期",而是用一个全新的文件名让它自然加载新资源。图片、字体等资源同样按内容 hash 命名后纳入长缓存;只有频繁变化、需要被即时发现的入口 HTML 才保持每次协商校验。
入口 HTML 文件名固定为 index.html、不带 hash;JS、CSS 输出名带 contenthash。webpack 可这样配:
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash:8].js',
clean: true
},
module: {
rules: [
{
test: /\.css$/,
use: [
require('mini-css-extract-plugin').loader,
'css-loader'
]
}
]
},
plugins: [
new (require('mini-css-extract-plugin'))({
filename: '[name].[contenthash:8].css'
})
]
};Vite 则在 Rollup 输出项里给文件名加 hash:
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash:8].js',
chunkFileNames: 'assets/[name].[hash:8].js',
assetFileNames: 'assets/[name].[hash:8].[ext]'
}
}
}
});建议配合代码分割把第三方库拆成独立 vendor 包。业务代码常变、第三方库很少变,拆出后 vendor 的 hash 长期稳定,用户发版后只需重新下载变化的业务包,缓存利用率更高。
nginx 对 index.html 单独设协商缓存,对带 hash 的资源目录设一年强缓存:
server {
listen 80;
root /usr/share/nginx/html;
location = /index.html {
add_header Cache-Control "no-cache";
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
}CDN 侧回源时遵循这些响应头:HTML 不缓存或只做短缓存,带 hash 的资源长缓存。因为文件名随内容变化,常规发版不需要对 CDN 做全量刷新。资源统一放在 /assets/ 这类目录下,便于用一条 location 规则统一处理,index.html 单独匹配。
发布要做到原子化。新版本先传到一个全新目录,例如 /release/20260924/,等所有文件传完,再通过软链或重命名一次性切换 index.html 的指向,这一步对文件系统是原子的,不要边传边覆盖线上文件。
旧版本资源要保留一段时间,长度至少覆盖最长在线用户会话加上 HTML 的缓存窗口。手里拿着旧 HTML 的用户在这段时间内仍能加载旧 hash 资源,不会 404。
单页应用还要让路由兜底,任意子路由刷新都回退到 index.html,即上例中的 try_files $uri $uri/ /index.html,否则用户在深层链接处刷新会 404。当前端大改、接口数据结构变化时,后端旧接口保留一个兼容窗口,避免新版页面配上错配接口导致白屏。
页面开了一整天的长会话用户不会自动加载新版。可以做轻量版本检测:定时请求一个 version.json 或重新拉取 index.html,与当前构建版本比较,发现新版后提示"页面已更新,点击刷新",由用户决定,而不是强制 location.reload 打断正在填写的表单。
let currentVersion = __BUILD_VERSION__;
async function checkUpdate() {
try {
const res = await fetch('/version.json?t=' + Date.now());
const data = await res.json();
if (data.version !== currentVersion) {
saveDraftBeforeReload();
promptUserToRefresh();
}
} catch (e) {
// 检测失败保持静默,下一轮再试,不影响当前页面
}
}
setInterval(checkUpdate, 5 * 60 * 1000);触发刷新提示前先保存草稿或本地状态,用户确认后再重载,把打断成本降到最低。
把缓存策略固化进构建和部署模板,而不是每次发版手动调整:构建产物默认带 contenthash,nginx 和 CDN 规则按 HTML 与 /assets 分开,部署脚本固定走"传新目录、原子切换、延迟清理旧目录"三步。
发版流程中加一步预发验证:在预发环境用新 HTML 完整加载一次新资源,确认无 404、控制台无报错,再切换到正式环境。
官网发版后用户还看到旧页面,是什么原因?
通常是 index.html 被强缓存,或 JS、CSS 文件名没变又被缓存。让 HTML 每次协商校验、给资源文件名加内容 hash,发版后新 HTML 会引用新 hash 资源。
带 hash 的静态资源为什么可以缓存一年?
文件名由内容计算得出,内容不变 hash 就不变,内容一改 hash 立即变;缓存旧 hash 文件不影响新版,新 HTML 会加载新文件名。
发版时要不要立刻删旧资源、刷新 CDN?
都不建议。旧资源保留一个会话兼容窗口,避免拿着旧 HTML 的用户 404;带 hash 的新文件会自然加载,不需要全量刷新 CDN。
发版缓存看似是运维细节,实则直接决定用户看到的是新版还是错乱页面。本文为前端工程实践分享,具体配置以项目实际架构和服务器环境为准。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。