前端生产部署完全指南:从零到精通
想象你在用手机银行转账:这就是我们要解决的核心问题!需求2️⃣:缓存最优化1.3 为什么这么难?传统部署方式的问题:2. 基础概念:小白必懂的名词解释 {#2-基础概念}2.1 什么是"构建"(Build)?🍞 比喻:做面包实际过程:2.2 什么是"哈希"(Hash)?🔑 比喻:文件的指纹为什么需要Hash?2.3 什么是"源站"(Origin Server)?🏠 比喻:你家的仓库2.4 什
🎯 本文用最通俗的语言,带你完整理解前端从开发到部署的全过程
目录
- 核心问题:为什么需要版本管理?
- 基础概念:小白必懂的名词解释
- 版本控制:package.json的作用
- 构建产物:打包后的文件长什么样?
- 部署策略:资源超市方案
- 对象存储OSS:文件的永久家园
- CDN加速:让用户飞快访问
- Nginx代理:流量的指挥官
- 完整实现:3版本共存方案
- 常见问题与解决方案
1. 核心问题:为什么需要版本管理? {#1-核心问题}
1.1 真实场景重现
想象你在用手机银行转账:
⏰ 09:00 - 你打开App,填写转账信息(此时加载的是 v1.5 版本)
☕ 09:10 - 你去泡咖啡,App停留在后台
🚀 09:15 - 银行发布了 v1.6 版本(服务器上的文件更新了)
💥 09:20 - 你回来点击"确认转账"
❌ 报错:chunk-abc123.js 404 Not Found
💸 你的钱转出去了吗?不知道!
这就是我们要解决的核心问题!
1.2 两大核心需求
需求1️⃣:零404错误
用户打开的是旧版本HTML,点击按钮时:
- 旧版本需要的 JS 文件必须还在服务器上
- 即使服务器已经发布了新版本
- 至少保证最近 3 个版本的文件都能访问
需求2️⃣:缓存最优化
如果文件内容没变:
- 文件名(带hash)不能变
- URL路径不能变
- 用户浏览器可以继续使用缓存
- 不需要重新下载
1.3 为什么这么难?
传统部署方式的问题:
# ❌ 错误做法1:直接覆盖
rm -rf /var/www/assets/*
cp new-build/* /var/www/assets/
# 问题:旧版本用户立即404
# ❌ 错误做法2:版本目录隔离
/v1.5/main.abc.js
/v1.6/main.abc.js # 内容一样,但路径不同
# 问题:缓存失效,用户重复下载相同内容
2. 基础概念:小白必懂的名词解释 {#2-基础概念}
2.1 什么是"构建"(Build)?
🍞 比喻:做面包
原材料(源代码) 成品(构建产物)
├── 面粉 (Vue文件) → └── 面包 (压缩后的JS)
├── 糖 (CSS) → 更小、更快、能直接吃
└── 酵母 (依赖包) → 保质期更长
实际过程:
// 1. 你写的代码(源代码)
import { reactive } from 'vue'
import axios from 'axios'
export default {
setup() {
const user = reactive({ name: '小明', age: 18 })
return { user }
}
}
// 2. 构建后(打包压缩)
const a=reactive({name:"小明",age:18});export{a as user}
// ↓ 体积减少70%,加载更快
2.2 什么是"哈希"(Hash)?
🔑 比喻:文件的指纹
文件内容 → 指纹(Hash)
main.js (100KB) → main.abc123.js
修改1个字符 → main.xyz789.js (完全不同的hash)
为什么需要Hash?
<!-- 没有hash:浏览器可能用旧缓存 -->
<script src="/main.js"></script>
<!-- 用户可能看到旧版本代码 -->
<!-- 有hash:内容变了,文件名就变 -->
<script src="/main.abc123.js"></script>
<!-- 内容变了 → 文件名变 → 浏览器知道要下载新的 -->
2.3 什么是"源站"(Origin Server)?
🏠 比喻:你家的仓库
源站 = 你真正存放文件的服务器
你的服务器(源站)
┌─────────────┐
│ /index.html │ ← 这是原始文件的家
│ /main.js │
└─────────────┘
2.4 什么是"对象存储"(OSS - Object Storage Service)?
🏢 比喻:专业的仓储公司
普通服务器 vs 对象存储OSS
服务器(你家仓库) OSS(顺丰仓库)
├── 空间有限 ├── 空间无限
├── 需要自己维护 ├── 专人维护
├── 磁盘坏了数据丢失 ├── 自动备份
├── 带宽有限 ├── 带宽超大
└── 费用:固定成本 └── 费用:按使用量付费
实际使用:
// 上传文件到阿里云OSS
const OSS = require('ali-oss')
const client = new OSS({
region: 'oss-cn-hangzhou',
accessKeyId: 'your-key',
accessKeySecret: 'your-secret',
bucket: 'my-app-assets'
})
// 上传构建后的文件
await client.put('assets/main.abc123.js', './dist/main.abc123.js')
// 文件现在的访问地址:
// https://my-app-assets.oss-cn-hangzhou.aliyuncs.com/assets/main.abc123.js
2.5 什么是"CDN"(Content Delivery Network)?
🚚 比喻:遍布全国的配送点
没有CDN 有CDN
用户(北京) 用户(北京)
↓ 3000公里 ↓ 50公里
服务器(广州) CDN节点(北京)
↓ 第一次才去拿
服务器(广州)
延迟:500ms 延迟:50ms
CDN做了什么?
// 第一次访问
用户A(北京)请求 main.abc123.js
→ CDN北京节点(没有文件)
→ 去源站(广州)下载
→ 缓存到北京节点
→ 返回给用户A
// 第二次访问
用户B(北京)请求 main.abc123.js
→ CDN北京节点(已有文件)
→ 直接返回(超快!)
2.6 什么是"Nginx"?
🚦 比喻:交通警察
用户请求
↓
[Nginx 交警] ← 根据规则指挥交通
/ \
/assets/* /api/*
(静态文件) (后端接口)
↓ ↓
文件服务器 应用服务器
Nginx能做什么?
server {
# 1. 静态文件直接返回
location /assets/ {
alias /var/www/assets/;
expires 1y; # 缓存1年
}
# 2. API请求转发给后端
location /api/ {
proxy_pass http://localhost:3000;
}
# 3. HTML文件短期缓存
location / {
alias /var/www/;
expires 5m; # 缓存5分钟
}
}
2.7 什么是"代理"(Proxy)?
🎭 比喻:中间人
客户端 代理服务器 真实服务器
↓ ↓ ↓
"我要访问/api/user" "好的,我帮你转发" "返回用户数据"
← ← ←
为什么需要代理?
// 没有代理
前端: https://www.example.com
后端: https://api.example.com
// ❌ 跨域问题!浏览器会拦截
// 有代理
前端: https://www.example.com/api/user
↓
Nginx代理: 转发到 https://api.example.com/user
// ✅ 同域名,没有跨域问题
3. 版本控制:package.json的作用 {#3-版本控制}
3.1 版本号的含义
{
"name": "my-app",
"version": "1.2.3"
}
版本号规则:主版本.次版本.修订号
v1.2.3
↓ ↓ ↓
│ │ └─ 修订号 (Patch) - Bug修复、小优化
│ └─── 次版本 (Minor) - 新功能、向下兼容
└───── 主版本 (Major) - 重大更新、不兼容旧版
例子:
v1.0.0 → v1.0.1 修复了登录按钮的bug
v1.0.1 → v1.1.0 新增了分享功能
v1.1.0 → v2.0.0 重构了整个架构
3.2 版本号的实际应用
{
"name": "shopping-app",
"version": "1.5.2",
"scripts": {
"build": "vite build",
"deploy": "npm version patch && npm run build && node scripts/deploy.js"
}
}
自动升级版本号:
# 修复bug
npm version patch # 1.5.2 → 1.5.3
# 新增功能
npm version minor # 1.5.3 → 1.6.0
# 重大更新
npm version major # 1.6.0 → 2.0.0
3.3 版本号与Git标签关联
# 1. 修改代码修复bug
git add .
git commit -m "fix: 修复支付按钮点击无效的问题"
# 2. 升级版本号(自动创建Git标签)
npm version patch -m "chore: bump version to %s"
# 执行后:
# - package.json: 1.5.2 → 1.5.3
# - 创建Git标签: v1.5.3
# - 自动提交
# 3. 推送到远程
git push origin main --tags
# 4. 部署时可以按标签部署
git checkout v1.5.3
npm run deploy
4. 构建产物:打包后的文件长什么样? {#4-构建产物}
4.1 构建前的项目结构
my-app/
├── src/
│ ├── main.js (10KB - 入口文件)
│ ├── App.vue (5KB - 根组件)
│ ├── components/
│ │ ├── Header.vue (3KB)
│ │ └── Footer.vue (2KB)
│ └── views/
│ ├── Home.vue (8KB)
│ └── About.vue (6KB)
├── node_modules/
│ └── vue/ (500KB)
└── package.json
4.2 构建后的产物(关键!)
npm run build
生成的文件:
dist/
├── index.html (2KB - 入口页面)
│ 内容:
│ <script src="/assets/main.abc123.js"></script>
│ <link href="/assets/main.xyz789.css">
│
├── assets/
│ ├── main.abc123.js (150KB - 主要代码)
│ │ 包含:你的业务逻辑
│ │
│ ├── vendor.def456.js (200KB - 第三方库)
│ │ 包含:vue, axios等
│ │
│ ├── Home.ghi789.js (25KB - 首页代码)
│ │ 动态导入:用户访问首页时才加载
│ │
│ ├── About.jkl012.js (20KB - 关于页代码)
│ │ 动态导入:用户访问关于页才加载
│ │
│ └── main.xyz789.css (10KB - 样式)
│
└── manifest.json (1KB - 资源清单)
{
"version": "1.5.3",
"assets": {
"main.js": "/assets/main.abc123.js",
"vendor.js": "/assets/vendor.def456.js"
}
}
4.3 哈希命名的原理
// Vite构建配置
export default {
build: {
rollupOptions: {
output: {
// 根据文件内容生成hash
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]'
}
}
}
}
// 构建时:
文件内容 → MD5计算 → 截取前8位 → 文件名
"const app = ..." → "abc12345..." → "abc123" → "main.abc123.js"
重要特性:
文件内容不变 → hash不变 → 文件名不变 → 缓存有效 ✅
例子:
v1.5.2: vendor.def456.js (vue代码)
v1.5.3: vendor.def456.js (vue代码没变)
→ 文件名完全一样!
→ 用户不需要重新下载!
4.4 代码分割(Code Splitting)
为什么要分割?
不分割:
main.js (500KB) ← 用户首次访问要下载500KB
└── 包含所有页面的代码
分割后:
main.js (150KB) ← 用户首次只下载150KB
Home.js (25KB) ← 访问首页时才下载
About.js (20KB) ← 访问关于页时才下载
Settings.js (30KB) ← 访问设置页时才下载
实现方式:
// Vue Router 动态导入
const routes = [
{
path: '/',
name: 'Home',
component: () => import('./views/Home.vue') // 单独打包
},
{
path: '/about',
name: 'About',
component: () => import('./views/About.vue') // 单独打包
}
]
5. 部署策略:资源超市方案 {#5-部署策略}
5.1 核心思想:建立"资源超市"
🛒 比喻:超市货架
传统方式(你家冰箱) 资源超市方式
├── 只放当前食材 ├── 放最近3次购买的所有食材
├── 买新的就扔掉旧的 ├── 旧的还能用,不会扔
└── 想吃旧菜谱?没食材了! └── 任何旧菜谱都能做!
对应前端:
传统:只保留当前版本文件 超市:保留最近3个版本的所有文件
问题:旧版本用户404 优势:零404 + 缓存最优
5.2 目录结构设计(重点!)
my-app/
├── dist/
│ ├── current/ # 🎯 当前线上版本
│ │ ├── index.html # 用户访问的入口
│ │ ├── manifest.json # 版本标识
│ │ └── assets/ # 当前版本的完整文件(备份)
│ │ ├── main.abc123.js
│ │ ├── vendor.def456.js
│ │ └── Home.ghi789.js
│ │
│ ├── shared-assets/ # 🛒 资源超市(对外服务)
│ │ ├── main.abc123.js # v1.5.3的主文件
│ │ ├── main.aaa111.js # v1.5.2的主文件
│ │ ├── main.bbb222.js # v1.5.1的主文件
│ │ ├── vendor.def456.js # 公共库(多版本共用)
│ │ ├── Home.ghi789.js # v1.5.3的首页
│ │ ├── Home.ccc333.js # v1.5.2的首页
│ │ └── ... # 所有需要的文件
│ │
│ └── versions/ # 🗄️ 版本档案馆
│ ├── v1.5.3/ # 最新版本完整档案
│ │ ├── index.html
│ │ ├── manifest.json
│ │ └── assets/
│ ├── v1.5.2/ # 上一个版本
│ ├── v1.5.1/ # 再上一个版本
│ └── ... # 保留10个版本
│
└── scripts/
└── deploy.js # 🔧 部署脚本
5.3 工作流程详解(图解版)
部署新版本 v1.5.3 的完整过程:
第1步:构建项目
npm run build
# 你做了什么:
# 把 src/ 里的 Vue/React 代码,打包成浏览器能直接运行的 JS 文件
# 得到什么:
build/
├── index.html # 入口页面
└── assets/
├── main.abc123.js # 你的业务代码(带hash)
├── vendor.def456.js # vue/react等库(带hash)
└── Home.ghi789.js # 首页代码(带hash)
第2步:备份当前线上版本
// 为什么要备份?因为可能需要回滚!
当前线上是 v1.5.2
↓
复制 dist/current/ → dist/versions/v1.5.2/
↓
现在有了 v1.5.2 的完整备份
// 类比:就像手机升级系统前,先备份当前系统
第3步:更新 current/ 为新版本
// 把刚才构建好的文件,放到 current/ 目录
清空 dist/current/
↓
复制 build/ → dist/current/
↓
dist/current/ 现在是 v1.5.3
// 类比:把新下载的应用,替换掉旧的
第4步:同步资源到"资源超市"(核心!)
这一步是零404的关键,我们要收集最近3个版本需要的所有文件:
// 【步骤4.1】找出最近3个版本
const recentVersions = [
'v1.5.3', // 刚发布的
'v1.5.2', // 上一个版本(可能还有用户在用)
'v1.5.1' // 再上一个(少量用户可能在用)
]
// 【步骤4.2】扫描每个版本,收集它们需要的文件
neededAssets = new Set() // Set会自动去重
// 扫描 v1.5.3 的 assets 目录
neededAssets.add('main.abc123.js') // v1.5.3的主文件
neededAssets.add('vendor.def456.js') // vue库(和v1.5.2一样)
neededAssets.add('Home.ghi789.js') // v1.5.3的首页
// 扫描 v1.5.2 的 assets 目录
neededAssets.add('main.aaa111.js') // v1.5.2的主文件
neededAssets.add('vendor.def456.js') // vue库(已存在,Set会忽略)
neededAssets.add('Home.ccc333.js') // v1.5.2的首页
// 扫描 v1.5.1 的 assets 目录
neededAssets.add('main.bbb222.js') // v1.5.1的主文件
neededAssets.add('vendor.def456.js') // vue库(已存在,Set会忽略)
neededAssets.add('Home.ddd444.js') // v1.5.1的首页
// 【结果】neededAssets 包含:
// [
// 'main.abc123.js', // v1.5.3需要
// 'main.aaa111.js', // v1.5.2需要
// 'main.bbb222.js', // v1.5.1需要
// 'vendor.def456.js', // 3个版本共用(内容一样,只存1份)
// 'Home.ghi789.js', // v1.5.3需要
// 'Home.ccc333.js', // v1.5.2需要
// 'Home.ddd444.js' // v1.5.1需要
// ]
// 【步骤4.3】复制需要的文件到 shared-assets/
for (const filename of neededAssets) {
const targetPath = `dist/shared-assets/${filename}`
// 如果文件已经存在,跳过(避免重复复制)
if (exists(targetPath)) {
console.log(`⏭️ 跳过: ${filename} (已存在)`)
continue
}
// 从版本档案中找到这个文件
const sourcePath = findInVersions(filename, recentVersions)
// 复制到共享目录
copy(sourcePath, targetPath)
console.log(`✅ 新增: ${filename}`)
}
// 【步骤4.4】清理不再需要的旧文件
// 遍历 shared-assets/ 里的所有文件
const currentFiles = listFiles('dist/shared-assets/')
for (const file of currentFiles) {
// 如果这个文件不在 neededAssets 里
// 说明最近3个版本都不需要它了
if (!neededAssets.has(file)) {
delete(`dist/shared-assets/${file}`)
console.log(`🗑️ 删除: ${file}`)
}
}
// 例如:v1.5.0 的 main.zzz000.js
// 因为只保留3个版本,v1.5.0 已经超出范围
// 所以它的文件可以删除了
第5步:看看效果(对比图)
━━━━━━━━━━ 发布 v1.5.3 前 ━━━━━━━━━━
dist/
├── current/ ← 线上版本是 v1.5.2
│ └── index.html (引用 main.aaa111.js)
│
├── versions/
│ ├── v1.5.2/ ← 当前版本的备份
│ ├── v1.5.1/ ← 上一个版本
│ └── v1.5.0/ ← 再上一个
│
└── shared-assets/ ← 资源超市
├── main.aaa111.js (v1.5.2需要)
├── main.bbb222.js (v1.5.1需要)
├── main.ccc333.js (v1.5.0需要)
└── vendor.def456.js (公共库)
━━━━━━━━━━ 发布 v1.5.3 后 ━━━━━━━━━━
dist/
├── current/ ← 线上版本变成 v1.5.3
│ └── index.html (引用 main.abc123.js)
│
├── versions/
│ ├── v1.5.3/ ← 新版本 ✨
│ ├── v1.5.2/ ← 保留(有用户可能还在用)
│ ├── v1.5.1/ ← 保留
│ └── v1.5.0/ ← 保留(但资源会被清理)
│
└── shared-assets/ ← 资源超市更新了
├── main.abc123.js (v1.5.3需要) ← 新增 ✅
├── main.aaa111.js (v1.5.2需要) ← 保留
├── main.bbb222.js (v1.5.1需要) ← 保留
├── main.ccc333.js ❌ 删除(v1.5.0超出3版本范围)
└── vendor.def456.js (公共库) ← 保留
关键理解点
Q: 为什么 vendor.def456.js 只有一份?
v1.5.3 用的 vue 版本 → hash: def456
v1.5.2 用的 vue 版本 → hash: def456 (内容一样)
v1.5.1 用的 vue 版本 → hash: def456 (内容一样)
因为内容完全一样,所以hash也一样
所以文件名也一样
所以只需要存一份!
这就是"内容哈希"的魔力:
- 内容变了 → hash变 → 文件名变 → 浏览器知道要下载新的
- 内容没变 → hash不变 → 文件名不变 → 缓存继续有效
Q: 为什么要删除 main.ccc333.js?
保留3个版本:v1.5.3, v1.5.2, v1.5.1
v1.5.0 已经是第4个版本了
假设现在还有用户在用 v1.5.0:
- 可能性:极低(通常刷新页面就升级了)
- 影响:这个用户需要刷新页面
- 收益:节省存储空间,保持目录整洁
如果担心,可以保留更多版本(比如10个)
5.4 为什么这样设计?
问题1:为什么需要 current/ 和 versions/?
current/ - 当前在线版本,快速访问
versions/ - 历史版本档案,用于回滚和同步
场景:需要回滚到 v1.5.2
1. 从 versions/v1.5.2/ 复制到 current/
2. 重新同步 shared-assets/
3. 完成!用户立即使用 v1.5.2
问题2:为什么需要 shared-assets/?
没有shared-assets(每个版本独立目录)
/v1.5.3/assets/vendor.def456.js (200KB)
/v1.5.2/assets/vendor.def456.js (200KB) ← 内容完全一样!
/v1.5.1/assets/vendor.def456.js (200KB) ← 浪费存储空间
用户访问v1.5.2 → 路径不同 → 缓存失效 → 重新下载
有shared-assets(共享目录)
/shared-assets/vendor.def456.js (200KB) ← 只存一份
用户访问任何版本 → 同一个URL → 缓存有效 ✅
问题3:为什么保留3个版本?
实际场景:
09:00 - 发布 v1.5.3(95%用户还在用 v1.5.2)
09:30 - 用户陆续刷新页面,升级到 v1.5.3
10:00 - 发现 v1.5.3 有bug,回滚到 v1.5.2
10:30 - 修复bug,发布 v1.5.4
过程中共存的版本:
- v1.5.2(大量用户)
- v1.5.3(部分用户,即使回滚了也有人在用)
- v1.5.4(新用户)
保留3个版本 = 覆盖99.9%的场景
6. 对象存储OSS:文件的永久家园 {#6-对象存储}
6.1 为什么要用OSS?
对比:普通服务器 vs OSS
| 对比项 | 普通服务器 | OSS对象存储 |
|---|---|---|
| 存储容量 | 有限(比如500GB) | 无限(按需扩展) |
| 可靠性 | 磁盘损坏=数据丢失 | 自动多重备份,99.9999999%可靠性 |
| 访问速度 | 取决于服务器带宽 | 自带CDN加速 |
| 成本 | 固定成本(闲置也付费) | 按使用量付费 |
| 维护 | 需要自己维护 | 零维护 |
实际场景:
普通服务器方案:
1. 租一台服务器(2000元/年)
2. 配置100GB硬盘
3. 第50个版本时硬盘满了
4. 删除旧版本文件 → 旧用户404
OSS方案:
1. 开通OSS(0.12元/GB/月)
2. 存储无上限
3. 100个版本也不怕
4. 所有历史版本永久可访问
6.2 OSS的工作原理
🏢 OSS = 云端的无限大仓库
你的应用 阿里云OSS
↓ ↓
构建完成 [华北机房]
↓ [华东机房] ← 自动备份3份
上传文件 ─────────────→ [华南机房]
↓ ↓
生成URL 永久存储
↓ ↓
https://your-bucket.oss-cn-hangzhou.aliyuncs.com/assets/main.abc123.js
6.3 OSS实际使用
6.3.1 开通OSS服务
// 1. 阿里云控制台创建Bucket
Bucket名称: my-app-production
地域: 华东1(杭州)
读写权限: 公共读(文件公开访问)
存储类型: 标准存储
// 2. 获取访问凭证
AccessKey ID: LTAI4FxxxxxxxxxxxxxxxxB
AccessKey Secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
6.3.2 上传脚本
// scripts/upload-to-oss.js
const OSS = require('ali-oss')
const fs = require('fs')
const path = require('path')
class OSSUploader {
constructor() {
this.client = new OSS({
region: 'oss-cn-hangzhou',
accessKeyId: process.env.OSS_ACCESS_KEY_ID,
accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET,
bucket: 'my-app-production'
})
}
// 上传整个目录
async uploadDirectory(localDir, ossPrefix) {
const files = this.getAllFiles(localDir)
console.log(`📤 开始上传 ${files.length} 个文件...`)
for (const file of files) {
const relativePath = path.relative(localDir, file)
const ossPath = path.join(ossPrefix, relativePath).replace(/\\/g, '/')
await this.uploadFile(file, ossPath)
}
console.log('✅ 上传完成!')
}
// 上传单个文件
async uploadFile(localPath, ossPath) {
try {
// 检查文件是否已存在
const exists = await this.checkFileExists(ossPath)
if (exists) {
console.log(`⏭️ 跳过: ${ossPath} (已存在)`)
return
}
// 上传文件
const result = await this.client.put(ossPath, localPath, {
headers: {
'Cache-Control': this.getCacheControl(localPath)
}
})
console.log(`✅ 上传: ${ossPath}`)
return result
} catch (error) {
console.error(`❌ 上传失败: ${ossPath}`, error)
throw error
}
}
// 检查文件是否已存在
async checkFileExists(ossPath) {
try {
await this.client.head(ossPath)
return true
} catch (error) {
if (error.code === 'NoSuchKey') {
return false
}
throw error
}
}
// 获取缓存策略
getCacheControl(filePath) {
const ext = path.extname(filePath)
// HTML文件:短期缓存
if (ext === '.html') {
return 'public, max-age=300' // 5分钟
}
// 静态资源:长期缓存
if (['.js', '.css', '.png', '.jpg', '.gif', '.woff2'].includes(ext)) {
return 'public, max-age=31536000, immutable' // 1年
}
return 'public, max-age=86400' // 1天
}
// 递归获取所有文件
getAllFiles(dir) {
let results = []
const items = fs.readdirSync(dir)
for (const item of items) {
const fullPath = path.join(dir, item)
const stat = fs.statSync(fullPath)
if (stat.isDirectory()) {
results = results.concat(this.getAllFiles(fullPath))
} else {
results.push(fullPath)
}
}
return results
}
}
// 使用示例
async function deploy() {
const uploader = new OSSUploader()
// 上传shared-assets目录
await uploader.uploadDirectory(
'./dist/shared-assets',
'assets'
)
// 上传当前版本的HTML
await uploader.uploadFile(
'./dist/current/index.html',
'index.html'
)
}
deploy().catch(console.error)
6.3.3 部署流程集成OSS
// scripts/deploy-with-oss.js
const VersionManager = require('./version-manager')
const OSSUploader = require('./upload-to-oss')
async function deployWithOSS() {
console.log('🚀 开始部署流程...')
// 1. 本地构建和版本管理
const versionManager = new VersionManager()
const version = await versionManager.deploy('./build')
console.log(`📦 版本 ${version} 准备完成`)
// 2. 上传到OSS
const uploader = new OSSUploader()
// 2.1 上传共享资源
await uploader.uploadDirectory(
'./dist/shared-assets',
'assets'
)
// 2.2 上传当前版本HTML
await uploader.uploadFile(
'./dist/current/index.html',
'index.html'
)
// 2.3 备份版本到OSS
await uploader.uploadDirectory(
`./dist/versions/${version}`,
`versions/${version}`
)
console.log('✅ OSS部署完成!')
console.log(`🌐 访问地址: https://your-domain.com`)
}
deployWithOSS()
6.4 OSS的优化技巧
6.4.1 避免重复上传
// 智能上传:只上传变化的文件
async uploadWithCheck(localPath, ossPath) {
const localMD5 = await this.getFileMD5(localPath)
try {
// 获取OSS上文件的MD5
const ossFile = await this.client.head(ossPath)
const ossMD5 = ossFile.headers['etag'].replace(/"/g, '')
if (localMD5 === ossMD5) {
console.log(`⏭️ 跳过: ${ossPath} (内容未变化)`)
return
}
} catch (error) {
// 文件不存在,继续上传
}
// 上传文件
await this.client.put(ossPath, localPath)
console.log(`✅ 上传: ${ossPath}`)
}
6.4.2 并行上传加速
async uploadDirectoryParallel(localDir, ossPrefix, concurrency = 10) {
const files = this.getAllFiles(localDir)
// 分批并行上传
for (let i = 0; i < files.length; i += concurrency) {
const batch = files.slice(i, i + concurrency)
await Promise.all(
batch.map(file => this.uploadFile(file, getOSSPath(file)))
)
console.log(`进度: ${Math.min(i + concurrency, files.length)}/${files.length}`)
}
}
7. CDN加速:让用户飞快访问 {#7-cdn加速}
7.1 CDN的工作原理
🌍 比喻:全国连锁店 vs 单店经营
没有CDN(单店) 有CDN(连锁店)
用户(北京) 用户(北京)
↓ ↓
3000公里 50公里
↓ ↓
源站(广州) CDN节点(北京)
↓
第一次才去
↓
源站(广州)
实际网络情况:
源站在广州,用户在全国各地访问:
无CDN:
- 北京用户:延迟 500ms
- 上海用户:延迟 300ms
- 广州用户:延迟 50ms
- 新疆用户:延迟 800ms
有CDN:
- 北京用户:CDN北京节点 → 延迟 50ms
- 上海用户:CDN上海节点 → 延迟 40ms
- 广州用户:CDN广州节点 → 延迟 30ms
- 新疆用户:CDN新疆节点 → 延迟 60ms
7.1.1 CDN vs 源站强缓存(重要!)
很多人会问:“浏览器不是有缓存吗?为什么还需要CDN?”
让我们对比这两种缓存:
📱 浏览器强缓存
// HTML中引用资源
<script src="https://example.com/main.abc123.js"></script>
// 浏览器缓存机制
用户A第1次访问 → 从源站下载 main.abc123.js → 存到浏览器缓存
用户A第2次访问 → 直接用浏览器缓存(超快!0延迟)
用户B第1次访问 → 从源站下载 main.abc123.js(慢!)
特点:
- ✅ 同一个用户重复访问 = 超快
- ❌ 不同用户 = 每人都要下载一次
- ❌ 清空缓存 = 又要重新下载
🌐 CDN缓存(共享缓存)
用户A(北京) 第1次访问 → CDN北京节点(没有) → 回源到上海 → 下载
→ CDN北京节点(缓存文件)
用户B(北京) 第1次访问 → CDN北京节点(已有!) → 直接返回(快!)
用户C(北京) 第1次访问 → CDN北京节点(已有!) → 直接返回(快!)
...
用户Z(北京) 第1次访问 → CDN北京节点(已有!) → 直接返回(快!)
特点:
- ✅ 一个用户回源,所有同地区用户受益!
- ✅ 即使清空浏览器缓存,CDN缓存还在
- ✅ 源站压力小(只需要服务少量CDN节点)
🎯 真实场景对比
场景1:热门应用发版
假设:抖音发布新版本,10万北京用户同时打开
只有浏览器缓存:
10万个请求 → 全部打到源站(上海)
├─ 源站服务器:CPU 100%,可能宕机
├─ 网络带宽:拥堵
└─ 用户体验:加载慢,甚至失败
有CDN:
第1个用户 → CDN北京节点 → 回源(上海) → 缓存到CDN
后99999个用户 → CDN北京节点 → 直接返回(本地)
├─ 源站服务器:只服务1个请求
├─ 网络带宽:充足
└─ 用户体验:秒开!
场景2:全国用户访问
只有浏览器缓存 + 源站强缓存:
北京用户 → 上海源站 (延迟 50ms, 每人都要请求)
广州用户 → 上海源站 (延迟 30ms, 每人都要请求)
新疆用户 → 上海源站 (延迟 100ms, 每人都要请求)
问题:
- 每个用户第一次访问都要跨地域
- 源站要处理所有用户的首次请求
- 网络路径可能绕路
有CDN:
北京用户 → CDN北京节点 (延迟 5ms) ← 一人回源,万人受益
广州用户 → CDN广州节点 (延迟 5ms) ← 一人回源,万人受益
新疆用户 → CDN新疆节点 (延迟 5ms) ← 一人回源,万人受益
优势:
- 绝大多数用户就近访问
- 源站只处理CDN回源请求(少量)
- 网络路径优化
📊 数据对比
| 场景 | 浏览器缓存 | CDN缓存 | 组合效果 |
|---|---|---|---|
| 用户A第1次访问 | ❌ 无缓存 | ⚠️ 回源 | 慢 (50-100ms) |
| 用户A第2次访问 | ✅ 有缓存 | - | 超快 (0ms) |
| 用户B第1次访问 | ❌ 无缓存 | ✅ 有缓存 | 快 (5-20ms) |
| 用户B第2次访问 | ✅ 有缓存 | - | 超快 (0ms) |
| 清空浏览器缓存后 | ❌ 无缓存 | ✅ 有缓存 | 快 (5-20ms) |
| 10万并发请求 | ❌ 都打源站 | ✅ 打CDN | 源站压力减99% |
🤔 什么时候CDN优势不明显?
场景1:超小流量网站
个人博客,每天只有10个访问者
- CDN:第1个用户回源,后9个快
- 源站:10个用户都慢一点
结论:差别不大,但CDN更稳定
场景2:用户都在同一城市
公司内部系统,所有用户都在上海
服务器也在上海
- CDN:需要先到CDN节点再回源(可能绕路)
- 源站:直连
结论:可能源站直连更快
场景3:个性化内容
每个用户看到的内容都不同
- 无法缓存
- CDN变成了"代理"而非"缓存"
结论:CDN优势有限
✅ CDN的核心价值总结
- 多用户共享缓存 - 一人回源,万人受益
- 地理分布 - 全国/全球用户都能就近访问
- 保护源站 - 防止突发流量冲垮服务器
- 网络优化 - CDN有专线,路径更优
- 高可用 - 某个节点挂了,自动切换
结论:浏览器缓存解决"个人重复访问",CDN解决"多人首次访问"!
7.2 CDN工作流程详解
第一次访问(缓存未命中)
用户A(北京)
↓
输入: www.example.com
↓
DNS解析: 返回 CDN北京节点IP
↓
请求: main.abc123.js
↓
[CDN北京节点]
↓ 没有这个文件
↓
回源: 去OSS源站下载
↓
[OSS源站] 返回文件
↓
[CDN北京节点] 缓存文件
↓
返回给用户A
第二次访问(缓存命中)
用户B(北京)
↓
请求: main.abc123.js
↓
[CDN北京节点]
↓ 已缓存!
↓
直接返回(超快!)
7.3 CDN配置实战
7.3.1 阿里云CDN配置
// 1. 添加CDN域名
域名: cdn.example.com
源站类型: OSS域名
源站地址: my-app-production.oss-cn-hangzhou.aliyuncs.com
// 2. 缓存配置
路径: /assets/*
过期时间: 365天
缓存规则: 遵循源站
路径: /index.html
过期时间: 5分钟
缓存规则: 遵循源站
// 3. 回源配置
回源HOST: my-app-production.oss-cn-hangzhou.aliyuncs.com
回源协议: HTTPS
7.3.2 修改构建配置
// vite.config.js
export default {
base: 'https://cdn.example.com/', // CDN域名
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]'
}
}
}
}
// 构建后的HTML:
// <script src="https://cdn.example.com/assets/main.abc123.js"></script>
7.4 CDN缓存策略
// 不同文件类型的缓存策略
const cacheStrategies = {
// HTML: 短期缓存
'index.html': {
maxAge: 300, // 5分钟
mustRevalidate: true
},
// JS/CSS: 长期缓存
'*.js': {
maxAge: 31536000, // 1年
immutable: true
},
// 图片: 中期缓存
'*.png': {
maxAge: 2592000 // 30天
},
// API: 不缓存
'/api/*': {
maxAge: 0,
noCache: true
}
}
7.5 CDN刷新
场景:紧急修复bug
// scripts/cdn-purge.js
const Core = require('@alicloud/pop-core')
class CDNPurge {
constructor() {
this.client = new Core({
accessKeyId: process.env.ALIBABA_CLOUD_ACCESS_KEY_ID,
accessKeySecret: process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET,
endpoint: 'https://cdn.aliyuncs.com',
apiVersion: '2018-05-10'
})
}
// 刷新指定文件
async purgeFiles(urls) {
const params = {
ObjectPath: urls.join('\n'),
ObjectType: 'File'
}
try {
const result = await this.client.request('RefreshObjectCaches', params)
console.log('✅ CDN刷新成功:', result)
} catch (error) {
console.error('❌ CDN刷新失败:', error)
}
}
// 刷新整个目录
async purgeDirectory(paths) {
const params = {
ObjectPath: paths.join('\n'),
ObjectType: 'Directory'
}
await this.client.request('RefreshObjectCaches', params)
}
}
// 使用示例
const purge = new CDNPurge()
// 刷新HTML文件(让用户立即看到新版本)
purge.purgeFiles([
'https://cdn.example.com/index.html'
])
8. Nginx代理:流量的指挥官 {#8-nginx代理}
8.1 Nginx是什么?
🚦 比喻:智能交通警察
用户请求
↓
[ Nginx ] ← 交通指挥
/ | \
/ | \
静态文件 API WebSocket
↓ ↓ ↓
OSS 后端服务 实时服务
8.2 基础配置
# /etc/nginx/nginx.conf
user nginx;
worker_processes auto; # 自动根据CPU核心数
events {
worker_connections 1024; # 每个进程最大连接数
}
http {
# 基础配置
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log;
# 性能优化
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript
application/x-javascript application/xml+rss
application/javascript application/json;
# 引入站点配置
include /etc/nginx/conf.d/*.conf;
}
8.3 完整站点配置
# /etc/nginx/conf.d/my-app.conf
# 上游服务器定义
upstream backend_api {
server localhost:3000;
server localhost:3001 backup; # 备用服务器
}
# 主服务器配置
server {
listen 80;
server_name www.example.com example.com;
# 强制HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name www.example.com example.com;
# SSL证书配置
ssl_certificate /etc/nginx/ssl/example.com.crt;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# 日志
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log;
# 根路径 - 返回当前版本HTML
location = / {
alias /var/www/my-app/dist/current/;
try_files /index.html =404;
# HTML短期缓存
expires 5m;
add_header Cache-Control "public, must-revalidate";
}
# 静态资源 - 从共享资源目录
location /assets/ {
alias /var/www/my-app/dist/shared-assets/;
# 长期缓存
expires 1y;
add_header Cache-Control "public, immutable";
# 开启Gzip
gzip_static on;
# CORS(如果需要)
add_header Access-Control-Allow-Origin *;
# 如果文件不存在,返回404(不要fallback)
try_files $uri =404;
}
# API代理
location /api/ {
proxy_pass http://backend_api;
# 代理头部
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 不缓存API响应
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# WebSocket代理
location /ws/ {
proxy_pass http://backend_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
# WebSocket超时
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# 健康检查
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# 版本信息API
location /api/version {
proxy_pass http://localhost:3000;
expires 1m;
}
# SPA路由支持(所有其他路径返回index.html)
location / {
alias /var/www/my-app/dist/current/;
try_files $uri $uri/ /index.html;
expires 5m;
add_header Cache-Control "public, must-revalidate";
}
# 错误页面
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
8.4 高级功能
8.4.1 灰度发布
# 基于IP的灰度发布
geo $use_new_version {
default 0;
192.168.1.0/24 1; # 内网IP使用新版本
}
server {
location / {
if ($use_new_version = 1) {
alias /var/www/my-app/dist/beta/;
}
alias /var/www/my-app/dist/current/;
try_files $uri /index.html;
}
}
# 基于Cookie的灰度发布
map $cookie_beta_user $version_dir {
"yes" "/var/www/my-app/dist/beta";
default "/var/www/my-app/dist/current";
}
server {
location / {
alias $version_dir;
try_files $uri /index.html;
}
}
8.4.2 限流保护
# 限制请求速率
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
server {
location /api/ {
# 每秒最多10个请求,burst允许突发20个
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://backend_api;
}
}
8.4.3 防盗链
server {
location /assets/ {
# 只允许特定域名访问
valid_referers none blocked www.example.com example.com;
if ($invalid_referer) {
return 403;
}
alias /var/www/my-app/dist/shared-assets/;
}
}
9. 完整实现:3版本共存方案 {#9-完整实现}
9.1 版本管理器
9.1.1 核心逻辑(简化版)
在看完整代码之前,先理解核心逻辑:
// ============ 最简化的版本管理逻辑 ============
class SimpleVersionManager {
// 部署新版本(只看核心步骤)
async deploy(buildDir, version) {
// 步骤1:备份当前版本
if (当前版本存在) {
复制('current' → `versions/${当前版本}`)
}
// 步骤2:更新为新版本
复制(buildDir → 'current')
// 步骤3:同步资源到共享目录(核心!)
const 最近3个版本 = 获取最近版本(3)
const 需要的文件 = []
// 收集最近3个版本需要的所有文件
for (const 版本 of 最近3个版本) {
const 版本的文件 = 读取(`versions/${版本}/assets`)
需要的文件.push(...版本的文件)
}
// 复制到共享目录
for (const 文件 of 需要的文件) {
if (!存在(`shared-assets/${文件}`)) {
复制(文件 → `shared-assets/${文件}`)
}
}
// 清理不需要的旧文件
for (const 文件 of shared-assets里的所有文件) {
if (!需要的文件.includes(文件)) {
删除(`shared-assets/${文件}`)
}
}
}
// 回滚到旧版本
async rollback(targetVersion) {
复制(`versions/${targetVersion}` → 'current')
重新同步资源()
}
}
理解要点:
-
3个目录的关系
current/ - 用户访问的当前版本 versions/ - 历史版本备份(用于回滚) shared-assets/ - 所有版本共用的资源池 -
为什么要"同步"而不是"复制"?
复制:把新版本的 assets/ 复制到 shared-assets/ 问题:旧版本的文件被覆盖了 同步:收集最近3个版本需要的所有文件,都放到 shared-assets/ 优势:新旧版本的文件都在,零404! -
Set的作用(去重)
const files = new Set() files.add('vendor.abc.js') // 添加 files.add('vendor.abc.js') // 重复的自动忽略 // files 里只有1个 vendor.abc.js
9.1.2 完整代码(带详细注释)
创建 scripts/version-manager.js:
const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')
const crypto = require('crypto')
class VersionManager {
constructor(config = {}) {
// 配置
this.distDir = config.distDir || path.join(__dirname, '../dist')
this.currentDir = path.join(this.distDir, 'current')
this.versionsDir = path.join(this.distDir, 'versions')
this.sharedAssetsDir = path.join(this.distDir, 'shared-assets')
this.keepRecentVersions = config.keepRecentVersions || 10
this.preserveAssetsForVersions = config.preserveAssetsForVersions || 3
this.ensureDirectories()
}
// 确保目录存在
ensureDirectories() {
[this.distDir, this.currentDir, this.versionsDir, this.sharedAssetsDir].forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
})
}
// 获取版本信息
getVersionInfo() {
try {
// 优先使用Git Tag
const tag = execSync('git describe --tags --exact-match 2>/dev/null', {
encoding: 'utf8'
}).trim()
return { version: tag, source: 'git-tag' }
} catch {
// 使用Git Commit
try {
const commit = execSync('git rev-parse --short HEAD', {
encoding: 'utf8'
}).trim()
return { version: `commit-${commit}`, source: 'git-commit' }
} catch {
// Git不可用,使用时间戳
return { version: `build-${Date.now()}`, source: 'timestamp' }
}
}
}
// 部署新版本
async deploy(buildOutputDir) {
console.log('\n🚀 开始部署流程...\n')
const versionInfo = this.getVersionInfo()
const version = versionInfo.version
console.log(`📌 版本: ${version} (${versionInfo.source})`)
// 1. 备份当前版本
if (fs.existsSync(this.currentDir)) {
const currentManifest = this.loadManifest(this.currentDir)
if (currentManifest) {
await this.backupVersion(currentManifest.version)
}
}
// 2. 更新当前版本
await this.updateCurrent(buildOutputDir, version)
// 3. 同步资源到共享目录
await this.syncSharedAssets()
// 4. 清理旧版本
await this.cleanOldVersions()
// 5. 生成资源清单
await this.generateResourceManifest()
console.log(`\n✅ 部署完成!版本: ${version}\n`)
this.printStatus()
return version
}
// 备份版本
async backupVersion(version) {
const versionDir = path.join(this.versionsDir, version)
if (fs.existsSync(versionDir)) {
console.log(`⏭️ 跳过备份: ${version} (已存在)`)
return
}
console.log(`📦 备份版本: ${version}`)
fs.cpSync(this.currentDir, versionDir, { recursive: true })
}
// 更新当前版本
async updateCurrent(buildOutputDir, version) {
console.log(`🔄 更新当前版本: ${version}`)
// 清空current目录
if (fs.existsSync(this.currentDir)) {
fs.rmSync(this.currentDir, { recursive: true })
}
fs.mkdirSync(this.currentDir, { recursive: true })
// 复制构建产物
fs.cpSync(buildOutputDir, this.currentDir, { recursive: true })
// 创建manifest.json
const manifest = {
version: version,
deployTime: new Date().toISOString(),
buildTime: this.getBuildTime(buildOutputDir),
git: {
commit: this.getGitCommit(),
branch: this.getGitBranch()
}
}
fs.writeFileSync(
path.join(this.currentDir, 'manifest.json'),
JSON.stringify(manifest, null, 2)
)
console.log(`✅ 当前版本已更新`)
}
// 同步共享资源(核心方法)
async syncSharedAssets() {
console.log(`\n🔄 同步共享资源...\n`)
// 获取最近N个版本
const recentVersions = this.getRecentVersions(this.preserveAssetsForVersions)
console.log(`📁 处理最近 ${recentVersions.length} 个版本`)
if (recentVersions.length === 0) {
console.log('⚠️ 没有版本需要同步')
return
}
// 收集所有需要的资源
const neededAssets = new Map() // filename -> { path, size, version }
for (const version of recentVersions) {
const assetsDir = path.join(version.path, 'assets')
if (!fs.existsSync(assetsDir)) continue
const files = this.getAllFiles(assetsDir)
for (const file of files) {
const filename = path.basename(file)
if (!neededAssets.has(filename)) {
neededAssets.set(filename, {
path: file,
size: fs.statSync(file).size,
version: version.version
})
}
}
}
console.log(`📄 需要 ${neededAssets.size} 个资源文件\n`)
// 同步文件
let copied = 0, skipped = 0
for (const [filename, info] of neededAssets) {
const targetPath = path.join(this.sharedAssetsDir, filename)
if (fs.existsSync(targetPath)) {
skipped++
} else {
fs.copyFileSync(info.path, targetPath)
copied++
console.log(` ➕ ${filename} (${this.formatSize(info.size)}) - ${info.version}`)
}
}
if (copied === 0) {
console.log(` ✓ 所有文件已是最新`)
}
// 清理不再需要的文件
await this.cleanupSharedAssets(neededAssets)
console.log(`\n✅ 资源同步完成: 新增 ${copied}, 跳过 ${skipped}`)
}
// 清理共享资源中的过期文件
async cleanupSharedAssets(neededAssets) {
if (!fs.existsSync(this.sharedAssetsDir)) return
const currentFiles = fs.readdirSync(this.sharedAssetsDir)
const removed = []
for (const file of currentFiles) {
if (!neededAssets.has(file)) {
const filePath = path.join(this.sharedAssetsDir, file)
fs.unlinkSync(filePath)
removed.push(file)
}
}
if (removed.length > 0) {
console.log(`\n🧹 清理过期文件:`)
removed.forEach(file => console.log(` 🗑️ ${file}`))
}
}
// 清理旧版本
async cleanOldVersions() {
if (!fs.existsSync(this.versionsDir)) return
const allVersions = this.getRecentVersions(999) // 获取所有版本
const toDelete = allVersions.slice(this.keepRecentVersions)
if (toDelete.length === 0) return
console.log(`\n🗑️ 清理旧版本: ${toDelete.length} 个`)
for (const version of toDelete) {
fs.rmSync(version.path, { recursive: true })
console.log(` ❌ ${version.version}`)
}
}
// 生成资源清单JSON
async generateResourceManifest() {
const recentVersions = this.getRecentVersions(this.preserveAssetsForVersions)
const manifest = {
generated: new Date().toISOString(),
versions: []
}
for (const version of recentVersions) {
const versionManifest = this.loadManifest(version.path)
if (!versionManifest) continue
const assetsDir = path.join(version.path, 'assets')
const assets = []
if (fs.existsSync(assetsDir)) {
const files = this.getAllFiles(assetsDir)
for (const file of files) {
const filename = path.basename(file)
const stat = fs.statSync(file)
assets.push({
filename: filename,
path: `/assets/${filename}`,
size: stat.size,
hash: this.getFileHash(file)
})
}
}
manifest.versions.push({
version: version.version,
deployTime: versionManifest.deployTime,
assetCount: assets.length,
totalSize: assets.reduce((sum, a) => sum + a.size, 0),
assets: assets
})
}
// 保存清单
const manifestPath = path.join(this.distDir, 'resource-manifest.json')
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
console.log(`\n📋 资源清单已生成: resource-manifest.json`)
}
// 获取最近的版本列表
getRecentVersions(limit) {
if (!fs.existsSync(this.versionsDir)) return []
const versions = []
const dirs = fs.readdirSync(this.versionsDir)
for (const dir of dirs) {
const versionPath = path.join(this.versionsDir, dir)
const manifestPath = path.join(versionPath, 'manifest.json')
if (fs.existsSync(manifestPath)) {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
versions.push({
version: manifest.version,
path: versionPath,
deployTime: manifest.deployTime
})
}
}
// 按时间排序
versions.sort((a, b) => new Date(b.deployTime) - new Date(a.deployTime))
return versions.slice(0, limit)
}
// 加载manifest.json
loadManifest(dir) {
const manifestPath = path.join(dir, 'manifest.json')
if (fs.existsSync(manifestPath)) {
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
}
return null
}
// 递归获取所有文件
getAllFiles(dir) {
let results = []
const items = fs.readdirSync(dir)
for (const item of items) {
const fullPath = path.join(dir, item)
const stat = fs.statSync(fullPath)
if (stat.isDirectory()) {
results = results.concat(this.getAllFiles(fullPath))
} else {
results.push(fullPath)
}
}
return results
}
// 获取文件哈希
getFileHash(filePath) {
const content = fs.readFileSync(filePath)
return crypto.createHash('md5').update(content).digest('hex').substring(0, 8)
}
// 获取Git信息
getGitCommit() {
try {
return execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim()
} catch {
return 'unknown'
}
}
getGitBranch() {
try {
return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim()
} catch {
return 'unknown'
}
}
getBuildTime(buildDir) {
try {
const stat = fs.statSync(buildDir)
return stat.mtime.toISOString()
} catch {
return new Date().toISOString()
}
}
// 格式化文件大小
formatSize(bytes) {
const units = ['B', 'KB', 'MB', 'GB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(2)} ${units[unitIndex]}`
}
// 打印状态
printStatus() {
const currentManifest = this.loadManifest(this.currentDir)
const recentVersions = this.getRecentVersions(5)
console.log('═'.repeat(60))
console.log('📊 部署状态')
console.log('═'.repeat(60))
if (currentManifest) {
console.log(`当前版本: ${currentManifest.version}`)
console.log(`部署时间: ${new Date(currentManifest.deployTime).toLocaleString()}`)
}
// 统计共享资源
if (fs.existsSync(this.sharedAssetsDir)) {
const files = fs.readdirSync(this.sharedAssetsDir)
const totalSize = files.reduce((sum, file) => {
const filePath = path.join(this.sharedAssetsDir, file)
return sum + fs.statSync(filePath).size
}, 0)
console.log(`\n共享资源: ${files.length} 个文件, ${this.formatSize(totalSize)}`)
}
console.log(`\n最近版本:`)
recentVersions.forEach((v, i) => {
console.log(` ${i + 1}. ${v.version} - ${new Date(v.deployTime).toLocaleString()}`)
})
console.log('═'.repeat(60))
}
// 回滚版本
async rollback(targetVersion) {
console.log(`\n↩️ 开始回滚到版本: ${targetVersion}\n`)
const versionDir = path.join(this.versionsDir, targetVersion)
if (!fs.existsSync(versionDir)) {
throw new Error(`版本不存在: ${targetVersion}`)
}
// 备份当前版本
const currentManifest = this.loadManifest(this.currentDir)
if (currentManifest) {
await this.backupVersion(currentManifest.version)
}
// 恢复目标版本
fs.rmSync(this.currentDir, { recursive: true })
fs.cpSync(versionDir, this.currentDir, { recursive: true })
// 重新同步资源
await this.syncSharedAssets()
console.log(`\n✅ 回滚完成!当前版本: ${targetVersion}\n`)
}
}
module.exports = VersionManager
9.2 部署脚本
创建 scripts/deploy.js:
#!/usr/bin/env node
const VersionManager = require('./version-manager')
const { execSync } = require('child_process')
const path = require('path')
const versionManager = new VersionManager({
keepRecentVersions: 10, // 保留10个历史版本
preserveAssetsForVersions: 3 // 保留最近3个版本的资源
})
async function deploy() {
try {
console.log('开始构建项目...')
// 1. 构建项目
execSync('npm run build', { stdio: 'inherit' })
// 2. 部署
const buildDir = path.join(__dirname, '../build') // 根据实际情况调整
const version = await versionManager.deploy(buildDir)
console.log(`\n🎉 部署成功!版本: ${version}`)
} catch (error) {
console.error('❌ 部署失败:', error.message)
process.exit(1)
}
}
// 命令行处理
const command = process.argv[2]
switch (command) {
case 'rollback':
const version = process.argv[3]
if (!version) {
console.error('请指定版本号')
process.exit(1)
}
versionManager.rollback(version)
break
case 'status':
versionManager.printStatus()
break
default:
deploy()
}
9.3 Package.json配置
{
"name": "my-app",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"deploy": "node scripts/deploy.js",
"deploy:rollback": "node scripts/deploy.js rollback",
"deploy:status": "node scripts/deploy.js status"
}
}
10. 真实场景演示:从问题到解决 {#10-真实场景}
让我们通过一个完整的真实场景,看看这套方案如何运作:
10.1 场景设定
你的应用:在线购物App
- 日活用户:10万
- 服务器:阿里云上海
- 用户分布:全国各地
- 当前版本:v2.3.5
10.2 问题发生
时间线:周五下午
15:00 【运营】发现优惠券计算有bug
→ 用户多领了优惠券!
15:10 【开发】紧急修复bug,准备发布 v2.3.6
15:15 【担心】现在还有5万用户在线
→ 如果直接发版,会不会出问题?
传统方案的问题
传统部署(直接替换):
15:20 - 发布 v2.3.6,删除 v2.3.5 的文件
15:21 - 用户A(北京) 打开App(加载了 v2.3.5 的HTML)
15:22 - 用户A 点击"商品详情"
→ 需要加载 ProductDetail.abc123.js
→ 服务器上已经没有这个文件了
→ ❌ 404 Not Found
→ 页面白屏,用户投诉!
10.3 使用我们的方案
部署过程
# 开发修复完bug
git add .
git commit -m "fix: 修复优惠券计算错误"
git tag v2.3.6
# 一键部署
npm run deploy
后台发生了什么
━━━━━━━━━━ 部署前的状态 ━━━━━━━━━━
dist/
├── current/
│ └── index.html (引用 main.xyz789.js)
│
├── versions/
│ ├── v2.3.5/ ← 当前线上版本
│ ├── v2.3.4/
│ └── v2.3.3/
│
└── shared-assets/
├── main.xyz789.js (v2.3.5需要)
├── main.uvw456.js (v2.3.4需要)
├── main.rst123.js (v2.3.3需要)
├── ProductDetail.abc123.js (v2.3.5需要) ← 关键文件
├── ProductDetail.def456.js (v2.3.4需要)
└── vendor.ghi789.js (公共库)
━━━━━━━━━━ 执行 npm run deploy ━━━━━━━━━━
步骤1:备份当前版本
复制 current/ → versions/v2.3.5/ ✅
步骤2:更新为新版本
复制 build/ → current/
现在 current/index.html 引用 main.aaa111.js ✅
步骤3:同步资源(关键!)
扫描最近3个版本:v2.3.6, v2.3.5, v2.3.4
收集需要的文件:
- v2.3.6: main.aaa111.js, ProductDetail.bbb222.js
- v2.3.5: main.xyz789.js, ProductDetail.abc123.js ← 保留!
- v2.3.4: main.uvw456.js, ProductDetail.def456.js
- 公共库: vendor.ghi789.js
复制新文件:
✅ 新增 main.aaa111.js
✅ 新增 ProductDetail.bbb222.js
⏭️ 保留 main.xyz789.js
⏭️ 保留 ProductDetail.abc123.js ← 关键!
清理旧文件:
🗑️ 删除 main.rst123.js (v2.3.3的)
━━━━━━━━━━ 部署后的状态 ━━━━━━━━━━
dist/
├── current/
│ └── index.html (引用 main.aaa111.js) ← 新版本
│
├── versions/
│ ├── v2.3.6/ ← 新增
│ ├── v2.3.5/ ← 保留
│ ├── v2.3.4/ ← 保留
│ └── v2.3.3/ ← 保留
│
└── shared-assets/
├── main.aaa111.js (v2.3.6需要) ← 新增
├── main.xyz789.js (v2.3.5需要) ← 保留
├── main.uvw456.js (v2.3.4需要) ← 保留
├── ProductDetail.abc123.js (v2.3.5需要) ← 保留!
├── ProductDetail.bbb222.js (v2.3.6需要) ← 新增
├── ProductDetail.def456.js (v2.3.4需要) ← 保留
└── vendor.ghi789.js (公共库) ← 保留
用户体验
15:20 - 发布 v2.3.6
【用户A - 已经打开App的老用户】
15:21 - 点击"商品详情"
→ 请求 /assets/ProductDetail.abc123.js
→ CDN/服务器:文件存在!返回 ✅
→ 页面正常显示 ✅
15:25 - 刷新页面
→ 加载新的 HTML (v2.3.6)
→ 请求 /assets/main.aaa111.js
→ 开始使用新版本 ✅
【用户B - 新打开App的用户】
15:22 - 打开App
→ 加载新的 HTML (v2.3.6)
→ 请求 /assets/main.aaa111.js
→ 直接使用新版本 ✅
15:23 - 点击"商品详情"
→ 请求 /assets/ProductDetail.bbb222.js
→ 页面正常显示 ✅
【用户C - 网络很慢的用户】
15:19 - 开始加载App(v2.3.5的HTML)
15:25 - HTML终于加载完(此时已经发版)
→ 请求 /assets/main.xyz789.js
→ CDN/服务器:文件存在!返回 ✅
→ 页面正常显示 ✅
10.4 突发情况:发现新版本有bug
15:40 【客服】收到投诉:新版本的支付流程有问题!
15:41 【决定】立即回滚到 v2.3.5
# 执行回滚
npm run deploy rollback v2.3.5
回滚过程
━━━━━━━━━━ 回滚执行 ━━━━━━━━━━
步骤1:恢复版本
复制 versions/v2.3.5/ → current/ ✅
步骤2:重新同步资源
扫描最近3个版本:v2.3.5, v2.3.4, v2.3.3
(v2.3.6的文件会被清理,但v2.3.6的版本档案保留)
━━━━━━━━━━ 回滚完成 ━━━━━━━━━━
耗时:5秒
影响:0个用户(所有版本的文件都在)
10.5 CDN的作用体现
场景:全国10万用户同时访问
【没有CDN】
10万个请求 → 全部打到上海服务器
服务器压力:⚠️⚠️⚠️ 可能宕机
用户延迟:
- 北京用户:300ms
- 广州用户:200ms
- 新疆用户:500ms
【有CDN】
第1波请求(各CDN节点回源):
- CDN北京节点 → 上海源站(缓存)
- CDN广州节点 → 上海源站(缓存)
- CDN新疆节点 → 上海源站(缓存)
约100个请求打到源站
后续请求(命中CDN缓存):
- 99900个请求从本地CDN节点返回
服务器压力:✅✅✅ 轻松
用户延迟:
- 北京用户:20ms(从CDN北京节点)
- 广州用户:15ms(从CDN广州节点)
- 新疆用户:25ms(从CDN新疆节点)
10.6 完整技术链路图
用户打开App
↓
DNS解析 → CDN节点IP
↓
【CDN节点(北京)】
↓
检查缓存
├─ 有缓存 → 直接返回(5ms)✅
└─ 无缓存 ↓
↓
回源到【OSS对象存储】
↓
读取 shared-assets/main.xyz789.js
↓
返回给CDN → CDN缓存
↓
CDN返回给用户(首次:50ms,后续:5ms)✅
用户点击按钮
↓
动态加载 ProductDetail.abc123.js
↓
【CDN节点】已有缓存
↓
秒返(5ms)✅
10.7 成本对比
传统方案 vs 我们的方案
【存储成本】
传统:只保留1个版本
- 磁盘占用:200MB
- 月成本:免费(服务器自带磁盘)
我们的方案:保留3个版本的资源
- OSS存储:约500MB(去重后)
- 月成本:500MB × 0.12元/GB = 0.06元
- 增加成本:几乎可以忽略
【人力成本】
传统方案:
- 发版时间:30分钟(担心出问题)
- 出问题回滚:15分钟
- 处理用户投诉:2小时
- 总成本:约500元/次
我们的方案:
- 发版时间:1分钟(一键部署)
- 出问题回滚:5秒
- 用户投诉:0
- 总成本:几乎为0
【稳定性收益】
传统方案:
- 用户体验:⭐⭐(经常白屏)
- 投诉率:5%
- 用户流失:不可估量
我们的方案:
- 用户体验:⭐⭐⭐⭐⭐(零感知发版)
- 投诉率:0%
- 用户留存:显著提升
11. 常见问题与解决方案 {#11-常见问题}
11.1 问题:用户看不到最新版本
原因:
- HTML文件被浏览器缓存
- CDN缓存未刷新
解决方案:
# 1. Nginx配置HTML短期缓存
location / {
alias /var/www/current/;
expires 5m;
add_header Cache-Control "public, must-revalidate";
}
# 2. 添加ETag和Last-Modified
location / {
alias /var/www/current/;
etag on;
}
// 3. 前端版本检测
setInterval(async () => {
const response = await fetch('/api/version')
const { version } = await response.json()
if (version !== window.__APP_VERSION__) {
// 提示用户刷新
showUpdateNotification()
}
}, 5 * 60 * 1000) // 5分钟检查一次
11.2 问题:资源文件404
排查步骤:
# 1. 检查shared-assets目录
ls -lh dist/shared-assets/
# 2. 检查resource-manifest.json
cat dist/resource-manifest.json
# 3. 检查nginx配置
nginx -t
nginx -s reload
# 4. 查看nginx日志
tail -f /var/log/nginx/error.log
常见原因:
- 文件未上传到OSS
- CDN回源失败
- 文件名不匹配
11.3 问题:缓存没生效
检查缓存头:
curl -I https://cdn.example.com/assets/main.abc123.js
# 应该看到:
# Cache-Control: public, max-age=31536000, immutable
# Expires: [未来日期]
优化:
location /assets/ {
alias /var/www/shared-assets/;
# 强缓存
expires 1y;
add_header Cache-Control "public, immutable";
# 禁用协商缓存(提高效率)
etag off;
if_modified_since off;
}
11.4 问题:磁盘空间不足
监控脚本:
#!/bin/bash
# scripts/check-disk.sh
THRESHOLD=80
usage=$(df -h /var/www | tail -1 | awk '{print $5}' | sed 's/%//')
if [ $usage -gt $THRESHOLD ]; then
echo "⚠️ 磁盘使用率: ${usage}%"
# 清理旧版本(保留最近5个)
cd /var/www/my-app/dist/versions
ls -t | tail -n +6 | xargs rm -rf
echo "✅ 清理完成"
fi
11.5 问题:部署中断怎么办?
原子性部署:
async function atomicDeploy(buildDir, version) {
const tempDir = path.join(this.distDir, `temp-${Date.now()}`)
try {
// 1. 复制到临时目录
fs.cpSync(buildDir, tempDir, { recursive: true })
// 2. 验证文件完整性
await validateBuild(tempDir)
// 3. 原子性替换
const oldCurrentDir = path.join(this.distDir, `current-old-${Date.now()}`)
fs.renameSync(this.currentDir, oldCurrentDir)
fs.renameSync(tempDir, this.currentDir)
// 4. 删除旧目录
fs.rmSync(oldCurrentDir, { recursive: true })
} catch (error) {
// 回滚
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true })
}
throw error
}
}
总结
通过本文,你应该完整掌握了:
✅ 核心概念
- 构建、哈希、源站、OSS、CDN、Nginx、代理
✅ 版本管理
- package.json版本号规则
- Git标签管理
- 3版本共存方案
✅ 部署流程
- 本地构建 → 版本备份 → 资源同步 → OSS上传 → CDN刷新
✅ 零404方案
- 共享资源目录
- 多版本资源保留
- 智能清理过期文件
✅ 缓存优化
- 内容哈希保证缓存有效
- 不同文件类型的缓存策略
- CDN缓存配置
✅ 生产部署
- Nginx完整配置
- OSS集成
- CDN加速
- 监控和回滚
现在,你的前端应用具备了企业级的部署能力!🎉
12. 进阶方案:动态HTML路由 {#12-进阶方案}
💡 核心思想:HTML是一切的入口,掌控HTML就掌控了版本!
前面我们讲的方案是"文件切换",现在我们来看更强大的"路由切换"。
12.1 从文件切换到路由切换
基础方案(文件切换)
用户访问 www.example.com
↓
Nginx返回: /var/www/current/index.html
↓
HTML引用: <script src="/assets/main.abc123.js"></script>
特点:
- ✅ 简单直接
- ✅ 易于理解
- ❌ 灵活性有限(所有用户看到同一个版本)
进阶方案(路由切换)
用户访问 www.example.com
↓
OpenResty/Node.js识别:
- Cookie中的版本号?
- HTTP Header中的标记?
- 用户ID?
↓
动态返回:
- 90%用户 → v1.5.2的HTML
- 10%用户 → v1.5.3的HTML(灰度)
- 员工 → v1.6.0的HTML(预览)
特点:
- ✅ 灵活控制(精确到单个用户)
- ✅ 秒级切换(无需部署)
- ✅ 支持AB测试、灰度发布
- ⚠️ 稍微复杂一点
12.2 为什么需要进阶方案?
场景1:灰度发布
传统方式(全量发布)
15:00 - 发布新版本
15:01 - 10万用户同时切换到新版本
15:05 - 发现bug!但已经影响10万用户
进阶方式(灰度发布)
15:00 - 10%用户(1万人)先试用新版本
15:30 - 数据监控正常,扩大到30%
16:00 - 继续正常,扩大到100%
如果发现问题:
立即调整为0%,只影响1万人
场景2:AB测试
产品经理:我想测试两个设计方案,看哪个转化率更高
方案A:红色购买按钮
方案B:绿色购买按钮
传统方式:需要发布两次,分别统计
进阶方式:同时运行,50%用户看A,50%看B,实时对比
场景3:上线预览
场景:新版本开发完成,想让老板提前看看
传统方式:
- 给老板发个测试环境链接
- 测试环境可能数据不全
- 环境不稳定
进阶方式:
- 老板访问正式环境
- 识别老板的Cookie,返回新版本HTML
- 使用真实数据,体验完全一致
12.3 技术选型对比
方案1:OpenResty(Nginx + Lua)
优势:
- ⚡ 极快(直接在Nginx层处理)
- 🔧 无需额外服务(就是Nginx的增强版)
- 💪 高性能(单机可处理10万+ QPS)
劣势:
- 📚 需要学习Lua语言
- 🔨 调试相对麻烦
适用场景:
- 超大流量应用
- 对性能要求极高
- 运维团队熟悉Nginx
方案2:Node.js中间层
优势:
- 🎯 前端友好(JavaScript)
- 🛠️ 易于开发和调试
- 🔄 灵活性强
劣势:
- 🐢 性能稍逊(但对大多数应用足够)
- 🏗️ 需要额外部署Node服务
适用场景:
- 中小型应用
- 团队熟悉JavaScript
- 需要快速迭代
12.4 实战1:OpenResty实现灰度发布
安装OpenResty
# CentOS/RHEL
yum install -y openresty
# Ubuntu/Debian
apt-get install -y openresty
Nginx配置
# /etc/openresty/nginx.conf
http {
# 共享内存,用于存储版本配置
lua_shared_dict version_config 10m;
# 初始化脚本(服务器启动时执行一次)
init_by_lua_block {
-- 默认配置:90%用户使用v1.5.2,10%用户灰度v1.5.3
local config = {
default_version = "v1.5.2",
versions = {
{version = "v1.5.2", weight = 90},
{version = "v1.5.3", weight = 10}
}
}
local cjson = require "cjson"
local version_config = ngx.shared.version_config
version_config:set("config", cjson.encode(config))
ngx.log(ngx.NOTICE, "版本配置已加载")
}
server {
listen 80;
server_name www.example.com;
# HTML入口
location = / {
content_by_lua_block {
local cjson = require "cjson"
local version_config = ngx.shared.version_config
-- 读取配置
local config_str = version_config:get("config")
local config = cjson.decode(config_str)
-- 获取用户版本
local user_version = nil
-- 1. 检查Cookie(用户已经分配过版本)
local cookie_version = ngx.var.cookie_app_version
if cookie_version then
user_version = cookie_version
ngx.log(ngx.INFO, "使用Cookie版本: ", user_version)
else
-- 2. 检查特殊标记(员工预览)
local preview_token = ngx.var.cookie_preview_token
if preview_token == "secret_token_2024" then
user_version = "v1.6.0" -- 预览版本
ngx.log(ngx.INFO, "员工预览模式: ", user_version)
else
-- 3. 灰度分配(根据权重随机)
local random = math.random(100)
local sum = 0
for _, v in ipairs(config.versions) do
sum = sum + v.weight
if random <= sum then
user_version = v.version
break
end
end
if not user_version then
user_version = config.default_version
end
ngx.log(ngx.INFO, "灰度分配版本: ", user_version)
-- 设置Cookie(下次直接使用)
ngx.header["Set-Cookie"] = "app_version=" .. user_version .. "; Path=/; Max-Age=86400"
end
end
-- 读取对应版本的HTML文件
local html_path = "/var/www/versions/" .. user_version .. "/index.html"
local file = io.open(html_path, "r")
if file then
local content = file:read("*all")
file:close()
-- 返回HTML
ngx.header["Content-Type"] = "text/html; charset=utf-8"
ngx.header["Cache-Control"] = "no-cache, no-store, must-revalidate"
ngx.say(content)
else
ngx.status = 500
ngx.say("版本文件不存在: " .. user_version)
end
}
}
# 静态资源(从共享目录)
location /assets/ {
alias /var/www/shared-assets/;
expires 1y;
add_header Cache-Control "public, immutable";
}
# 管理接口:调整灰度比例
location /api/version/update {
content_by_lua_block {
-- 只允许内网访问
local client_ip = ngx.var.remote_addr
if not string.match(client_ip, "^192%.168%.") and
not string.match(client_ip, "^10%.") then
ngx.status = 403
ngx.say("Forbidden")
return
end
-- 读取请求体
ngx.req.read_body()
local body = ngx.req.get_body_data()
if not body then
ngx.status = 400
ngx.say("Missing body")
return
end
local cjson = require "cjson"
local new_config = cjson.decode(body)
-- 更新配置
local version_config = ngx.shared.version_config
version_config:set("config", cjson.encode(new_config))
ngx.log(ngx.NOTICE, "版本配置已更新: ", body)
ngx.status = 200
ngx.say("OK")
}
}
# 管理接口:查看当前配置
location /api/version/status {
content_by_lua_block {
local version_config = ngx.shared.version_config
local config_str = version_config:get("config")
ngx.header["Content-Type"] = "application/json"
ngx.say(config_str)
}
}
}
}
灰度控制脚本
#!/bin/bash
# scripts/update-gray-scale.sh
# 调整灰度比例
function set_gray_scale() {
local v1_weight=$1
local v2_weight=$2
curl -X POST http://localhost/api/version/update \
-H "Content-Type: application/json" \
-d '{
"default_version": "v1.5.2",
"versions": [
{"version": "v1.5.2", "weight": '$v1_weight'},
{"version": "v1.5.3", "weight": '$v2_weight'}
]
}'
echo "✅ 灰度比例已更新: v1.5.2($v1_weight%) / v1.5.3($v2_weight%)"
}
# 查看当前状态
function show_status() {
curl http://localhost/api/version/status | jq '.'
}
# 使用示例
case $1 in
"10")
set_gray_scale 90 10
;;
"30")
set_gray_scale 70 30
;;
"50")
set_gray_scale 50 50
;;
"100")
set_gray_scale 0 100
;;
"rollback")
set_gray_scale 100 0
;;
"status")
show_status
;;
*)
echo "用法: $0 {10|30|50|100|rollback|status}"
;;
esac
12.5 实战2:Node.js实现版本路由
创建 server-with-version.js:
const express = require('express')
const fs = require('fs')
const path = require('path')
const cookieParser = require('cookie-parser')
const app = express()
app.use(cookieParser())
app.use(express.json())
// 版本配置(可以从数据库或Redis读取)
let versionConfig = {
default: 'v1.5.2',
versions: [
{ version: 'v1.5.2', weight: 90 },
{ version: 'v1.5.3', weight: 10 }
],
// 特殊用户配置
userVersions: {
// 'user_12345': 'v1.6.0' // 特定用户使用特定版本
},
// 员工预览token
previewToken: 'secret_token_2024',
previewVersion: 'v1.6.0'
}
// 根据权重选择版本
function selectVersionByWeight(versions) {
const random = Math.random() * 100
let sum = 0
for (const v of versions) {
sum += v.weight
if (random <= sum) {
return v.version
}
}
return versions[0].version
}
// 获取用户版本
function getUserVersion(req) {
// 1. 检查预览token
if (req.cookies.preview_token === versionConfig.previewToken) {
console.log('✨ 员工预览模式')
return versionConfig.previewVersion
}
// 2. 检查用户ID(如果已登录)
const userId = req.cookies.user_id
if (userId && versionConfig.userVersions[userId]) {
console.log(`👤 用户 ${userId} 使用指定版本`)
return versionConfig.userVersions[userId]
}
// 3. 检查已分配的版本(Cookie)
if (req.cookies.app_version) {
console.log(`🔄 使用已分配版本: ${req.cookies.app_version}`)
return req.cookies.app_version
}
// 4. 灰度分配
const version = selectVersionByWeight(versionConfig.versions)
console.log(`🎲 灰度分配版本: ${version}`)
return version
}
// HTML入口
app.get('/', (req, res) => {
try {
// 获取用户版本
const version = getUserVersion(req)
// 读取对应版本的HTML
const htmlPath = path.join(__dirname, 'dist/versions', version, 'index.html')
if (!fs.existsSync(htmlPath)) {
return res.status(500).send(`版本文件不存在: ${version}`)
}
const html = fs.readFileSync(htmlPath, 'utf-8')
// 设置Cookie(记录用户版本)
if (!req.cookies.app_version || req.cookies.app_version !== version) {
res.cookie('app_version', version, {
maxAge: 24 * 60 * 60 * 1000, // 24小时
httpOnly: false
})
}
// 添加版本信息到HTML(方便调试)
const htmlWithVersion = html.replace(
'</head>',
`<script>window.__APP_VERSION__='${version}'</script></head>`
)
res.set('Content-Type', 'text/html')
res.set('Cache-Control', 'no-cache, no-store, must-revalidate')
res.send(htmlWithVersion)
// 记录日志
console.log(`📄 返回版本 ${version} 给用户 ${req.ip}`)
} catch (error) {
console.error('❌ 错误:', error)
res.status(500).send('Internal Server Error')
}
})
// 静态资源(从共享目录)
app.use('/assets', express.static(path.join(__dirname, 'dist/shared-assets'), {
maxAge: '1y',
immutable: true
}))
// API:更新灰度配置
app.post('/api/version/update', (req, res) => {
// 简单的认证(生产环境应该用更安全的方式)
const apiKey = req.headers['x-api-key']
if (apiKey !== process.env.ADMIN_API_KEY) {
return res.status(401).json({ error: 'Unauthorized' })
}
const newConfig = req.body
// 验证配置
if (!newConfig.versions || !Array.isArray(newConfig.versions)) {
return res.status(400).json({ error: 'Invalid config' })
}
// 更新配置
versionConfig = { ...versionConfig, ...newConfig }
console.log('✅ 版本配置已更新:', versionConfig)
res.json({ success: true, config: versionConfig })
})
// API:查看当前配置
app.get('/api/version/status', (req, res) => {
res.json({
config: versionConfig,
stats: getVersionStats()
})
})
// API:为特定用户分配版本
app.post('/api/version/assign', (req, res) => {
const apiKey = req.headers['x-api-key']
if (apiKey !== process.env.ADMIN_API_KEY) {
return res.status(401).json({ error: 'Unauthorized' })
}
const { userId, version } = req.body
if (!userId || !version) {
return res.status(400).json({ error: 'Missing userId or version' })
}
versionConfig.userVersions[userId] = version
console.log(`✅ 用户 ${userId} 已分配版本: ${version}`)
res.json({ success: true, userId, version })
})
// 统计各版本使用情况(简化版,生产环境应该用Redis或数据库)
const versionStats = {}
function getVersionStats() {
return versionStats
}
function recordVersionAccess(version) {
if (!versionStats[version]) {
versionStats[version] = 0
}
versionStats[version]++
}
// 健康检查
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
config: versionConfig
})
})
const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
console.log(`
🚀 服务器启动成功!
📍 端口: ${PORT}
📊 当前配置:
${JSON.stringify(versionConfig, null, 2)}
🔧 管理接口:
POST /api/version/update - 更新灰度配置
POST /api/version/assign - 为用户分配版本
GET /api/version/status - 查看配置和统计
`)
})
灰度控制CLI工具
创建 scripts/gray-control.js:
#!/usr/bin/env node
const axios = require('axios')
const API_KEY = process.env.ADMIN_API_KEY || 'your-secret-key'
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000'
const api = axios.create({
baseURL: SERVER_URL,
headers: {
'X-API-Key': API_KEY
}
})
// 调整灰度比例
async function setGrayScale(percentage) {
const v1Weight = 100 - percentage
const v2Weight = percentage
console.log(`🔄 设置灰度: v1.5.2(${v1Weight}%) / v1.5.3(${v2Weight}%)`)
try {
const response = await api.post('/api/version/update', {
versions: [
{ version: 'v1.5.2', weight: v1Weight },
{ version: 'v1.5.3', weight: v2Weight }
]
})
console.log('✅ 更新成功!')
console.log(response.data)
} catch (error) {
console.error('❌ 更新失败:', error.message)
}
}
// 查看状态
async function showStatus() {
try {
const response = await api.get('/api/version/status')
console.log('\n📊 当前状态:\n')
console.log(JSON.stringify(response.data, null, 2))
} catch (error) {
console.error('❌ 获取状态失败:', error.message)
}
}
// 为用户分配版本
async function assignVersion(userId, version) {
try {
const response = await api.post('/api/version/assign', {
userId,
version
})
console.log(`✅ 用户 ${userId} 已分配到版本 ${version}`)
} catch (error) {
console.error('❌ 分配失败:', error.message)
}
}
// 快捷命令
const commands = {
'0': () => setGrayScale(0), // 全部使用旧版本
'10': () => setGrayScale(10), // 10%灰度
'30': () => setGrayScale(30), // 30%灰度
'50': () => setGrayScale(50), // 50%灰度
'100': () => setGrayScale(100), // 全部使用新版本
'status': () => showStatus(),
'assign': () => {
const userId = process.argv[3]
const version = process.argv[4]
if (!userId || !version) {
console.error('用法: npm run gray assign <userId> <version>')
return
}
assignVersion(userId, version)
}
}
// 执行命令
const command = process.argv[2]
const handler = commands[command]
if (handler) {
handler()
} else {
console.log(`
🎮 灰度控制工具
用法:
npm run gray <command>
命令:
0 - 回滚到旧版本(0%灰度)
10 - 10%用户使用新版本
30 - 30%用户使用新版本
50 - 50%用户使用新版本
100 - 全量发布(100%新版本)
status - 查看当前状态
assign - 为指定用户分配版本
示例:
npm run gray 10
npm run gray status
npm run gray assign user_12345 v1.6.0
`)
}
Package.json配置
{
"scripts": {
"server:version": "node server-with-version.js",
"gray": "node scripts/gray-control.js"
},
"dependencies": {
"express": "^4.18.0",
"cookie-parser": "^1.4.6",
"axios": "^1.4.0"
}
}
12.6 实战3:灰度发布完整流程
场景:发布新版本 v1.5.3
# 第1步:构建并部署(使用前面的版本管理器)
npm run build
npm run deploy
# 第2步:启动版本路由服务器
npm run server:version
# 第3步:开始灰度(10%用户)
npm run gray 10
# 控制台输出:
# 🔄 设置灰度: v1.5.2(90%) / v1.5.3(10%)
# ✅ 更新成功!
# 第4步:观察数据(30分钟)
# - 错误率是否正常?
# - 性能是否达标?
# - 用户反馈如何?
# 第5步:如果正常,逐步放量
npm run gray 30 # 30%
# 观察30分钟...
npm run gray 50 # 50%
# 观察30分钟...
npm run gray 100 # 全量
# 如果发现问题,立即回滚
npm run gray 0 # 秒级回滚到旧版本!
实际效果
15:00 - 灰度10% (1万用户试用v1.5.3)
└─ 90%用户继续使用v1.5.2
15:30 - 数据监控正常,扩大到30% (3万用户)
16:00 - 继续正常,扩大到50% (5万用户)
16:30 - 稳定运行,全量发布100% (10万用户)
如果发现问题:
16:05 - 监控发现错误率上升!
16:06 - 执行 npm run gray 0
16:06 - 所有用户立即回到v1.5.2
16:06 - 共影响时间:1分钟,影响用户:3万人
对比传统全量发布:
如果15:00全量发布,16:05发现问题
需要重新构建部署,耗时15分钟
共影响10万用户×15分钟
12.7 实战4:AB测试
// 产品经理的需求:测试红色按钮 vs 绿色按钮
// 配置AB测试
const abTestConfig = {
name: 'button_color_test',
variants: [
{ id: 'A', version: 'v1.5.3-red', weight: 50 }, // 红色按钮
{ id: 'B', version: 'v1.5.3-green', weight: 50 } // 绿色按钮
]
}
// 用户访问时分配AB组
function assignABTest(req) {
// 检查是否已经分配过
let variant = req.cookies.ab_variant
if (!variant) {
// 随机分配
const random = Math.random() * 100
if (random < 50) {
variant = 'A'
} else {
variant = 'B'
}
// 保存到Cookie(保持用户体验一致)
res.cookie('ab_variant', variant, { maxAge: 30 * 24 * 60 * 60 * 1000 }) // 30天
}
return variant
}
// 前端埋点上报
// 在HTML中注入:
<script>
window.__AB_VARIANT__ = 'A'; // 或 'B'
// 用户点击购买按钮时上报
document.getElementById('buy-button').addEventListener('click', () => {
analytics.track('purchase_click', {
ab_variant: window.__AB_VARIANT__
});
});
</script>
// 7天后统计结果
A组(红色按钮):转化率 3.2%
B组(绿色按钮):转化率 4.1%
结论:绿色按钮效果更好,全量使用B版本!
12.8 实战5:员工预览
// 场景:v1.6.0开发完成,让产品经理提前体验
// 方式1:设置预览Cookie
// 开发者在浏览器控制台执行:
document.cookie = 'preview_token=secret_token_2024; path=/';
// 刷新页面,就能看到v1.6.0了!
// 方式2:预览链接
// 服务器识别URL参数
app.get('/', (req, res) => {
if (req.query.preview === 'secret_token_2024') {
// 设置预览Cookie
res.cookie('preview_token', 'secret_token_2024', {
maxAge: 24 * 60 * 60 * 1000
})
// 返回v1.6.0的HTML
return renderVersion('v1.6.0', res)
}
// ... 正常逻辑
})
// 分享预览链接给产品经理:
// https://www.example.com/?preview=secret_token_2024
// 优势:
// ✅ 使用真实环境和真实数据
// ✅ 体验和正式用户完全一致
// ✅ 无需部署测试环境
12.9 方案对比与选择
基础方案(文件切换)
适用场景:
- ✅ 中小型应用(日活 < 10万)
- ✅ 发版频率低(每周1-2次)
- ✅ 团队规模小(5人以下)
- ✅ 追求简单稳定
优势:
- 简单易懂
- 维护成本低
- 不依赖额外服务
劣势:
- 发版风险大(全量发布)
- 无法AB测试
- 回滚需要重新部署
进阶方案(路由切换)
适用场景:
- ✅ 大型应用(日活 > 10万)
- ✅ 发版频率高(每天多次)
- ✅ 需要灰度发布
- ✅ 需要AB测试
优势:
- 灵活控制版本
- 秒级灰度/回滚
- 支持AB测试
- 降低发版风险
劣势:
- 实现复杂度增加
- 需要额外服务(OpenResty或Node.js)
- 需要监控和统计系统
对比表格
| 功能 | 基础方案 | 进阶方案 |
|---|---|---|
| 零404 | ✅ | ✅ |
| 缓存优化 | ✅ | ✅ |
| 灰度发布 | ❌ | ✅ |
| AB测试 | ❌ | ✅ |
| 秒级回滚 | ❌ | ✅ |
| 员工预览 | ❌ | ✅ |
| 实现难度 | 简单 | 中等 |
| 维护成本 | 低 | 中 |
12.10 最佳实践
1. 监控告警
// 版本级别的监控
const versionMetrics = {
'v1.5.2': {
pv: 90000,
errorRate: 0.1,
avgLoadTime: 1.2
},
'v1.5.3': {
pv: 10000,
errorRate: 0.15, // ⚠️ 错误率上升!
avgLoadTime: 1.5 // ⚠️ 加载变慢!
}
}
// 自动告警
if (versionMetrics['v1.5.3'].errorRate > 0.12) {
alert('⚠️ v1.5.3错误率超过阈值,建议回滚!')
// 自动回滚
autoRollback('v1.5.3')
}
2. 灰度节奏
推荐的灰度节奏:
00:00 - 部署完成,灰度0%(准备)
00:05 - 灰度1%(内部员工测试)
01:00 - 灰度5%(观察1小时)
02:00 - 灰度10%(观察1小时)
03:00 - 灰度30%(观察1小时)
04:00 - 灰度50%(观察1小时)
05:00 - 灰度100%(全量)
关键点:
- 夜间流量低时开始
- 每个阶段充分观察
- 发现问题立即暂停
3. 版本管理
# 版本命名规范
v1.5.3 # 正式版本
v1.5.3-gray # 灰度版本
v1.5.3-red # AB测试A组
v1.5.3-green # AB测试B组
v1.6.0-beta # 内部预览版本
# 目录结构
dist/
├── versions/
│ ├── v1.5.2/ # 稳定版本
│ ├── v1.5.3/ # 灰度版本
│ ├── v1.5.3-red/ # AB测试版本
│ └── v1.6.0-beta/ # 预览版本
└── shared-assets/ # 共享资源
4. 日志记录
// 详细记录用户版本分配
function logVersionAccess(req, version) {
const log = {
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent: req.headers['user-agent'],
userId: req.cookies.user_id,
version: version,
previousVersion: req.cookies.app_version,
isGrayUser: req.cookies.app_version !== version
}
console.log(JSON.stringify(log))
// 发送到日志系统(ELK、Sentry等)
logger.info('version_access', log)
}
// 定期分析日志
// - 各版本的PV分布
// - 错误率对比
// - 性能指标对比
12.11 总结
通过动态HTML路由,我们实现了:
-
灵活的版本控制
- 从0%到100%任意调整
- 秒级生效,无需重启
-
降低发版风险
- 小范围灰度验证
- 发现问题快速回滚
-
支持AB测试
- 产品迭代更有数据支撑
- 精细化运营
-
更好的用户体验
- 平滑升级
- 零感知切换
核心理念:HTML是一切的入口,掌控HTML就掌控了版本!
13. 核心知识点总结 {#13-总结}
13.1 一句话理解核心概念
| 概念 | 一句话解释 | 比喻 |
|---|---|---|
| 构建 | 把源代码变成浏览器能运行的压缩文件 | 把食材做成菜 |
| 哈希 | 文件内容的"指纹",内容变就变 | 身份证号 |
| 源站 | 存放原始文件的服务器 | 工厂仓库 |
| OSS | 云端的无限存储空间 | 顺丰仓库 |
| CDN | 全国各地的缓存节点 | 连锁便利店 |
| Nginx | 流量分发和代理的工具 | 交通警察 |
| 资源超市 | 保留多版本资源的共享目录 | 超市货架 |
13.2 核心问题与解决方案
问题1:为什么会404?
用户打开的是旧HTML → 需要旧JS文件
但服务器已经发新版 → 删除了旧JS文件
结果:404 Not Found
解决方案:资源超市
保留最近3个版本的所有资源文件
旧用户访问旧文件 → 文件还在 → 正常显示 ✅
问题2:如何保证缓存有效?
如果每次发版文件路径都变:
/v1/main.js → /v2/main.js
即使内容没变,浏览器也要重新下载
解决方案:内容哈希
内容没变 → hash不变 → 文件名不变
/assets/vendor.abc123.js (v1.0用)
/assets/vendor.abc123.js (v1.1也用,因为内容一样)
浏览器缓存继续有效 ✅
13.3 技术链路图(完整)
开发阶段
├── 写代码 (src/)
├── Git提交
└── 打标签 (git tag v1.0.0)
↓
构建阶段
├── npm run build
├── 生成哈希文件名
└── 输出到 build/
↓
部署阶段(本地)
├── 备份当前版本 → versions/v1.0.0/
├── 更新 current/ 为新版本
└── 同步资源到 shared-assets/
↓
部署阶段(云端)
├── 上传 shared-assets/ → OSS
├── 上传 index.html → OSS
└── 刷新 CDN 缓存
↓
用户访问
├── DNS解析 → CDN节点IP
├── 请求 index.html (CDN缓存5分钟)
├── 请求 main.abc123.js (CDN缓存1年)
└── 页面正常显示 ✅
13.4 关键数字记忆
| 数字 | 含义 | 原因 |
|---|---|---|
| 3个版本 | shared-assets/ 保留最近3个版本的资源 | 覆盖99%的用户场景 |
| 10个版本 | versions/ 保留最近10个版本的完整档案 | 用于回滚和追溯 |
| 5分钟 | HTML文件的CDN缓存时间 | 快速更新 + 减少回源 |
| 1年 | JS/CSS文件的CDN缓存时间 | 最大化缓存效果 |
| 5秒 | 版本回滚耗时 | 极速恢复 |
13.5 命令速查表
# 构建
npm run build
# 部署
npm run deploy
# 回滚
npm run deploy rollback v1.0.0
# 查看状态
npm run deploy status
# 查看版本列表
npm run deploy list
# 手动同步资源
npm run deploy sync
# 启动本地服务器
npm run server
# 生产环境启动
npm run server:prod
13.6 实际收益对比
传统方案 vs 我们的方案
| 对比项 | 传统方案 | 我们的方案 | 提升 |
|---|---|---|---|
| 404率 | 5-10% | 0% | ✅ 100% |
| 发版速度 | 30分钟 | 1分钟 | ✅ 30倍 |
| 回滚速度 | 15分钟 | 5秒 | ✅ 180倍 |
| 缓存命中率 | 30% | 95% | ✅ 3倍 |
| 用户投诉 | 经常 | 几乎没有 | ✅ 无价 |
| 存储成本 | 0元 | 0.06元/月 | ❌ 可忽略 |
13.7 扩展阅读
想深入了解?可以继续学习:
-
HTTP缓存机制
- Cache-Control详解
- ETag和Last-Modified
- 强缓存vs协商缓存
-
CDN原理
- 回源策略
- 缓存预热
- 边缘计算
-
自动化部署
- CI/CD流程
- Docker容器化
- Kubernetes部署
-
监控告警
- 前端性能监控
- 错误追踪
- 用户行为分析
🎉 恭喜!
如果你读到这里,说明你已经完整理解了前端生产部署的全流程!
你现在掌握了:
- ✅ 版本管理的核心原理
- ✅ 零404的部署方案
- ✅ 缓存优化的最佳实践
- ✅ CDN加速的正确姿势
- ✅ 完整的技术实现代码
下一步:
- 在自己的项目中实践这套方案
- 根据实际情况调整参数(保留版本数、缓存时间等)
- 持续优化和监控
记住:好的技术方案不是最复杂的,而是最适合业务场景的!
如有问题,欢迎讨论交流!💪
火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。
更多推荐
所有评论(0)