Vue 基础体系 · 第 23/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 生产交付:环境配置、静态资源、缓存、灰度和回滚

Vue 应用的“生产交付”不是执行一次 vite build 就结束。浏览器最终拿到的是一组相互依赖的产物:HTML 决定入口模块,入口模块再引用带哈希的 JavaScript 和 CSS,应用运行期间还会请求 API、图片、字体以及可能存在的 Service Worker。只要其中一个环节的版本、缓存策略或路由规则不一致,就可能出现白屏、旧代码请求新接口、灰度用户加载到非灰度资源,甚至回滚后仍然无法恢复。

本文以 Vue 3、Composition API、TypeScript 和现代 Vite 工具链为例,建立一条从配置、构建、静态资源发布到灰度和回滚的完整链路。


一、先定义交付对象:浏览器实际消费什么

一次前端发布通常包含四类对象:

  1. HTML 入口:例如 index.html
  2. 构建产物:JavaScript、CSS、字体、图片等。
  3. 运行时配置:API 地址、监控地址、功能开关等。
  4. 服务端路由与缓存策略:静态文件如何返回、SPA 路由如何回退、哪些响应可以缓存。

它们的生命周期不同:

  • HTML 经常需要较快看到新版本;
  • 带内容哈希的 JS/CSS 可以长期缓存;
  • API 数据通常由 API 自己决定缓存;
  • 环境变量可能在构建时写入,也可能在容器启动时注入。

因此,生产交付的核心不是“把文件上传”,而是维护以下不变量:

HTML 版本它引用的静态资源必须存在且可访问\text{HTML 版本} \Rightarrow \text{它引用的静态资源必须存在且可访问}

若 HTML 引用 /assets/index-A1B2.js,发布系统就不能在 HTML 仍可能被浏览器或 CDN 读取期间删除这个文件。否则即使新 HTML 正确,用户也会因为缓存中的旧 HTML 请求已经被删除的旧资源而白屏。


二、环境配置:构建时变量和运行时配置是两种机制

1. Vite 环境变量的真实语义

Vite 会把环境变量以 import.meta.env 的形式暴露给前端代码:

const apiBaseUrl = import.meta.env.VITE_API_BASE_URL

这里的 VITE_API_BASE_URL 并不是浏览器在运行时从操作系统读取的变量。对于通常的 Vite 构建,它会在构建阶段被替换进产物。

例如:

VITE_API_BASE_URL=https://api.example.com npm run build

构建后,产物中会包含这个地址。用户打开网页时,服务器不会再读取构建机器上的环境变量。

因此:

const token = import.meta.env.VITE_TOKEN

并不能安全地保存 Token、数据库密码或私钥。任何以 VITE_ 前缀暴露的变量都应当视为公开信息,用户可以通过浏览器开发者工具、网络请求或 JavaScript 产物读取它。

2. .env 文件和 mode

一个项目可以按 mode 管理配置:

.env
.env.local
.env.staging
.env.staging.local
.env.production
.env.production.local

常见加载关系是:通用文件先加载,指定 mode 的文件覆盖通用文件;.local 文件通常用于本机私有配置,不应提交到版本库。

示例:

# .env
VITE_APP_NAME=WR Blog
VITE_API_BASE_URL=/api

# .env.staging
VITE_API_BASE_URL=https://staging-api.example.com

# .env.production
VITE_API_BASE_URL=https://api.example.com

构建命令:

{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "build:staging": "vue-tsc --noEmit && vite build --mode staging",
    "preview": "vite preview"
  }
}

执行:

npm run build:staging

等价于使用 staging mode 构建。vite build 默认使用 production mode;vite 开发服务器默认使用 development mode。具体变量加载细节属于 Vite 的配置行为,升级 Vite 时应以当前版本文档为准。

为了让 TypeScript 识别自定义变量,可以创建:

// src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_BASE_URL: string
  readonly VITE_APP_NAME: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

使用时应对关键配置做启动校验,而不是让错误地址一路传播:

// src/config/env.ts
function requiredEnv(name: keyof ImportMetaEnv): string {
  const value = import.meta.env[name]

  if (!value || value.trim() === '') {
    throw new Error(`Missing required environment variable: ${name}`)
  }

  return value
}

export const appConfig = {
  appName: requiredEnv('VITE_APP_NAME'),
  apiBaseUrl: requiredEnv('VITE_API_BASE_URL'),
}

如果生产构建漏了变量,应用会在启动阶段尽早失败,而不是等用户点击某个页面后才出现“网络错误”。这提高了可诊断性,但也意味着部署检查必须捕获启动错误。

3. 构建时配置与运行时配置

两者的区别可以形式化为:

构建时配置:
源代码 + 构建环境变量 -> 静态产物 -> 浏览器

运行时配置:
同一份静态产物 + 部署环境配置 -> 浏览器运行

如果每个环境都重新构建:

commit X + staging env -> staging artifact
commit X + production env -> production artifact

两个环境的产物可能不同,难以证明“生产测试的就是最终发布内容”。

如果使用同一份产物:

commit X -> artifact-X
artifact-X + staging runtime config
artifact-X + production runtime config

就可以把构建和部署解耦。不过,Vite 默认并不会自动提供一个生产运行时配置文件,需要自行设计。

一种简单方案是在静态目录放置 config.js

<!-- index.html -->
<script src="/config.js"></script>
<script type="module" src="/src/main.ts"></script>

生产环境生成:

// public/config.js
window.__APP_CONFIG__ = {
  apiBaseUrl: "https://api.example.com",
  releaseId: "2025-03-08-001"
}

TypeScript 类型声明:

// src/types/runtime-config.d.ts
export {}

declare global {
  interface Window {
    __APP_CONFIG__: {
      apiBaseUrl: string
      releaseId: string
    }
  }
}

应用读取:

export const appConfig = {
  apiBaseUrl: window.__APP_CONFIG__.apiBaseUrl,
  releaseId: window.__APP_CONFIG__.releaseId,
}

这个文件必须在 HTML 之前或至少在应用入口执行前加载。它不能包含秘密,因为它同样会发送给浏览器。若 config.js 被 CDN 长时间缓存,修改运行时配置后用户仍可能得到旧值,所以它通常使用 Cache-Control: no-cache 或较短缓存时间。

运行时配置的风险是“配置文件本身也成为发布依赖”。必须验证:

  • config.js 是否存在;
  • JSON 或 JavaScript 是否可解析;
  • API 地址是否符合允许的协议和域名;
  • releaseId 是否与当前发布记录一致。

4. 开发代理不是生产代理

vite.config.ts 中的代理主要服务于本地开发:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
})

浏览器请求 /api/users,开发服务器将请求转发到后端。这解决的是本地开发时的跨域和地址统一问题。

执行 vite build 后,server.proxy 不会变成生产代理。生产环境必须由 Nginx、网关、云负载均衡或后端服务自己配置 /api 转发。把开发代理误认为生产代理,是“本地正常、上线 404”的常见原因。


三、静态资源:导入资源和 public 资源的差异

1. 由模块图管理的资源

推荐在源码中导入图片、CSS 和字体:

<script setup lang="ts">
import logoUrl from '@/assets/logo.svg'
</script>

<template>
  <img :src="logoUrl" alt="WR Blog" />
</template>

或者:

import workerUrl from './worker?url'

Vite 会分析这些导入,复制资源到构建目录,并为生产资源生成可用于缓存的文件名。最终路径可能类似:

/assets/index-C8f3k2.js
/assets/logo-4a91d0.svg

哈希的意义是内容寻址:内容改变,文件名通常改变;内容不改变,文件名保持不变。于是可以安全地对这类文件设置长期缓存。

2. public 目录的资源

public 目录中的文件会原样复制到构建输出目录,引用时使用根路径:

public/
  favicon.ico
  robots.txt
  config.js

引用:

<link rel="icon" href="/favicon.ico">

或:

const url = '/robots.txt'

public 适合必须保持固定 URL 的文件,例如:

  • favicon.ico
  • robots.txt
  • 第三方验证文件
  • 由外部系统约定固定路径的文件

public 资源通常不会自动添加内容哈希。若把经常变化的图片放在 public/banner.png,长期缓存会造成旧内容持续存在;若把它设置为不缓存,则失去静态资源缓存收益。

3. base 决定资源根路径

如果应用部署在域名根路径:

https://example.com/

默认的 / 通常符合预期。

如果应用部署在:

https://example.com/blog/

需要设置:

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  base: '/blog/',
  plugins: [vue()],
})

否则 HTML 可能请求 /assets/index.js,实际文件却位于 /blog/assets/index.js

也可以使用相对路径配置,但它对 HTML 的访问路径、路由层级和静态服务器行为更敏感。生产系统应明确验证:

curl -I https://example.com/blog/
curl -I https://example.com/blog/assets/index-xxxx.js

4. SPA 路由回退不是静态文件回退

Vue Router 使用 history 模式时,访问:

/dashboard

服务器收到的是对 /dashboard 的请求。如果服务器只按文件系统查找,通常得到 404,因为不存在名为 dashboard 的文件。

生产服务器需要实现:

请求 /assets/app-hash.js -> 返回真实静态文件
请求 /favicon.ico         -> 返回真实文件
其他前端路由请求           -> 返回 index.html

不能把所有请求都回退到 index.html,否则静态资源拼写错误也会返回 HTML,浏览器会报:

Failed to load module script:
Expected a JavaScript module script but the server responded with a MIME type of "text/html"

Nginx 的一个基本示例:

server {
    listen 80;
    server_name example.com;

    root /srv/www/wr-blog/current;
    index index.html;

    location /assets/ {
        try_files $uri =404;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    location = /config.js {
        try_files $uri =404;
        add_header Cache-Control "no-cache";
    }

    location /api/ {
        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    location / {
        try_files $uri $uri/ /index.html;
        add_header Cache-Control "no-cache";
    }
}

这里的关键顺序是:/assets/ 先执行 try_files $uri =404,避免不存在的资源被 SPA 回退;其他路径才回退到 index.html


四、缓存:按内容类型设计,而不是给整个站点设置一个过期时间

1. HTTP 缓存的几个关键字段

Cache-Control 描述客户端和中间缓存如何处理响应。常用指令包括:

  • no-cache:可以存储,但使用前必须向服务器重新验证;
  • no-store:不应存储;
  • max-age=秒数:在指定时间内可直接使用;
  • immutable:告诉客户端内容在缓存期内不会变化;
  • public:允许共享缓存,例如 CDN;
  • private:只允许浏览器私有缓存。

no-cache 不等于“不缓存”。如果 HTML 设置:

Cache-Control: no-cache
ETag: "release-001"

浏览器可以保存 HTML,但下次会带上 If-None-Match;如果内容没变,服务器返回 304 Not Modified

2. 典型缓存分层

推荐将资源分成三类:

对象 典型策略 原因
带内容哈希的 JS/CSS/图片 public, max-age=31536000, immutable 文件名变化代表内容变化
index.htmlconfig.js no-cache 或短缓存 它们决定用户进入哪个版本
API 响应 按业务设置 数据新鲜度和一致性要求不同

对于哈希资源:

Cache-Control: public, max-age=31536000, immutable

客户端第一次下载后,一年内通常不会重新验证。因为新版本会引用新文件名,例如:

index-A.js -> index-B.js

因此长期缓存不会阻止新版本生效。

对于 HTML:

Cache-Control: no-cache

这样浏览器会快速验证是否有新版本。若 CDN 配置了很长的 TTL,却没有正确处理重新验证,用户可能长时间拿到旧 HTML。

3. 为什么不能只替换文件

假设版本 v1 的 HTML 引用:

/assets/app-v1.js

发布 v2 时,如果部署脚本先删除 app-v1.js,再上传 v2,会出现如下故障:

  1. 用户浏览器缓存中的 index.html 仍是 v1
  2. 浏览器请求 /assets/app-v1.js
  3. 服务器返回 404;
  4. Vue 应用无法启动,表现为白屏。

这就是“旧 HTML + 删除旧资源”的不兼容组合。

安全部署应满足:

先上传新资源
再切换 HTML 或 current 软链接
保留旧资源一段时间
最后再进行垃圾回收

一种目录发布方式:

set -eu

RELEASE_ID="2025-03-08-001"
RELEASE_DIR="/srv/releases/$RELEASE_ID"

npm ci
npm run build

mkdir -p "$RELEASE_DIR"
cp -r dist/. "$RELEASE_DIR/"

# 先验证产物存在
test -f "$RELEASE_DIR/index.html"
test -d "$RELEASE_DIR/assets"

# 原子切换 current 指向
ln -sfn "$RELEASE_DIR" /srv/www/wr-blog/current

ln -sfn 的具体行为受系统和目标类型影响,生产脚本应在目标环境验证。更稳妥的方式是生成新软链接后使用原子 rename,并让 Web 服务器的 root 指向稳定的 current 路径。

切换完成后,不应立即删除旧目录。至少要覆盖:

  • 浏览器缓存中旧 HTML 的存活时间;
  • CDN 缓存传播时间;
  • 用户长时间打开页面后再次加载资源的时间;
  • 灰度和回滚窗口。

4. Service Worker 会改变缓存模型

如果项目注册了 Service Worker,普通 HTTP 缓存策略不再是唯一决定因素。Service Worker 可能:

  • 拦截导航请求;
  • 缓存旧 HTML;
  • 预缓存旧资源;
  • 延迟新 Service Worker 激活;
  • 在多个标签页之间保持旧控制器。

因此,部署后“清了 CDN 但用户仍是旧版本”不一定是 CDN 问题,也可能是 Service Worker 缓存。

升级 Service Worker 时需要明确:

  1. 新 worker 何时安装;
  2. 旧 worker 何时激活;
  3. 是否调用 skipWaiting
  4. 是否调用 clientsClaim
  5. 页面如何通知用户刷新;
  6. 如何清理旧缓存键。

强制立即接管可能让同一页面中已有的代码与新缓存资源混用,带来运行时不一致。除非能够验证兼容性,否则“提示用户刷新”通常比静默强制刷新更容易控制。


五、发布验证:验证文件、响应和运行时,而不是只看 CI 绿色

构建成功只说明编译和打包阶段完成,不说明生产服务正确。

1. 构建阶段验证

npm ci
npm run build

npm ci 依据锁文件安装依赖,适合可重复构建;它通常会清理现有 node_modules,因此不应把本地未提交的依赖状态当作构建依据。

构建后检查:

find dist -maxdepth 2 -type f | sort
grep -oE '/assets/[^"]+' dist/index.html

需要确认:

  • dist/index.html 存在;
  • HTML 中引用的每个 JS/CSS 文件都存在;
  • 没有把本地开发地址写入产物;
  • 资源 URL 与 base 一致;
  • source map 是否按安全策略发布。

Source map 有助于错误监控还原源码,但可能暴露源码结构和注释。可以选择不公开 .map,或上传到只允许监控系统访问的存储,而不是直接放在公共静态目录。

2. HTTP 验证

curl -I https://example.com/
curl -I https://example.com/assets/index-A1B2.js
curl -I https://example.com/does-not-exist.js
curl -I https://example.com/dashboard

预期结果:

  • / 返回 200Content-Type 为 HTML;
  • 哈希 JS 返回 200Content-Type 为 JavaScript MIME 类型;
  • 不存在的 JS 返回 404,而不是 index.html
  • /dashboard 返回 index.html,由 Vue Router 接管;
  • 生产 HTML 的缓存策略符合发布设计。

如果 /assets/does-not-exist.js 返回 200 text/html,说明 SPA 回退规则覆盖了静态资源错误。这个问题必须修复,否则浏览器会把 HTML 当成 JavaScript 加载并失败。

3. 运行时版本标识

在 HTML 或运行时配置中提供发布标识:

console.info('[release]', appConfig.releaseId)

也可以在请求头中携带:

fetch(`${appConfig.apiBaseUrl}/health`, {
  headers: {
    'X-Frontend-Release': appConfig.releaseId,
  },
})

这样可以把前端错误、API 日志、灰度批次和发布记录关联起来。版本标识不是安全认证机制,不能代替鉴权。


六、灰度发布:控制的是用户集合与版本路由

1. 灰度的定义

灰度发布是让一部分用户先使用新版本,并根据指标决定是否扩大范围。设全部用户集合为 UU,新版本用户集合为 CC,则:

CUC \subseteq U

灰度比例为:

p=CUp = \frac{|C|}{|U|}

但实际系统不一定能精确按用户数分配,因为请求会经过 CDN、负载均衡和多标签页。更重要的是,灰度需要保证同一用户在一段时间内尽量稳定地命中同一版本,这称为粘性

可以使用用户 ID 或稳定 Cookie 做哈希:

bucket=hash(userId)mod100bucket = hash(userId) \bmod 100

bucket < 5 时进入 5% 灰度。相同用户的哈希稳定,因此不会在每次请求间随机切换。

2. 前端灰度的两种主要方式

方式一:边缘或网关选择完整静态站点

用户请求 /
  -> 网关读取用户 Cookie 或用户 ID
  -> 选择 stable 或 canary 目录
  -> 返回对应版本的 index.html

此时必须保证 HTML 和它引用的资源属于同一版本。不能让 v1 的 HTML 经过某个 CDN 缓存后,又让资源请求被路由到 v2 的私有目录而找不到文件。

常见解决方案:

  • 两个版本的资源都放在全局可访问的内容寻址路径;
  • HTML 使用版本目录,例如 /releases/v2/assets/...
  • 网关对 HTML 做灰度,对带哈希资源直接按文件路径提供;
  • 在切换期间保留所有仍被 HTML 引用的旧资源。

方式二:静态前端相同,通过 API 或运行时开关灰度

所有用户加载同一份前端产物,应用启动后由后端返回功能开关:

type FeatureConfig = {
  newEditor: boolean
}

const featureConfig = ref<FeatureConfig | null>(null)

async function loadFeatureConfig() {
  const response = await fetch(`${appConfig.apiBaseUrl}/feature-config`, {
    credentials: 'include',
  })

  if (!response.ok) {
    throw new Error(`Feature config failed: ${response.status}`)
  }

  featureConfig.value = await response.json()
}

这种方式适合功能级灰度,但它不是完整的“前端版本灰度”:所有用户仍然运行同一份 JavaScript。若新功能涉及路由、依赖或大规模代码变更,仍需要完整版本灰度。

3. 灰度中的缓存陷阱

如果灰度依据 Cookie:

Cookie: frontend_cohort=canary

而 CDN 缓存 HTML 时没有按 Cookie 区分缓存键,可能发生:

  1. 第一个灰度用户请求 /
  2. CDN 缓存了 canary HTML;
  3. 普通用户请求 /
  4. 普通用户也收到 canary HTML。

因此,灰度 HTML 必须满足以下条件之一:

  • CDN 不缓存 HTML;
  • CDN 缓存键包含灰度标识;
  • 网关在 CDN 之前完成分流;
  • 使用不同域名或路径,例如 canary.example.com

资源本身通常使用内容哈希,不需要按用户分流;但它们必须在所有目标节点都可访问。

4. 灰度不是只看错误率

前端灰度至少要观察:

  • JavaScript 运行时异常;
  • 资源加载失败率;
  • 路由失败率;
  • API 4xx/5xx;
  • 首屏和交互性能指标;
  • 关键业务流程成功率;
  • 与后端版本的兼容性错误。

灰度扩大可以是:

1% -> 5% -> 25% -> 50% -> 100%

每一步都应有明确的观察窗口和停止条件。例如,资源 404 突增通常比业务转化率下降更适合立即停止,因为它可能意味着发布结构错误。


七、前后端兼容:回滚前端不等于回滚整个系统

前端版本和后端 API 经常不是完全同步发布。设:

  • F1F_1:旧前端;
  • F2F_2:新前端;
  • B1B_1:旧后端;
  • B2B_2:新后端。

安全发布通常要求至少满足:

F1B1,F1B2,F2B2F_1 \leftrightarrow B_1,\quad F_1 \leftrightarrow B_2,\quad F_2 \leftrightarrow B_2

如果后端先升级,必须仍支持旧前端请求;否则用户浏览器中的旧 HTML 可能持续运行数小时甚至更久,导致 API 失败。

典型不兼容反例:

  • 后端删除了旧字段,旧前端仍直接读取该字段;
  • 后端把字符串改成数字,旧前端的比较逻辑改变;
  • 后端强制要求新请求头,旧前端不会发送;
  • 后端删除旧 API 路径,但旧 HTML 仍在缓存中。

更安全的接口演进方式是:

  1. 后端先增加新字段或新接口;
  2. 前端新版本开始使用;
  3. 观察旧版本是否仍正常;
  4. 等旧前端不再活跃后,再删除旧协议。

“数据库迁移和前端发布同时回滚”也存在风险。数据库通常应该采用向后兼容的扩展式迁移:

增加新列 -> 新旧代码都可运行 -> 回填数据 -> 切换读取 -> 最后删除旧列

直接删除列或改变字段语义,会让应用回滚无法恢复。


八、回滚:回到已知可用的不可变产物

1. 回滚的目标

回滚不是“重新执行上一次构建”,而是将流量重新指向一个已经构建、验证并记录过的版本:

current -> release-2025-03-08-002

发现问题后:

current -> release-2025-03-08-001

这要求每次发布都保留:

  • Git commit;
  • 依赖锁文件;
  • 构建日志;
  • 构建参数;
  • 产物校验和;
  • 发布人和时间;
  • 运行时配置版本;
  • 灰度批次。

2. 一个可执行的目录发布示例

假设目录结构为:

/srv/releases/
  2025-03-08-001/
  2025-03-08-002/

/srv/www/wr-blog/current -> /srv/releases/2025-03-08-002

查看当前版本:

readlink -f /srv/www/wr-blog/current

切回旧版本:

ln -sfn /srv/releases/2025-03-08-001 /srv/www/wr-blog/current

然后验证:

curl -fsS https://example.com/ \
  | grep -q 'release-2025-03-08-001'

如果版本标识位于 config.js,则应检查:

curl -fsS https://example.com/config.js

回滚后还要检查浏览器实际加载的资源。因为浏览器可能仍缓存了新 HTML,或者 CDN 仍持有新 HTML。仅切换源站目录并不保证所有用户立即得到旧页面。

3. 回滚的缓存恢复路径

一种可靠顺序是:

  1. 先确保旧版本目录和旧哈希资源仍存在;
  2. 将流量或 current 指向旧版本;
  3. 让 HTML 重新验证,必要时清理 HTML 的 CDN 缓存;
  4. 验证旧 HTML 引用的资源全部返回 200
  5. 检查 API 兼容性;
  6. 观察错误率和关键流程;
  7. 稳定后再分析新版本问题。

不要先删除新版本资源再回滚。原因是某些用户、Service Worker 或缓存节点仍可能引用新版本资源;保留资源通常比立即回收少占用一部分磁盘更重要。

4. 回滚失败的典型原因

旧产物被删除

旧 HTML 还在缓存中,但旧 JS 已经被清理,产生资源 404。解决办法是延长产物保留时间,或把资源放入带版本前缀的永久存储。

HTML 和资源来自不同版本

HTML 由一个节点返回,资源由另一个节点返回,而两个节点发布不同步。解决办法是使用不可变发布目录、原子切换或统一的发布同步机制。

CDN 没有清理或缓存键错误

源站已回滚,CDN 仍返回新 HTML。应区分 HTML、哈希资源和 API 的缓存策略,不要使用“清空所有缓存”作为唯一恢复手段。

后端已经完成破坏性变更

前端虽然回滚,但旧版本请求的 API 或数据库结构已经不存在。此时必须单独恢复兼容接口或回滚后端,说明前后端发布没有遵循兼容演进。

Service Worker 继续提供新版本

需要检查:

Application -> Service Workers
Application -> Cache Storage

并确认当前控制器、缓存名称和激活时间。不能仅凭普通浏览器刷新判断 Service Worker 已更新。


九、一个完整的交付时序

下面的时序展示了从构建到灰度、扩大和回滚的依赖关系:

sequenceDiagram
    participant CI as CI 构建系统
    participant Store as 产物存储
    participant Edge as CDN/网关
    participant Browser as 浏览器
    participant API as 后端 API
    participant Obs as 监控系统

    CI->>CI: npm ci
    CI->>CI: vue-tsc --noEmit && vite build
    CI->>Store: 上传不可变 release-002
    CI->>Store: 校验 index.html 和所有 assets
    Edge->>Store: 配置 release-002 为 canary
    Browser->>Edge: 请求 index.html
    Edge-->>Browser: 按用户 cohort 返回版本
    Browser->>Edge: 请求带哈希 JS/CSS
    Edge-->>Browser: 返回对应资源
    Browser->>API: 请求兼容接口
    API-->>Browser: 返回数据
    Browser->>Obs: 上报 releaseId、错误和性能
    Obs-->>Edge: 灰度指标正常
    Edge->>Store: 扩大 release-002 流量
    Obs-->>Edge: 错误异常
    Edge->>Store: 将流量切回 release-001

关键路径有三个:

  1. 产物先存在:网关不能指向尚未完整上传的版本;
  2. 版本内资源闭合:HTML 引用的资源必须来自可访问的版本集合;
  3. 监控参与决策:灰度扩大和回滚不能依赖人工猜测。

十、常见误解与诊断方法

误解一:.env.production 中的密码不会进入前端

错误。只要变量通过 import.meta.env 使用并被暴露到客户端,它就是公开数据。真正的秘密必须留在服务端或密钥管理系统中。

诊断方法:

grep -R "password\|secret\|token" dist

这不是完整的安全扫描,但可以快速发现明显泄漏。更可靠的做法是构建后对产物进行自动化敏感信息检测。

误解二:设置 no-cache 就不会缓存

错误。no-cache 允许存储,但要求使用前重新验证。若目标是禁止存储,应使用 no-store。HTML 通常需要可重新验证,而不是绝对禁止存储。

误解三:文件名带哈希后,旧文件可以立即删除

错误。旧 HTML、浏览器缓存、Service Worker、离线标签页和 CDN 节点都可能仍引用旧文件。哈希资源可以长期缓存,但也意味着旧文件必须在兼容窗口内继续存在。

误解四:Vite 开发代理上线后仍然有效

错误。Vite 的 server.proxy 只作用于开发服务器。生产代理必须在实际的 Web 服务器或网关上配置。

误解五:灰度只要按请求随机百分比即可

不可靠。用户可能在一次页面加载中分别命中两个版本,导致 HTML、资源或 API 行为不一致。应使用稳定用户标识、Cookie 或网关级粘性策略,并明确 CDN 缓存键。

误解六:回滚只需要切换前端目录

不完整。还要检查 HTML 缓存、静态资源保留、API 兼容、运行时配置、Service Worker 和监控指标。回滚是一个系统状态转换,而不是一个文件复制动作。


十一、交付设计的最小闭环

一个可恢复的 Vue 生产交付系统至少应具备以下闭环:

源代码与锁文件
    -> 可重复构建
    -> 带 releaseId 的不可变产物
    -> 产物完整性验证
    -> 分环境运行时配置
    -> 正确的静态资源和 SPA 路由规则
    -> 分类型缓存
    -> 稳定灰度分流
    -> 前后端兼容
    -> 错误与性能监控
    -> 原子切换或流量回退

其中最重要的因果关系是:

  • 环境变量决定构建或运行时行为;
  • base 和服务器路由决定资源是否能被正确找到;
  • 内容哈希决定静态资源能否长期缓存;
  • HTML 的缓存策略决定新版本能否及时进入用户浏览器;
  • 灰度分流决定哪些用户获得哪个版本;
  • 兼容性决定前端和后端能否独立发布;
  • 不可变产物和旧资源保留决定回滚是否真的可行。

当这些关系被分别设计并通过命令、HTTP 响应和运行时指标验证时,Vue 应用的生产交付才从“上传一份 dist”变成了可观察、可灰度、可恢复的工程流程。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。