跳到主内容

从 3 次事故到零回滚:静态资源灰度发布实战 ​

问题背景:为什么静态资源发版总在深夜出事 ​

前端项目上线看似只是把 dist 目录推到 CDN,但事故往往藏在「HTML 与静态资源版本错配」里。典型场景是:用户在发版前打开了页面,index.html 引用的是 index-a1b2c3.js;发版后旧 JS 被清理或 CDN 缓存失效,异步路由 chunk 404,页面白屏。Vue3 项目的动态 import、VitePress 文档站的按需加载,都会放大这个问题——首屏正常,点到二级页才崩。

传统方案有两种硬伤:全量发布后只能整体回滚,CDN 缓存刷新有延迟;依赖用户强刷,体验完全失控。我们需要一套「按比例放量、出问题秒级回退、终端本地兜底」的灰度体系。

方案对比:四种发布策略的取舍 ​

方案灰度粒度回滚速度改造成本适用场景
---------------
全量覆盖 + CDN 刷新无分钟级,受缓存制约极低内部小工具
多版本目录共存版本级秒级切流量低静态资源为主
CDN 边缘脚本路由用户级秒级中需要按比例灰度
本地存储快照兜底会话级即时中弱网/404 自愈

最终采用「多版本目录 + CDN 版本路由 + localStorage 兜底」三层组合:构建产物按版本号存放,永不覆盖;边缘节点根据灰度规则改写 HTML 中的资源前缀;浏览器侧缓存最近一次可用清单,chunk 加载失败时自动回退。

CI 构建产物cdn/v1.8.0/assetsCDN 边缘路由按 userId 哈希灰度index.html注入版本前缀localStorage 清单chunk 404 自动回退

实操步骤一: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 条经验 ​

  1. 旧版本目录被定时任务清理:现象是发版后老用户刷新直接 404 白屏。修正:生命周期策略至少保留最近 N 个版本,且清理任务必须检查「该版本是否仍被边缘路由引用」,宁可多留不可误删。

  2. HTML 被 CDN 缓存导致灰度不生效:现象是改了灰度比例,用户拿到的资源版本纹丝不动。修正:文档请求强制 cache-control: no-cache 或在边缘执行 ESI 级别的动态改写;只有带哈希的 /app/版本号/assets/* 才开 max-age=31536000, immutable。

  3. localStorage 存了坏清单形成死循环:现象是回退后页面反复跳转。修正:兜底重载必须带版本参数并在服务端校验目标版本存在性;清单写入时机放在「首屏核心 chunk 加载成功后」,而不是页面一打开就写,避免把半成品清单固化。

  4. 灰度按随机数分流导致用户体验漂移:现象是用户刷新一次在新版、再刷回旧版,localStorage 状态错乱。修正:分流键必须稳定(userId 或持久 cookie 哈希),并在边缘与服务端共用同一哈希算法。

  5. 只监控 JS 错误,漏掉 chunk 加载失败:动态 import 的 reject 常被框架吞掉,Sentry 里看不到。修正:必须显式监听 vite:preloadError 与 unhandledrejection,把 fallback 次数接入告警,灰度错误率超过阈值(如 0.5%)自动把边缘路由切回稳定版,实现真正的零事故闭环。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。