外观
从 3 次事故到零回滚:静态资源灰度发布实战
问题背景:为什么静态资源发版总在深夜出事
前端项目上线看似只是把 dist 目录推到 CDN,但事故往往藏在「HTML 与静态资源版本错配」里。典型场景是:用户在发版前打开了页面,index.html 引用的是 index-a1b2c3.js;发版后旧 JS 被清理或 CDN 缓存失效,异步路由 chunk 404,页面白屏。Vue3 项目的动态 import、VitePress 文档站的按需加载,都会放大这个问题——首屏正常,点到二级页才崩。
传统方案有两种硬伤:全量发布后只能整体回滚,CDN 缓存刷新有延迟;依赖用户强刷,体验完全失控。我们需要一套「按比例放量、出问题秒级回退、终端本地兜底」的灰度体系。
方案对比:四种发布策略的取舍
| 方案 | 灰度粒度 | 回滚速度 | 改造成本 | 适用场景 |
|---|---|---|---|---|
| --- | --- | --- | --- | --- |
| 全量覆盖 + CDN 刷新 | 无 | 分钟级,受缓存制约 | 极低 | 内部小工具 |
| 多版本目录共存 | 版本级 | 秒级切流量 | 低 | 静态资源为主 |
| CDN 边缘脚本路由 | 用户级 | 秒级 | 中 | 需要按比例灰度 |
| 本地存储快照兜底 | 会话级 | 即时 | 中 | 弱网/404 自愈 |
最终采用「多版本目录 + CDN 版本路由 + localStorage 兜底」三层组合:构建产物按版本号存放,永不覆盖;边缘节点根据灰度规则改写 HTML 中的资源前缀;浏览器侧缓存最近一次可用清单,chunk 加载失败时自动回退。
实操步骤一:Vite 多版本产物与清单生成
核心思路是让每次构建输出独立目录,并生成一份资源清单供运行时查询。修改 vite.config.ts:
typescript
import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\nimport { writeFileSync } from 'node:fs'
// 版本号由 CI 注入,例如 20240612-1530-a1b2c3
const VERSION = process.env.BUILD_VERSION || 'dev'
export default defineConfig({
plugins: [
vue(),
{
name: 'emit-version-manifest',
apply: 'build',
closeBundle() {
// 写入版本清单,HTML 与运行时都会读取
writeFileSync(
'dist/version.json',
JSON.stringify({
version: VERSION,
cdnBase: `https://cdn.example.com/app/${VERSION}/`,
// 上一个稳定版本,用于本地兜底
fallbackVersion: process.env.STABLE_VERSION || VERSION,
builtAt: Date.now()
})
)
}
}
],
base: `https://cdn.example.com/app/${VERSION}/`,
build: {
// 产物带内容哈希,配合长期强缓存
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]'
}
}
}
})CI 中只上传不删除,保留最近 10 个版本目录。这样旧 HTML 引用的任何 chunk 都能继续访问,从根上消灭「发版即 404」。
实操步骤二:CDN 边缘路由做按比例灰度
在 CDN 边缘函数(以 EdgeWorkers/Cloudflare Workers 为例)中根据用户标识决定版本。HTML 必须设置为 no-cache 或短缓存,静态资源则永久强缓存:
javascript
// edge-router.js:边缘节点拦截 HTML 请求
const STABLE = 'v1.7.0' // 当前稳定版
const CANARY = 'v1.8.0' // 灰度版本
async function handleRequest(request) {
const url = new URL(request.url)
// 只对文档入口做路由,静态资源走版本目录直连
if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html')) {
return fetch(request)
}
// 灰度身份:登录用户用 userId,匿名用户种灰度 cookie
const cookie = request.headers.get('cookie') || ''
const userId = (cookie.match(/uid=([^;]+)/) || [])[1] || seedAnonymous(cookie)
// 哈希取模,保证同一用户始终命中同一版本
const hash = [...userId].reduce((s, c) => (s * 31 + c.charCodeAt(0)) >>> 0, 7)
const inCanary = hash % 100 < 10 // 10% 流量
const version = inCanary ? CANARY : STABLE
const res = await fetch(request)
const html = await res.text()
// 替换资源前缀,并把版本号注入 window,供运行时兜底
const injected = html
.replaceAll('/app/__CANARY__/', `/app/${version}/`)
.replace('<head>', `<head><script>window.__APP_VERSION__='${version}'</script>`)
return new Response(injected, {
headers: { 'content-type': 'text/html;charset=utf-8', 'cache-control': 'no-cache' }
})
}回滚时只需把配置中心的 STABLE 指回旧版本,边缘节点秒级生效,无需重新构建或刷新 CDN。
实操步骤三:本地存储兜底实现 chunk 自愈
即使版本目录共存,用户在灰度过程中跨版本刷新仍可能拿到新旧混合的 chunk。利用 Vite 的 import.meta.env 与运行时错误捕获,加载失败时回退到清单中的稳定版本:
typescript
// bootstrap.ts:应用启动入口
interface VersionManifest {
version: string
cdnBase: string
fallbackVersion: string
}
const MANIFEST_KEY = 'app_version_manifest'
// 保存最近一次成功加载的清单
export function saveManifest(manifest: VersionManifest) {
try {
localStorage.setItem(MANIFEST_KEY, JSON.stringify(manifest))
} catch {
// 隐私模式或配额超限时静默降级
}
}
function getLastManifest(): VersionManifest | null {
try {
return JSON.parse(localStorage.getItem(MANIFEST_KEY) || 'null')
} catch {
return null
}
}
// 拦截动态 chunk 加载失败,自动重载到稳定版本
let retrying = false
window.addEventListener('vite:preloadError', async () => {
if (retrying) return
retrying = true
const last = getLastManifest()
const current = window.__APP_VERSION__
// 当前版本与上次可用版本不同,说明正处于发版窗口
if (last && last.version !== current) {
// 上报灰度异常,用于自动暂停灰度
navigator.sendBeacon?.('/api/report', JSON.stringify({
type: 'chunk_fallback', from: current, to: last.version
}))
// 用稳定版本前缀重载整页,资源均为长期缓存
location.replace(`/?_v=${last.fallbackVersion}`)
} else {
location.reload()
}
})
// Vue Router 异步路由也包一层兜底
import type { RouteRecordRaw } from 'vue-router'\nexport function safeImport<T>(loader: () => Promise<T>) {
return async (): Promise<T> => {
try {
return await loader()
} catch (err) {
window.dispatchEvent(new Event('vite:preloadError'))
throw err
}
}
}
export const routes: RouteRecordRaw[] = [
{ path: '/dashboard', component: safeImport(() => import('./views/Dashboard.vue')) }
]避坑总结:来自线上事故的 5 条经验
旧版本目录被定时任务清理:现象是发版后老用户刷新直接 404 白屏。修正:生命周期策略至少保留最近 N 个版本,且清理任务必须检查「该版本是否仍被边缘路由引用」,宁可多留不可误删。
HTML 被 CDN 缓存导致灰度不生效:现象是改了灰度比例,用户拿到的资源版本纹丝不动。修正:文档请求强制
cache-control: no-cache或在边缘执行 ESI 级别的动态改写;只有带哈希的/app/版本号/assets/*才开max-age=31536000, immutable。localStorage 存了坏清单形成死循环:现象是回退后页面反复跳转。修正:兜底重载必须带版本参数并在服务端校验目标版本存在性;清单写入时机放在「首屏核心 chunk 加载成功后」,而不是页面一打开就写,避免把半成品清单固化。
灰度按随机数分流导致用户体验漂移:现象是用户刷新一次在新版、再刷回旧版,localStorage 状态错乱。修正:分流键必须稳定(userId 或持久 cookie 哈希),并在边缘与服务端共用同一哈希算法。
只监控 JS 错误,漏掉 chunk 加载失败:动态 import 的 reject 常被框架吞掉,Sentry 里看不到。修正:必须显式监听
vite:preloadError与unhandledrejection,把 fallback 次数接入告警,灰度错误率超过阈值(如 0.5%)自动把边缘路由切回稳定版,实现真正的零事故闭环。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。