前端项目怎么部署?缓存、灰度和回滚怎么做?
一句话回答
构建产物的文件名带内容哈希,内容不变哈希就不变。缓存策略分两类:HTML 用协商缓存(Cache-Control: no-cache),保证每次都能拿到最新的入口;带哈希的 JS、CSS、图片设置一年的强缓存加 immutable。发布时先上传静态资源、再更新 HTML,旧版本的资源保留一段时间。灰度是按用户或比例返回不同版本的 HTML,回滚是把 HTML 切回上一个版本,都不需要重新构建。SPA 还要配置 history 路由的 fallback,并处理发版后旧页面加载 chunk 失败的问题。
详细解析
为什么要用内容哈希
构建工具会根据文件内容生成哈希,写进文件名,例如 index-CFzkvzqM.js(webpack 用 [contenthash],Vite 默认就带哈希)。这样做有两个好处:
- 内容变了文件名一定变,浏览器会把它当作新文件请求,不存在缓存没更新的问题
- 内容没变的文件名保持不变,用户升级版本时只下载变化的部分。把第三方库拆成单独的 chunk,业务代码改动就不会影响它的缓存
缓存策略
| 资源 | 响应头 | 原因 |
|---|---|---|
index.html |
Cache-Control: no-cache |
每次都向服务器确认,内容没变返回 304,变了就拿到新版本 |
| 带哈希的 JS、CSS、字体、图片 | Cache-Control: public, max-age=31536000, immutable |
文件名变了就是新文件,旧文件可以放心缓存一年 |
不带哈希的文件(favicon.ico、robots.txt) |
较短的 max-age 或协商缓存 |
文件名固定,缓存太久就无法更新 |
no-cache 不是"不缓存",而是"使用前必须重新验证";真正禁止缓存的是 no-store。强缓存和协商缓存的原理见 HTTP 缓存。静态资源通常放在 CDN 上,HTML 可以放在源站或者 CDN 上设置很短的缓存,发版后主动刷新(CDN 原理见 CDN)。
发布顺序
- 构建,生成带哈希的静态资源和新的
index.html - 先上传静态资源到 CDN 或静态服务器。文件名都是新的,不会覆盖线上正在使用的文件,此时用户看到的还是旧版本
- 再更新 HTML,新的 HTML 引用的资源都已就位
- 旧版本的静态资源保留一段时间(例如最近几个版本),不要随着发布一起删掉
顺序反过来,就会出现新 HTML 引用的 JS 还没上传、页面白屏的情况;先删掉旧资源,正打开着旧页面的用户在懒加载时就会 404。
灰度和回滚
灰度:每个版本的 HTML 单独保存(例如 releases/v1/index.html、releases/v2/index.html),静态资源统一放在一起,靠哈希区分。网关或 nginx 根据规则决定返回哪个版本的 HTML:
- 按比例:用用户 ID 或设备 ID 的哈希分桶,固定 10% 的用户看到新版本
- 按名单:内部员工、指定地区、带特定 Cookie 的用户先体验
- 同一个用户要稳定落在同一个版本,不能每次刷新都在新旧版本之间跳
观察错误率和关键指标(见 前端监控),没有问题再逐步扩大比例。只想对某个功能做灰度时,可以用功能开关(feature flag),在同一个版本的代码里按用户决定是否启用。
回滚:因为旧版本的 HTML 和资源都还在,回滚只需要把 HTML 的指向切回上一个版本,例如修改软链接或网关配置,几秒钟就能完成,不需要重新构建。用容器部署时,回滚就是切回上一个镜像版本(镜像构建见 多阶段构建)。
history 路由的 fallback
history 模式下,用户直接访问或刷新 /user/42,服务器上并没有这个文件。需要配置成"找不到文件时返回 index.html",再由前端路由渲染对应的页面(原理见 前端路由)。注意 /assets/ 下的文件找不到时要直接返回 404,不能也返回 HTML,否则浏览器会把 HTML 当作 JS 执行,报出难以理解的语法错误。
发版后旧页面加载 chunk 失败
用户在发版前打开了页面,一直没有刷新。发版后他点进一个懒加载的路由,旧代码去请求旧的 chunk:
- 旧资源还在:正常加载,这就是要保留旧版本资源的原因
- 旧资源已被删除:动态导入失败。webpack 抛出
ChunkLoadError,Vite 会在window上触发vite:preloadError事件
兜底做法是捕获这类错误后刷新页面,拿到新的 HTML;要记录刷新时间,防止资源确实出了问题时无限刷新。更主动的做法是定期请求一个 version.json,发现版本变化后提示用户刷新。
代码示例
nginx 配置:HTML 协商缓存、静态资源长期缓存、history fallback,并按 Cookie 分桶做灰度:
# 按用户 ID 的哈希分桶:10% 的用户进入 v2(要求请求带 uid Cookie)
split_clients "${cookie_uid}" $release {
10% v2;
* v1;
}
# 没有 uid Cookie 的请求哈希值都相同,会落进同一个桶,实际使用时要先由网关下发 uid
server {
listen 80;
# 所有版本带哈希的静态资源都放在同一个目录
location /assets/ {
root /var/www/app/shared;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# 每个版本的 index.html 单独存放;回滚时删掉 split_clients 里 v2 那一行再 reload nginx
location / {
root /var/www/app/releases/$release;
add_header Cache-Control "no-cache";
# 找不到文件时内部跳转到 /index.html,会重新匹配这个 location,Cache-Control 照样加上
try_files $uri /index.html;
}
}
Vite 项目中,旧 chunk 加载失败时刷新页面,并防止反复刷新:
window.addEventListener('vite:preloadError', (event) => {
const key = 'chunk-reload-at'
const last = Number(sessionStorage.getItem(key) || 0)
// 10 秒内已经刷新过一次,说明不是版本问题,交给错误处理流程
if (Date.now() - last < 10_000) return
event.preventDefault() // 阻止错误继续抛出
sessionStorage.setItem(key, String(Date.now()))
window.location.reload()
})
面试官可能追问
静态资源已经带了哈希,为什么 HTML 不能也设置强缓存?
HTML 是整个应用的入口,文件名固定,里面写着这次要加载哪些带哈希的资源。HTML 一旦被强缓存,用户在缓存过期前会一直使用旧的入口,引用旧的资源,新版本发布了也看不到,出了问题也无法通过回滚修复。所以 HTML 必须每次都向服务器确认。
同一份构建产物怎么部署到测试、预发、生产多个环境?
如果把接口地址这类配置在构建时写死,每个环境就得单独构建,测试过的产物和上线的产物不是同一份。更好的做法是"构建一次,到处部署":配置放在运行时加载的 config.json 里,或者在部署时注入到 HTML 的一个全局变量中,产物本身和环境无关。
灰度的时候,新旧版本的接口不兼容怎么办?
灰度期间新旧前端会同时访问后端,后端接口必须同时兼容两个版本:先发布兼容新旧两种调用方式的后端,再灰度前端,等旧版前端完全下线后再清理旧的接口逻辑。新增字段只加不删,改动字段含义时用新的字段名,就是为了让这个过程平滑。
易错点
- 给
index.html设置了长时间的强缓存,或者 CDN 上的 HTML 没有在发版后刷新,用户迟迟看不到新版本 - 每次发布都清空静态资源目录,打开着旧页面的用户懒加载时报 404
- history fallback 对所有路径都返回
index.html,资源 404 变成了 JS 语法错误,问题被掩盖 - 把
no-cache理解成不缓存,把禁止缓存的no-store用在了静态资源上
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。