🎯 本文用最通俗的语言,带你完整理解前端从开发到部署的全过程


目录

  1. 核心问题:为什么需要版本管理?
  2. 基础概念:小白必懂的名词解释
  3. 版本控制:package.json的作用
  4. 构建产物:打包后的文件长什么样?
  5. 部署策略:资源超市方案
  6. 对象存储OSS:文件的永久家园
  7. CDN加速:让用户飞快访问
  8. Nginx代理:流量的指挥官
  9. 完整实现:3版本共存方案
  10. 常见问题与解决方案

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>

// 浏览器缓存机制
用户A1次访问 → 从源站下载 main.abc123.js → 存到浏览器缓存
用户A2次访问 → 直接用浏览器缓存(超快!0延迟)
用户B1次访问 → 从源站下载 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的核心价值总结
  1. 多用户共享缓存 - 一人回源,万人受益
  2. 地理分布 - 全国/全球用户都能就近访问
  3. 保护源站 - 防止突发流量冲垮服务器
  4. 网络优化 - CDN有专线,路径更优
  5. 高可用 - 某个节点挂了,自动切换

结论:浏览器缓存解决"个人重复访问",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')
    重新同步资源()
  }
}

理解要点:

  1. 3个目录的关系

    current/        - 用户访问的当前版本
    versions/       - 历史版本备份(用于回滚)
    shared-assets/  - 所有版本共用的资源池
    
  2. 为什么要"同步"而不是"复制"?

    复制:把新版本的 assets/ 复制到 shared-assets/
          问题:旧版本的文件被覆盖了
    
    同步:收集最近3个版本需要的所有文件,都放到 shared-assets/
          优势:新旧版本的文件都在,零404!
    
  3. 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

常见原因:

  1. 文件未上传到OSS
  2. CDN回源失败
  3. 文件名不匹配

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路由,我们实现了:

  1. 灵活的版本控制

    • 从0%到100%任意调整
    • 秒级生效,无需重启
  2. 降低发版风险

    • 小范围灰度验证
    • 发现问题快速回滚
  3. 支持AB测试

    • 产品迭代更有数据支撑
    • 精细化运营
  4. 更好的用户体验

    • 平滑升级
    • 零感知切换

核心理念: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 扩展阅读

想深入了解?可以继续学习:

  1. HTTP缓存机制

    • Cache-Control详解
    • ETag和Last-Modified
    • 强缓存vs协商缓存
  2. CDN原理

    • 回源策略
    • 缓存预热
    • 边缘计算
  3. 自动化部署

    • CI/CD流程
    • Docker容器化
    • Kubernetes部署
  4. 监控告警

    • 前端性能监控
    • 错误追踪
    • 用户行为分析

🎉 恭喜!

如果你读到这里,说明你已经完整理解了前端生产部署的全流程!

你现在掌握了:

  • ✅ 版本管理的核心原理
  • ✅ 零404的部署方案
  • ✅ 缓存优化的最佳实践
  • ✅ CDN加速的正确姿势
  • ✅ 完整的技术实现代码

下一步:

  1. 在自己的项目中实践这套方案
  2. 根据实际情况调整参数(保留版本数、缓存时间等)
  3. 持续优化和监控

记住:好的技术方案不是最复杂的,而是最适合业务场景的!

如有问题,欢迎讨论交流!💪

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐