当前位置: 首页 > news >正文

前端身份识别与灰度发布完整指南

A Complete Guide to User Identity and Gray Release in Web Applications


📚 目录

  1. 前置知识:浏览器请求的生命周期
  2. 身份识别三剑客:URL、Cookie、Token
  3. 首次访问 vs 后续访问
  4. 静态资源 vs 动态接口请求
  5. 跨域场景下的身份传递
  6. 跨设备登录与账号同步
  7. 单点登录(SSO)原理
  8. 灰度发布:用户分流与版本管理
  9. 回滚机制与前后端交互
  10. 完整实战案例

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 还没有执行,所以无法访问 localStoragesessionStorage 或 JS 变量。

这就是为什么我们需要 CookieURL 参数 来在初始阶段传递信息。


2. 身份识别三剑客:URL、Cookie、Token

2.1 三者对比

特性URL 参数CookieToken (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 还没执行)

服务器如何处理?

  1. 按比例或规则分配版本(如 10% 灰度)
  2. 在响应中写入 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_versionuser_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, URLCookie, RefererCookie, 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 预检

简单请求的条件(同时满足):

  1. 请求方法GETPOSTHEAD 之一
  2. 请求头:只能是以下字段或浏览器自动添加的字段
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type(且值只能是以下之一)
      • text/plain
      • multipart/form-data
      • application/x-www-form-urlencoded
  3. 无自定义请求头(除了上述允许的)
  4. 无事件监听器(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 请求来询问服务器是否允许跨域。

触发预检请求的情况

  1. 请求方法PUTDELETEPATCH
  2. 自定义请求头:包含 AuthorizationX-Custom-Header
  3. Content-Typeapplication/jsonapplication/xml
  4. 其他非简单请求的特征

预检请求流程

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)

  • 统一管理用户登录状态
  • 颁发 TicketToken
  • 维护全局会话

应用系统

  • 检测到未登录,重定向到认证中心
  • 从 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(推荐)
  1. 入口 HTML 短缓存,静态资源长缓存

    location / {add_header Cache-Control "no-cache";
    }
    location /assets/ {add_header Cache-Control "max-age=31536000, immutable";
    }
    
  2. 保留历史版本资源,避免 404

    dist/
    ├─ assets/      # 聚合最近 5 个版本
    └─ versions/    # 完整备份
    
  3. Cookie + Token 组合使用

    • Cookie:HTML 请求、静态资源
    • Token:API 请求、跨域场景
  4. 灰度分配使用哈希算法,保持稳定性

    hash(userId) % 100 < grayPercent
    
  5. WebSocket + 轮询双保险

    • 主要:WebSocket 实时推送
    • 备用:轮询兜底
❌ DON’T(避免)
  1. 不要删除旧版本资源

    # ❌ 错误:直接覆盖
    rm -rf dist/assets/*
    cp new-assets/* dist/assets/# ✅ 正确:追加保留
    cp -n new-assets/* dist/assets/
    
  2. 不要在 URL 中传递敏感 Token

    // ❌ 错误
    fetch(`/api/data?token=${token}`);// ✅ 正确
    fetch('/api/data', {headers: { 'Authorization': `Bearer ${token}` }
    });
    
  3. 不要假设浏览器缓存会自动更新

    • 始终考虑用户长时间不刷新的情况
    • 通过版本检测主动通知
  4. 不要在静态资源请求中依赖 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。

http://www.dtcms.com/a/565129.html

相关文章:

  • 使用扣子+飞书表格+影刀实现了自动化生成和发布 运营效率提升50倍
  • 智能做网站网页游戏传奇合击
  • h5科技 网站公众号小程序制作流程
  • 最小变更成本 vs 最小信息表达:第一性原理的比较
  • 在 Android 中使用协程(Coroutine)
  • 【​生命周期评价(LCA)】基于OpenLCA、GREET、R语言的生命周期评价方法、模型构建
  • 家用电器GB4706.1-2005质检报告办理
  • 有哪些网站主页做的比较好看汽车营销活动策划方案
  • 男女直接做的视频视频网站教育推广
  • 从HDFS NN报错看Flink+K8s+HDFS:基础、架构与问题关联
  • 一篇图文详解PID调参细节,实现PID入门到精通
  • 上位机与下位机(Host Computer/Slave Device)
  • SqlSugar查询字符串转成Int的问题
  • 网站免费做软件有哪些银川市建设诚信平台网站
  • Vue2 和 Vue3 生命周期的理解与对比
  • WinRAR“转换格式”功能详解:一键切换压缩包格式
  • 个人网站备案备注写什么济宁网站建设 企诺
  • 生活琐记(12)初七之月
  • 动态自优化的认知医疗层次激励编程技术架构(2025.10版)
  • 【005】使用DBeaver备份与还原mysql数据库
  • 生活方式与肥胖风险:多维度数据分析与预测模型研究
  • 南宁网站建设哪家好分销网站建设方案
  • 产品开发与创新方法论的系统应用与协同价值
  • 平衡二叉树-力扣
  • 常州工厂网站建设网站布局优化策略
  • 向量数据库对比
  • 配置Ubuntu20.04 x64平台上使用vcpkg交叉编译到Ubuntu20.04 ARM64的环境
  • Cocos Creator 和 Unity 3D 编辑界面字体样式大小调整
  • TensorFlow 2.x常用函数总结(持续更新)
  • 《备忘录模式:软件设计中的经典模式解析与应用》