前端身份识别与灰度发布完整指南
A Complete Guide to User Identity and Gray Release in Web Applications
📚 目录
- 前置知识:浏览器请求的生命周期
 - 身份识别三剑客:URL、Cookie、Token
 - 首次访问 vs 后续访问
 - 静态资源 vs 动态接口请求
 - 跨域场景下的身份传递
 - 跨设备登录与账号同步
 - 单点登录(SSO)原理
 - 灰度发布:用户分流与版本管理
 - 回滚机制与前后端交互
 - 完整实战案例
 
1. 前置知识:浏览器请求的生命周期
1.1 从输入网址到页面显示
想象你在浏览器地址栏输入 https://www.myshop.com,会发生什么?
Step 1: 浏览器发送 HTTP 请求 (请求 HTML)↓
Step 2: 服务器返回 HTML 文档↓
Step 3: 浏览器解析 HTML,发现需要 CSS、JS、图片等资源↓
Step 4: 浏览器再次发送多个请求,获取这些资源↓
Step 5: 浏览器执行 JS 代码↓
Step 6: JS 可能发起 API 请求(如获取用户信息)
 
关键点:
- Step 1 和 Step 2:此时 JS 还没执行,浏览器拿不到 
localStorage的数据 - Step 4:静态资源请求(CSS、JS、图片)也是在 JS 执行之前
 - Step 6:只有到这一步,JS 才能手动读取 
localStorage并发送 API 请求 
1.2 核心认知
💡 浏览器请求 HTML 和静态资源时,JS 还没有执行,所以无法访问
localStorage、sessionStorage或 JS 变量。
这就是为什么我们需要 Cookie 和 URL 参数 来在初始阶段传递信息。
2. 身份识别三剑客:URL、Cookie、Token
2.1 三者对比
| 特性 | URL 参数 | Cookie | Token (LocalStorage) | 
|---|---|---|---|
| 自动发送 | ❌ 需要手动拼接 | ✅ 浏览器自动携带 | ❌ 需要 JS 手动添加到请求头 | 
| HTML 请求可用 | ✅ | ✅ | ❌ | 
| 静态资源请求可用 | ❌ | ✅ | ❌ | 
| API 请求可用 | ✅ | ✅ | ✅ | 
| 跨域传递 | ✅ (在 URL 中) | ❌ (默认不跨域) | ✅ (JS 手动设置) | 
| 安全性 | ⚠️ 低 (URL 可见) | ✅ 可设置 HttpOnly | ⚠️ 易受 XSS 攻击 | 
| 生命周期 | 一次性 | 可设置过期时间 | 永久 (除非清除) | 
2.2 三者的角色定位
┌─────────────┬──────────────┬─────────────────────────┐
│   技术      │    角色      │        比喻             │
├─────────────┼──────────────┼─────────────────────────┤
│ URL 参数    │  引导者      │  介绍信、秘密口令        │
│ Cookie      │  维持者      │  会员手环、门禁卡        │
│ Token       │  认证者      │  身份证、护照           │
└─────────────┴──────────────┴─────────────────────────┘
 
应用场景:
- URL 参数:首次访问引导、灰度版本指定、分享链接
 - Cookie:会话保持、自动登录、版本标记
 - Token:API 认证、跨域请求、服务间通信
 
3. 首次访问 vs 后续访问
3.1 第一次访问(匿名用户)
场景:用户首次访问 https://www.myshop.com
// 浏览器请求
GET https://www.myshop.com
Headers:(无 Cookie)
 
服务器能拿到什么?
- ✅ IP 地址
 - ✅ User-Agent
 - ✅ Referer (如果有)
 - ❌ Cookie (首次访问没有)
 - ❌ 用户 ID (还没登录)
 - ❌ Token (JS 还没执行)
 
服务器如何处理?
- 按比例或规则分配版本(如 10% 灰度)
 - 在响应中写入 Cookie 标记用户
 
// 服务器响应
HTTP/1.1 200 OK
Set-Cookie: user_version=1.0.5; Path=/; Max-Age=86400
Set-Cookie: user_id=guest_abc123; Path=/; Max-Age=86400
Content-Type: text/html<html>...</html>
 
3.2 第二次访问(已有 Cookie)
场景:用户第二天再次访问
// 浏览器请求
GET https://www.myshop.com
Headers:Cookie: user_version=1.0.5; user_id=guest_abc123
 
服务器能拿到什么?
- ✅ Cookie 中的 
user_version和user_id - ✅ 可以根据 Cookie 确定用户版本,保持一致性
 
关键差异:
第一次:没有 Cookie → 按规则分配 → 写入 Cookie
第二次:有 Cookie → 直接读取 → 保持版本一致
 
3.3 URL 参数指定版本
场景:测试人员访问 https://www.myshop.com?version=canary
// 服务器处理优先级
function getVersion(req) {// 1. 优先:URL 参数(方便测试)if (req.query.version) return req.query.version;// 2. 其次:Cookie(保持一致性)if (req.cookies.user_version) return req.cookies.user_version;// 3. 最后:比例分配(新用户)return randomProportionVersion();
}
 
4. 静态资源 vs 动态接口请求
4.1 静态资源请求(JS、CSS、图片)
特点:
- 通过 
<script src="...">,<link href="...">,<img src="...">标签加载 - 浏览器 只会自动携带 Cookie
 - 无法携带 LocalStorage 中的 Token
 
<!-- HTML 中的静态资源请求 -->
<script src="/assets/app.js"></script>
<link rel="stylesheet" href="/assets/style.css">
<img src="/assets/logo.png">
 
浏览器请求:
GET /assets/app.js
Headers:Cookie: user_version=1.0.5  ← 自动携带(无 Authorization header)   ← 无法携带 Token
 
鉴权方式:
- ✅ Cookie
 - ✅ Referer 检查
 - ✅ Origin / Host 检查
 - ✅ 签名 URL
 - ❌ Token (无法自动携带)
 
4.2 动态接口请求(API)
特点:
- 通过 
fetch()或XMLHttpRequest发起 - JS 可以 手动添加任何请求头
 
// JS 中的 API 请求
const token = localStorage.getItem('token');fetch('/api/user/profile', {method: 'GET',headers: {'Authorization': `Bearer ${token}`,  // 手动添加 Token'Content-Type': 'application/json'},credentials: 'include'  // 同时携带 Cookie
});
 
浏览器请求:
GET /api/user/profile
Headers:Cookie: user_version=1.0.5           ← 自动携带Authorization: Bearer eyJhbGc...    ← JS 手动添加
 
4.3 对照表
| 请求类型 | HTML 入口 | 静态资源 (JS/CSS/图片) | API 接口 | 
|---|---|---|---|
| JS 是否执行 | ❌ | ❌ | ✅ | 
| 自动携带 Cookie | ✅ | ✅ | ✅ | 
| 可携带 Token | ❌ | ❌ | ✅ | 
| 可用鉴权方式 | Cookie, URL | Cookie, Referer | Cookie, Token | 
5. 跨域场景下的身份传递
5.1 同域请求(最简单)
场景:前端 https://www.myshop.com 请求 https://www.myshop.com/api/user
fetch('https://www.myshop.com/api/user');
// Cookie 自动发送,无需任何配置
 
5.2 跨域请求的基础知识:简单请求 vs 预检请求
跨域请求分为两种类型:简单请求(Simple Request) 和 预检请求(Preflight Request)。
简单请求(Simple Request)
简单请求是浏览器可以直接发送的跨域请求,不会触发 OPTIONS 预检。
简单请求的条件(同时满足):
- 请求方法:
GET、POST、HEAD之一 - 请求头:只能是以下字段或浏览器自动添加的字段 
AcceptAccept-LanguageContent-LanguageContent-Type(且值只能是以下之一)text/plainmultipart/form-dataapplication/x-www-form-urlencoded
 - 无自定义请求头(除了上述允许的)
 - 无事件监听器(XMLHttpRequestUpload 对象)
 
示例:简单请求
// ✅ 简单请求:GET 方法,无自定义头
fetch('https://api.myshop.com/user');// ✅ 简单请求:POST + application/x-www-form-urlencoded
fetch('https://api.myshop.com/user', {method: 'POST',headers: {'Content-Type': 'application/x-www-form-urlencoded'},body: 'name=John&age=30'
});// ❌ 不是简单请求:自定义头 Authorization
fetch('https://api.myshop.com/user', {headers: {'Authorization': 'Bearer token123'  // 触发预检}
});// ❌ 不是简单请求:Content-Type 为 application/json
fetch('https://api.myshop.com/user', {method: 'POST',headers: {'Content-Type': 'application/json'  // 触发预检},body: JSON.stringify({ name: 'John' })
});
 
预检请求(Preflight Request)
预检请求是浏览器在发送实际请求前,先发送一个 OPTIONS 请求来询问服务器是否允许跨域。
触发预检请求的情况:
- 请求方法:
PUT、DELETE、PATCH等 - 自定义请求头:包含 
Authorization、X-Custom-Header等 - Content-Type:
application/json、application/xml等 - 其他非简单请求的特征
 
预检请求流程:
1. 浏览器发送 OPTIONS 预检请求↓
2. 服务器返回 CORS 响应头↓
3. 浏览器检查响应头,判断是否允许↓
4. 允许 → 发送实际请求不允许 → 报错(CORS policy)
 
示例:预检请求
// 实际请求(会触发预检)
fetch('https://api.myshop.com/user', {method: 'PUT',headers: {'Content-Type': 'application/json','Authorization': 'Bearer token123','X-Custom-Header': 'value'},body: JSON.stringify({ name: 'John' })
});
 
浏览器自动发送的 OPTIONS 请求:
OPTIONS /user HTTP/1.1
Host: api.myshop.com
Origin: https://www.myshop.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type,authorization,x-custom-header
 
服务器必须响应的头:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://www.myshop.com
Access-Control-Allow-Methods: PUT, POST, GET
Access-Control-Allow-Headers: Content-Type, Authorization, X-Custom-Header
Access-Control-Max-Age: 86400  // 预检结果缓存时间(秒)
 
后端配置示例(Express + CORS):
// 自动处理预检请求
app.use(cors({origin: 'https://www.myshop.com',credentials: true,methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],allowedHeaders: ['Content-Type', 'Authorization', 'X-Custom-Header'],maxAge: 86400  // 预检结果缓存 24 小时
}));
 
简单请求 vs 预检请求对比
| 特性 | 简单请求 | 预检请求 | 
|---|---|---|
| 请求次数 | 1 次 | 2 次(OPTIONS + 实际请求) | 
| 性能 | ✅ 更快 | ⚠️ 稍慢(多一次往返) | 
| 适用场景 | GET、POST 表单提交 | PUT、DELETE、JSON 提交、自定义头 | 
| 浏览器行为 | 直接发送 | 先发 OPTIONS 询问 | 
最佳实践:
- 🎯 优先使用简单请求:如果是 GET 或表单提交,尽量使用 
application/x-www-form-urlencoded - 📝 需要 JSON 时:接受预检请求的开销,使用 
application/json - ⚡ 优化预检请求:设置合理的 
Access-Control-Max-Age,减少预检频率 
5.3 跨域请求 Cookie(需要配置)
场景:前端 https://www.myshop.com 请求 https://api.myshop.com/user
// 前端配置
fetch('https://api.myshop.com/user', {credentials: 'include'  // 允许跨域携带 Cookie
});// 后端配置(必须)
app.use(cors({origin: 'https://www.myshop.com',  // 允许的源credentials: true  // 允许携带凭证
}));
 
注意:
- ⚠️ 跨域携带 Cookie 时,后端不能设置 
Access-Control-Allow-Origin: * - ✅ 必须指定具体的 
Origin - 💡 跨域 Cookie 请求可能是简单请求,也可能触发预检请求(取决于请求方式)
 
5.4 跨域请求 Token(更灵活)
场景:前端 https://www.myshop.com 请求 https://another-service.com/api/data
const token = localStorage.getItem('token');fetch('https://another-service.com/api/data', {method: 'GET',headers: {'Authorization': `Bearer ${token}`  // Token 可以跨域传递}
});// 后端只需验证 Token,无需特殊 CORS 配置
 
Token 的优势:
- ✅ 可以在多个服务之间传递
 - ✅ 不受同源策略限制
 - ✅ 服务器之间也可以传递
 
5.5 服务器之间调用
场景:A 服务器需要调用 B 服务器
// ❌ 错误:浏览器的 Cookie 不会自动传递给 B 服务器
// A 服务器
app.get('/proxy', (req, res) => {// req.cookies 是浏览器发给 A 的// 但 A 调用 B 时,这些 Cookie 不会自动发送axios.get('http://b-server.com/api/data');
});// ✅ 正确:手动传递 Token 或使用内部密钥
app.get('/proxy', async (req, res) => {const token = req.cookies.user_token;  // 从浏览器 Cookie 获取const response = await axios.get('http://b-server.com/api/data', {headers: {'Authorization': `Bearer ${token}`  // 手动添加到请求头}});res.json(response.data);
});
 
核心原则:
Cookie 是浏览器和服务器之间的约定,服务器之间通信必须手动传递 Token 或密钥。
6. 跨设备登录与账号同步
6.1 问题场景
用户在 A 设备(手机)已登录游客账号,现在想在 B 设备(电脑)继续使用,但不想输入账号密码。
6.2 方案一:二维码同步(推荐)
流程图
┌─────────────┐                    ┌─────────────┐
│  A 设备      │                    │  B 设备      │
│  (已登录)    │                    │  (新设备)    │
└──────┬──────┘                    └──────┬──────┘│                                  ││ 1. 点击"同步到新设备"              │├─────────────────────────────────►││                                  ││ 2. 生成一次性授权码                ││    auth_code = XYZ789            ││    (有效期 5 分钟)                ││                                  ││ 3. 生成二维码显示                 ││    [QR Code: XYZ789]             ││                                  ││◄─────────────────────────────────┤ 4. 扫描二维码│                                  │    获取 XYZ789│                                  ││                                  │ 5. 请求同步│                                  │    POST /api/account/sync│                                  │    { code: "XYZ789" }│                                  ││ 6. 后端验证                       ││    ✓ 授权码有效                   ││    ✓ 未过期                      ││    ✓ 查找对应账号                 ││                                  ││                                  │◄─ 7. 返回 Token│                                  │    { token: "..." }│                                  ││                                  │ 8. 登录成功 ✓└──────────────────────────────────┘
 
后端实现
// 数据库表:sync_codes
// | id | auth_code | guest_account_id | status | expires_at |// A 设备:生成同步码
app.post('/api/account/create-sync-code', async (req, res) => {const userId = req.user.id;  // 从 Token 获取const authCode = generateRandomCode();  // 生成 6 位随机码await db.insert('sync_codes', {auth_code: authCode,guest_account_id: userId,status: 'pending',expires_at: Date.now() + 5 * 60 * 1000  // 5 分钟后过期});res.json({ code: authCode });
});// B 设备:使用同步码登录
app.post('/api/account/sync', async (req, res) => {const { code } = req.body;// 1. 查找授权码const syncCode = await db.findOne('sync_codes', {auth_code: code,status: 'pending'});// 2. 验证if (!syncCode) return res.status(400).json({ error: 'Invalid code' });if (syncCode.expires_at < Date.now()) {return res.status(400).json({ error: 'Code expired' });}// 3. 标记为已使用await db.update('sync_codes', { id: syncCode.id }, { status: 'used' });// 4. 生成新 Token 给 B 设备const token = generateToken({ userId: syncCode.guest_account_id });res.json({ token });
});
 
前端实现
// A 设备:生成二维码
import QRCode from 'qrcode';async function createSyncQR() {const res = await fetch('/api/account/create-sync-code', {method: 'POST',headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`}});const { code } = await res.json();// 生成二维码const qrDataURL = await QRCode.toDataURL(code);document.getElementById('qr-code').src = qrDataURL;
}// B 设备:扫描并登录
async function scanAndSync(scannedCode) {const res = await fetch('/api/account/sync', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ code: scannedCode })});const { token } = await res.json();localStorage.setItem('token', token);// 登录成功,刷新页面location.reload();
}
 
6.3 方案二:魔法链接(备选)
场景:无法使用摄像头,通过链接同步
// A 设备:生成同步链接
const syncURL = `https://www.myshop.com/sync?token=abc123`;// 用户复制链接,在 B 设备打开// B 设备:检测 URL 参数
const urlParams = new URLSearchParams(window.location.search);
const syncToken = urlParams.get('token');if (syncToken) {// 自动调用同步接口syncAccount(syncToken);
}
 
安全加固:
- ✅ 链接有效期 1-2 分钟
 - ✅ A 设备二次确认(WebSocket 通知)
 - ✅ 明确警告:“此链接包含登录权限,请勿分享!”
 
7. 单点登录(SSO)原理
7.1 什么是单点登录
定义:在一个地方登录,多个系统都能访问。
场景:
- 登录 Google 账号,可以访问 Gmail、YouTube、Drive
 - 企业内部系统:登录 OA 后,ERP、CRM 都免登录
 
7.2 SSO 流程
┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│  应用 A       │    │  应用 B       │    │  认证中心     │
│  (未登录)     │    │  (未登录)     │    │  (SSO Server)│
└──────┬───────┘    └──────┬───────┘    └──────┬───────┘│                   │                   ││ 1. 访问应用 A      │                   ││───────────────────────────────────────►││                   │                   ││ 2. 发现未登录,重定向到认证中心           ││◄──────────────────────────────────────┤│   Redirect: https://sso.com/login     ││            ?redirect=https://app-a.com││                   │                   ││ 3. 用户输入账号密码 │                   ││───────────────────────────────────────►││                   │                   ││ 4. 认证成功,颁发 Ticket               ││◄──────────────────────────────────────┤│   Redirect: https://app-a.com         ││            ?ticket=TGT-123456         ││                   │                   ││ 5. 应用 A 验证 Ticket                  ││───────────────────────────────────────►││                   │                   ││ 6. 返回用户信息,登录成功               ││◄──────────────────────────────────────┤│                   │                   ││ ✓ 已登录           │                   ││                   │                   │└───────────────────┼───────────────────┘││ 7. 访问应用 B│────────────────────────►││                         ││ 8. 检测到 SSO Cookie     ││    自动登录,无需输入密码││◄────────────────────────┤│                         ││ ✓ 已登录                │
 
7.3 核心机制
认证中心(SSO Server):
- 统一管理用户登录状态
 - 颁发 Ticket 或 Token
 - 维护全局会话
 
应用系统:
- 检测到未登录,重定向到认证中心
 - 从 URL 获取 Ticket,向认证中心验证
 - 验证通过,创建本地会话
 
关键 Cookie:
TGC (Ticket Granting Cookie):存在认证中心域名下,表示用户已登录- 应用 A、B 都会检查这个 Cookie,实现免登录
 
8. 灰度发布:用户分流与版本管理
8.1 什么是灰度发布
定义:新版本只发布给部分用户,观察效果后再全量上线。
优势:
- ✅ 降低风险(出问题影响范围小)
 - ✅ 快速验证(真实用户反馈)
 - ✅ 可回滚(随时切回旧版本)
 
8.2 灰度分配策略
优先级顺序
function getUserVersion(req) {// 1. 优先:数据库固定分配(如内部员工)const fixedVersion = await db.getUserVersion(req.user?.id);if (fixedVersion) return fixedVersion;// 2. 其次:URL 参数(测试用)if (req.query.version) return req.query.version;// 3. 再次:Cookie 标记(保持一致性)if (req.cookies.user_version) return req.cookies.user_version;// 4. 最后:比例分配(新用户)return randomProportionVersion(req.ip, config.grayPercent);
}
 
比例分配算法
function randomProportionVersion(userId, grayPercent = 10) {// 方案 1:哈希取模(稳定性好,同一用户永远分到同一组)const hash = md5(userId);const num = parseInt(hash.substring(0, 8), 16);return (num % 100) < grayPercent ? '1.0.5' : '1.0.4';// 方案 2:随机数(灵活但不稳定)return Math.random() < (grayPercent / 100) ? '1.0.5' : '1.0.4';
}
 
8.3 版本资源管理
问题:旧版本资源 404
场景:
10:00 用户打开网页,加载 v1.0.4 的 HTML
10:10 发布 v1.0.5,删除 v1.0.4 的 JS/CSS
10:15 用户点击功能,请求 v1.0.4 的 chunk-abc123.js → 404 ❌
 
解决方案:资源聚合池
dist/
├─ current/              # 当前入口 HTML
│  ├─ index.html
│  └─ manifest.json
│
├─ assets/               # 最近 N 个版本的静态资源(聚合池)
│  ├─ chunk-abc123.js    # v1.0.4
│  ├─ chunk-def456.js    # v1.0.4
│  ├─ chunk-xyz789.js    # v1.0.5
│  └─ chunk-qwe321.js    # v1.0.5
│
└─ versions/             # 每个版本完整备份├─ 1.0.4/│  ├─ index.html│  └─ assets/└─ 1.0.5/├─ index.html└─ assets/
 
发布流程
# 1. 构建新版本
npm run build  # 产出 dist/# 2. 部署到 current/
cp -r dist/* server/dist/current/# 3. 聚合资源到 assets/(追加,不覆盖)
cp -r dist/assets/* server/dist/assets/# 4. 备份完整版本
cp -r dist/ server/dist/versions/1.0.5/# 5. 清理旧版本(保留最近 5 个版本)
cleanOldVersions(5);
 
8.4 Nginx 配置
server {listen 80;server_name www.myshop.com;# 1. 入口 HTML(短缓存)location / {root /var/www/dist/current;try_files $uri /index.html;# 不缓存 HTMLadd_header Cache-Control "no-cache, no-store, must-revalidate";}# 2. 静态资源(长缓存)location /assets/ {root /var/www/dist;expires 1y;add_header Cache-Control "public, immutable";}# 3. API 转发location /api/ {proxy_pass http://backend-server;proxy_set_header X-Real-IP $remote_addr;}
}
 
8.5 前端检测版本更新
// 方案 1:轮询检测
let currentVersion = window.APP_VERSION;  // 从 HTML 注入setInterval(async () => {const res = await fetch('/manifest.json');const data = await res.json();if (data.version !== currentVersion) {// 提示用户刷新showUpdateNotification();}
}, 60000);  // 每分钟检测一次// 方案 2:WebSocket 推送
const ws = new WebSocket('wss://www.myshop.com/version-notify');ws.onmessage = (event) => {const { version } = JSON.parse(event.data);if (version !== currentVersion) {showUpdateNotification();}
};// 刷新页面
function showUpdateNotification() {if (confirm('New version available. Refresh now?')) {location.reload(true);  // 强制刷新}
}
 
9. 回滚机制与前后端交互
9.1 为什么需要回滚
场景:
- 新版本上线后,发现严重 Bug
 - 错误率突然升高
 - 用户投诉激增
 
目标:
- 快速恢复到旧版本
 - 最小化用户影响
 
9.2 回滚流程
┌─────────────┐          ┌─────────────┐          ┌─────────────┐
│  监控系统    │          │  后端服务    │          │  前端用户    │
└──────┬──────┘          └──────┬──────┘          └──────┬──────┘│                        │                        ││ 1. 检测到错误率升高     │                        ││────────────────────────►│                        ││                        │                        ││                        │ 2. 决策回滚             ││                        │    切换版本配置         ││                        │    1.0.5 → 1.0.4       ││                        │                        ││                        │ 3. 清除用户版本映射     ││                        │    DELETE user_version  ││                        │                        ││                        │ 4. 通知所有在线用户     ││                        │───────────────────────►││                        │    WebSocket / 轮询     ││                        │    { action: "reload" } ││                        │                        ││                        │                        │ 5. 前端刷新│                        │◄───────────────────────┤│                        │    GET /index.html      ││                        │                        ││                        │ 6. 返回旧版本 HTML      ││                        │───────────────────────►││                        │    v1.0.4               ││                        │                        ││                        │                        │ 7. 加载旧版本资源│                        │◄───────────────────────┤│                        │    GET /assets/chunk... ││                        │                        ││                        │ 8. 返回旧版本资源       ││                        │───────────────────────►││                        │    (从 assets/ 获取)   ││                        │                        ││                        │                        │ ✓ 回滚完成
 
9.3 后端实现
// 1. 版本配置管理
const versionConfig = {current: '1.0.5',fallback: '1.0.4',grayPercent: 10,forceRollback: false  // 回滚开关
};// 2. 回滚 API
app.post('/admin/rollback', async (req, res) => {// 切换当前版本versionConfig.current = versionConfig.fallback;versionConfig.forceRollback = true;// 清除所有用户的版本映射await db.clearAllUserVersions();// 通知所有在线用户notifyAllUsers({ action: 'reload', version: versionConfig.current });res.json({ success: true, currentVersion: versionConfig.current });
});// 3. WebSocket 通知
function notifyAllUsers(message) {wss.clients.forEach(client => {if (client.readyState === WebSocket.OPEN) {client.send(JSON.stringify(message));}});
}// 4. 入口 HTML 返回
app.get('/', (req, res) => {const version = versionConfig.forceRollback ? versionConfig.fallback : getUserVersion(req);const htmlPath = `/var/www/dist/versions/${version}/index.html`;res.sendFile(htmlPath);
});
 
9.4 前端实现
// 1. WebSocket 连接
let ws;function connectVersionNotifier() {ws = new WebSocket('wss://www.myshop.com/version-notify');ws.onmessage = (event) => {const data = JSON.parse(event.data);if (data.action === 'reload') {handleVersionChange(data.version);}};ws.onclose = () => {// 断线重连setTimeout(connectVersionNotifier, 5000);};
}// 2. 处理版本变更
function handleVersionChange(newVersion) {// 保存当前页面状态const currentState = {path: location.pathname,scrollY: window.scrollY,formData: getFormData()  // 如果有表单};sessionStorage.setItem('pre-reload-state', JSON.stringify(currentState));// 显示通知showNotification('System updated, reloading...', 2000);// 延迟刷新setTimeout(() => {location.reload(true);  // 强制刷新,绕过缓存}, 2000);
}// 3. 恢复页面状态
window.addEventListener('load', () => {const savedState = sessionStorage.getItem('pre-reload-state');if (savedState) {const state = JSON.parse(savedState);// 恢复滚动位置window.scrollTo(0, state.scrollY);// 恢复表单数据restoreFormData(state.formData);// 清除状态sessionStorage.removeItem('pre-reload-state');}
});// 4. 备用:轮询检测
function pollVersionCheck() {setInterval(async () => {try {const res = await fetch('/api/current-version');const { version } = await res.json();if (version !== window.APP_VERSION) {handleVersionChange(version);}} catch (err) {console.error('Version check failed:', err);}}, 30000);  // 每 30 秒检测一次
}// 启动
connectVersionNotifier();
pollVersionCheck();
 
9.5 灰度回滚(部分用户)
// 场景:只回滚灰度用户,不影响稳定版用户app.post('/admin/rollback-gray', async (req, res) => {// 1. 查找所有灰度用户const grayUsers = await db.findAll('user_versions', {version: '1.0.5'});// 2. 将他们回滚到旧版本await db.updateMany('user_versions', { version: '1.0.5' },{ version: '1.0.4' });// 3. 只通知灰度用户grayUsers.forEach(user => {notifyUser(user.id, { action: 'reload', version: '1.0.4' });});res.json({ success: true, affectedUsers: grayUsers.length });
});
 
10. 完整实战案例
10.1 场景描述
项目:电商网站(www.myshop.com)
 目标:发布新版商品详情页,灰度 10% 用户
10.2 技术架构
┌─────────────────────────────────────────────────────┐
│                    用户浏览器                          │
│  ┌──────────┐  ┌──────────┐  ┌──────────────────┐   │
│  │ HTML     │  │ JS/CSS   │  │ localStorage     │   │
│  │ (短缓存)  │  │ (长缓存)  │  │ token: "..."     │   │
│  └──────────┘  └──────────┘  └──────────────────┘   │
└─────────────────────────────────────────────────────┘│                   │↓                   ↓
┌──────────────────────────────────────────────────────┐
│                    Nginx (网关)                       │
│  • 静态资源:/assets/ → 长缓存                         │
│  • 入口 HTML:/ → 短缓存                               │
│  • API 转发:/api/ → 后端服务                          │
└──────────────────────────────────────────────────────┘│↓
┌──────────────────────────────────────────────────────┐
│                  Node.js 后端服务                      │
│  • 用户身份识别                                        │
│  • 灰度版本分配                                        │
│  • API 业务逻辑                                        │
│  • WebSocket 推送                                     │
└──────────────────────────────────────────────────────┘│↓
┌──────────────────────────────────────────────────────┐
│                    MySQL 数据库                        │
│  • user_versions (用户版本映射)                        │
│  • sync_codes (同步码)                                │
│  • users (用户信息)                                    │
└──────────────────────────────────────────────────────┘
 
10.3 完整代码实现
数据库设计
-- 用户版本映射表
CREATE TABLE user_versions (id INT PRIMARY KEY AUTO_INCREMENT,user_id VARCHAR(255) NOT NULL,version VARCHAR(50) NOT NULL,created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,expires_at TIMESTAMP,INDEX idx_user_id (user_id)
);-- 同步码表(跨设备登录)
CREATE TABLE sync_codes (id INT PRIMARY KEY AUTO_INCREMENT,auth_code VARCHAR(20) NOT NULL UNIQUE,guest_account_id VARCHAR(255) NOT NULL,status ENUM('pending', 'used', 'expired') DEFAULT 'pending',created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,expires_at TIMESTAMP,INDEX idx_auth_code (auth_code)
);-- 用户表
CREATE TABLE users (id INT PRIMARY KEY AUTO_INCREMENT,username VARCHAR(255),email VARCHAR(255),password_hash VARCHAR(255),is_guest BOOLEAN DEFAULT TRUE,created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
 
后端服务(Node.js + Express)
const express = require('express');
const cookieParser = require('cookie-parser');
const WebSocket = require('ws');
const crypto = require('crypto');const app = express();
app.use(express.json());
app.use(cookieParser());// ========== 配置 ==========
const config = {versions: {stable: '1.0.4',canary: '1.0.5'},grayPercent: 10,  // 灰度比例forceRollback: false
};// ========== 灰度分配逻辑 ==========
async function getUserVersion(req) {// 1. 强制回滚if (config.forceRollback) {return config.versions.stable;}// 2. URL 参数指定if (req.query.version) {setVersionCookie(res, req.query.version);return req.query.version;}// 3. Cookie 已分配if (req.cookies.user_version) {return req.cookies.user_version;}// 4. 数据库固定分配if (req.user) {const dbVersion = await db.query('SELECT version FROM user_versions WHERE user_id = ?',[req.user.id]);if (dbVersion.length > 0) {return dbVersion[0].version;}}// 5. 比例分配const userId = req.cookies.guest_id || generateGuestId();const version = hashDistribute(userId, config.grayPercent);return version;
}// 哈希分配算法
function hashDistribute(userId, grayPercent) {const hash = crypto.createHash('md5').update(userId).digest('hex');const num = parseInt(hash.substring(0, 8), 16);return (num % 100) < grayPercent ? config.versions.canary : config.versions.stable;
}// 设置版本 Cookie
function setVersionCookie(res, version) {res.cookie('user_version', version, {maxAge: 24 * 60 * 60 * 1000,  // 24 小时httpOnly: false,  // 允许 JS 读取sameSite: 'lax'});
}// ========== 路由 ==========// 入口 HTML
app.get('/', async (req, res) => {const version = await getUserVersion(req);// 设置 CookiesetVersionCookie(res, version);// 返回对应版本的 HTMLconst htmlPath = `/var/www/dist/versions/${version}/index.html`;res.sendFile(htmlPath);
});// 当前版本 API(前端轮询)
app.get('/api/current-version', (req, res) => {res.json({ version: config.forceRollback ? config.versions.stable : config.versions.canary });
});// 回滚 API(管理员)
app.post('/admin/rollback', async (req, res) => {config.forceRollback = true;// 清除所有用户版本映射await db.query('DELETE FROM user_versions');// 通知所有在线用户notifyAllClients({ action: 'reload', version: config.versions.stable });res.json({ success: true });
});// 跨设备登录:生成同步码
app.post('/api/account/create-sync-code', authenticate, async (req, res) => {const authCode = Math.random().toString(36).substring(2, 8).toUpperCase();const expiresAt = new Date(Date.now() + 5 * 60 * 1000);await db.query('INSERT INTO sync_codes (auth_code, guest_account_id, expires_at) VALUES (?, ?, ?)',[authCode, req.user.id, expiresAt]);res.json({ code: authCode });
});// 跨设备登录:使用同步码
app.post('/api/account/sync', async (req, res) => {const { code } = req.body;const result = await db.query('SELECT * FROM sync_codes WHERE auth_code = ? AND status = "pending" AND expires_at > NOW()',[code]);if (result.length === 0) {return res.status(400).json({ error: 'Invalid or expired code' });}const syncCode = result[0];// 标记为已使用await db.query('UPDATE sync_codes SET status = "used" WHERE id = ?',[syncCode.id]);// 生成 Tokenconst token = generateToken({ userId: syncCode.guest_account_id });res.json({ token });
});// ========== WebSocket 推送 ==========
const wss = new WebSocket.Server({ port: 8080 });function notifyAllClients(message) {wss.clients.forEach(client => {if (client.readyState === WebSocket.OPEN) {client.send(JSON.stringify(message));}});
}// ========== 启动服务 ==========
app.listen(3000, () => {console.log('Server running on http://localhost:3000');
});
 
前端代码(Vue 3)
<template><div id="app"><!-- 版本更新提示 --><div v-if="showUpdateNotice" class="update-notice"><p>New version available!</p><button @click="reloadApp">Refresh Now</button></div><!-- 主内容 --><router-view /><!-- 跨设备同步 --><button @click="showSyncModal = true">Sync to Other Device</button><div v-if="showSyncModal" class="modal"><h3>Scan QR Code to Sync</h3><canvas ref="qrCanvas"></canvas><p>Code: {{ syncCode }}</p><button @click="closeSyncModal">Close</button></div></div>
</template><script setup>
import { ref, onMounted } from 'vue';
import QRCode from 'qrcode';// ========== 版本检测 ==========
const showUpdateNotice = ref(false);
const currentVersion = window.APP_VERSION;// WebSocket 连接
let ws;function connectVersionNotifier() {ws = new WebSocket('ws://localhost:8080');ws.onmessage = (event) => {const data = JSON.parse(event.data);if (data.action === 'reload' && data.version !== currentVersion) {showUpdateNotice.value = true;}};ws.onclose = () => {setTimeout(connectVersionNotifier, 5000);};
}// 轮询备用
function pollVersionCheck() {setInterval(async () => {const res = await fetch('/api/current-version');const { version } = await res.json();if (version !== currentVersion) {showUpdateNotice.value = true;}}, 60000);
}// 刷新应用
function reloadApp() {// 保存状态sessionStorage.setItem('pre-reload-path', location.pathname);location.reload(true);
}// 恢复状态
onMounted(() => {const savedPath = sessionStorage.getItem('pre-reload-path');if (savedPath && savedPath !== location.pathname) {router.push(savedPath);sessionStorage.removeItem('pre-reload-path');}connectVersionNotifier();pollVersionCheck();
});// ========== 跨设备同步 ==========
const showSyncModal = ref(false);
const syncCode = ref('');
const qrCanvas = ref(null);async function generateSyncCode() {const token = localStorage.getItem('token');const res = await fetch('/api/account/create-sync-code', {method: 'POST',headers: {'Authorization': `Bearer ${token}`}});const { code } = await res.json();syncCode.value = code;// 生成二维码QRCode.toCanvas(qrCanvas.value, code, {width: 200,margin: 2});
}function closeSyncModal() {showSyncModal.value = false;syncCode.value = '';
}// 监听模态框打开
watch(showSyncModal, (newVal) => {if (newVal) {nextTick(() => {generateSyncCode();});}
});
</script><style scoped>
.update-notice {position: fixed;top: 20px;right: 20px;background: #4caf50;color: white;padding: 15px 20px;border-radius: 8px;box-shadow: 0 4px 12px rgba(0,0,0,0.15);z-index: 9999;
}.update-notice button {margin-top: 10px;background: white;color: #4caf50;border: none;padding: 8px 16px;border-radius: 4px;cursor: pointer;
}.modal {position: fixed;top: 50%;left: 50%;transform: translate(-50%, -50%);background: white;padding: 30px;border-radius: 12px;box-shadow: 0 8px 32px rgba(0,0,0,0.2);text-align: center;z-index: 9999;
}.modal canvas {margin: 20px 0;
}
</style>
 
CI/CD 发布脚本
#!/bin/bash# deploy.sh - 灰度发布脚本VERSION=$1
GRAY_PERCENT=${2:-10}echo "========== Deploying version $VERSION =========="# 1. 构建前端
echo "Building frontend..."
npm run build# 2. 部署到服务器
echo "Deploying to server..."# 备份完整版本
ssh user@server "mkdir -p /var/www/dist/versions/$VERSION"
scp -r dist/* user@server:/var/www/dist/versions/$VERSION/# 更新 current 入口
scp -r dist/index.html user@server:/var/www/dist/current/
scp -r dist/manifest.json user@server:/var/www/dist/current/# 聚合资源到 assets/
ssh user@server "cp -n /var/www/dist/versions/$VERSION/assets/* /var/www/dist/assets/ 2>/dev/null || true"# 3. 更新后端配置
echo "Updating backend config..."
ssh user@server "curl -X POST http://localhost:3000/admin/update-config \-H 'Content-Type: application/json' \-d '{\"canary\":\"$VERSION\",\"grayPercent\":$GRAY_PERCENT}'"# 4. 清理旧版本(保留最近 5 个)
echo "Cleaning old versions..."
ssh user@server "cd /var/www/dist/versions && ls -t | tail -n +6 | xargs rm -rf"echo "========== Deployment complete =========="
echo "Current version: $VERSION"
echo "Gray percent: $GRAY_PERCENT%"
 
10.4 完整流程演示
场景 1:新用户首次访问
1. 用户访问 https://www.myshop.com└─ 浏览器请求:GET /Headers: (无 Cookie)2. 后端处理:✓ 检测无 Cookie✓ 生成 guest_id: guest_abc123✓ 哈希分配:hash(guest_abc123) % 100 = 7 < 10✓ 分配版本:1.0.5 (灰度版)3. 后端响应:Set-Cookie: user_version=1.0.5; guest_id=guest_abc123Content: /versions/1.0.5/index.html4. 浏览器加载:✓ 解析 HTML✓ 请求资源:/assets/chunk-xyz789.js✓ 执行 JS,连接 WebSocket5. 用户正常使用 v1.0.5
 
场景 2:用户第二天再次访问
1. 用户访问 https://www.myshop.com└─ 浏览器请求:GET /Headers: Cookie: user_version=1.0.5; guest_id=guest_abc1232. 后端处理:✓ 读取 Cookie: user_version=1.0.5✓ 直接返回对应版本3. 用户继续使用 v1.0.5(版本保持一致)
 
场景 3:发现 Bug,紧急回滚
1. 监控系统检测到错误率升高(v1.0.5)2. 管理员执行回滚:POST /admin/rollback3. 后端处理:✓ 设置 forceRollback = true✓ 清除数据库中所有 user_versions✓ WebSocket 推送:{ action: 'reload', version: '1.0.4' }4. 所有在线用户收到通知:✓ 弹出提示:"System updated, reloading..."✓ 2 秒后自动刷新5. 用户刷新后:└─ 浏览器请求:GET /Headers: Cookie: user_version=1.0.5 (旧 Cookie)└─ 后端检测到 forceRollback=true✓ 忽略 Cookie✓ 强制返回 v1.0.46. 用户加载 v1.0.4,Bug 解决 ✓
 
场景 4:跨设备登录
1. 用户在手机(A 设备)点击"同步到其他设备"2. 前端请求:POST /api/account/create-sync-codeHeaders: Authorization: Bearer <token>3. 后端生成同步码:✓ auth_code: A3X9K2✓ 有效期:5 分钟✓ 返回前端4. 前端生成二维码显示5. 用户在电脑(B 设备)扫描二维码6. B 设备请求:POST /api/account/syncBody: { code: "A3X9K2" }7. 后端验证:✓ 同步码有效✓ 查找对应账号:guest_abc123✓ 生成新 Token 返回8. B 设备保存 Token,登录成功✓ localStorage.setItem('token', token)✓ 刷新页面,进入已登录状态9. A、B 设备现在共享同一账号 ✓
 
11. 总结与最佳实践
11.1 核心概念总结
| 概念 | 核心作用 | 生命周期 | 
|---|---|---|
| URL 参数 | 首次引导、版本指定 | 单次请求 | 
| Cookie | 会话保持、自动携带 | 可设置过期 | 
| Token | 身份认证、跨域传递 | 登录会话 | 
| 灰度发布 | 降低风险、逐步验证 | 发布周期 | 
| 回滚机制 | 快速恢复、止损 | 异常处理 | 
11.2 最佳实践
✅ DO(推荐)
-  
入口 HTML 短缓存,静态资源长缓存
location / {add_header Cache-Control "no-cache"; } location /assets/ {add_header Cache-Control "max-age=31536000, immutable"; } -  
保留历史版本资源,避免 404
dist/ ├─ assets/ # 聚合最近 5 个版本 └─ versions/ # 完整备份 -  
Cookie + Token 组合使用
- Cookie:HTML 请求、静态资源
 - Token:API 请求、跨域场景
 
 -  
灰度分配使用哈希算法,保持稳定性
hash(userId) % 100 < grayPercent -  
WebSocket + 轮询双保险
- 主要:WebSocket 实时推送
 - 备用:轮询兜底
 
 
❌ DON’T(避免)
-  
不要删除旧版本资源
# ❌ 错误:直接覆盖 rm -rf dist/assets/* cp new-assets/* dist/assets/# ✅ 正确:追加保留 cp -n new-assets/* dist/assets/ -  
不要在 URL 中传递敏感 Token
// ❌ 错误 fetch(`/api/data?token=${token}`);// ✅ 正确 fetch('/api/data', {headers: { 'Authorization': `Bearer ${token}` } }); -  
不要假设浏览器缓存会自动更新
- 始终考虑用户长时间不刷新的情况
 - 通过版本检测主动通知
 
 -  
不要在静态资源请求中依赖 LocalStorage
<script>和<link>无法携带 Token- 静态资源鉴权使用 Cookie 或 Referer
 
 
11.3 常见问题 FAQ
Q1: 用户清除了 Cookie,会不会重新分配版本?
A: 是的。Cookie 被清除后,用户下次访问会被视为新用户,重新走分配逻辑。可以通过数据库持久化用户 ID 和版本映射来避免。
Q2: 灰度用户能否手动切回稳定版?
A: 可以。提供一个开关,让用户选择退出灰度:
// 用户点击"退出灰度"
fetch('/api/opt-out-gray', { method: 'POST' });// 后端更新数据库
db.update('user_versions', { user_id }, { version: 'stable' });
 
Q3: Service Worker 会不会导致旧版本无法更新?
A: 可能会。建议:
- Service Worker 中检测版本变化,自动 
skipWaiting() - 或者不缓存 
index.html,只缓存静态资源 
Q4: 多个后端服务如何共享用户版本信息?
A: 使用共享存储(Redis)或统一网关:
// Redis 存储
redis.set(`user:${userId}:version`, '1.0.5', 'EX', 86400);// 其他服务读取
const version = await redis.get(`user:${userId}:version`);
 
Q5: 如何监控灰度效果?
A: 埋点统计:
// 前端上报
window.APP_VERSION = '1.0.5';function reportMetric(event, data) {fetch('/api/metrics', {method: 'POST',body: JSON.stringify({version: window.APP_VERSION,event,data,timestamp: Date.now()})});
}// 使用
reportMetric('page_view', { page: '/product/123' });
reportMetric('error', { message: err.message });
 
12. 延伸阅读
推荐资源
-  
灰度发布
- Martin Fowler - BlueGreenDeployment
 - Google SRE Book - Release Engineering
 
 -  
前端缓存策略
- MDN - HTTP Caching
 - Jake Archibald - The offline cookbook
 
 -  
身份认证
- JWT.io - Introduction to JSON Web Tokens
 - OWASP - Session Management Cheat Sheet
 
 
相关技术
- 特性开关(Feature Flags):比灰度发布更细粒度的功能控制
 - A/B 测试:对比不同版本的用户行为和转化率
 - 蓝绿部署:两套环境无缝切换
 - 金丝雀部署:灰度发布的别名
 
结语
前端灰度发布和用户身份识别是一个系统工程,涉及:
- 浏览器机制:Cookie、LocalStorage、请求生命周期
 - 网络协议:HTTP 缓存、跨域、WebSocket
 - 后端架构:版本管理、资源聚合、数据库设计
 - 运维流程:CI/CD、监控告警、回滚机制
 
理解这些概念的核心原理,才能在实际项目中灵活运用,打造稳定、可控、用户体验良好的发布流程。
希望这篇指南能帮助你从零开始,掌握现代 Web 应用的身份识别与灰度发布技术!
💡 提示:本文档使用 Markdown 编写,推荐使用支持 Mermaid 图表的阅读器查看完整效果。
📝 贡献:如有问题或建议,欢迎提交 Issue 或 Pull Request。
