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

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


📚 目录

  1. 前置知识:浏览器请求的生命周期
  2. 身份识别三剑客:URL、Cookie、Token
  3. [首次访问 vs 后续访问](#首次访问 vs 后续访问)
  4. [静态资源 vs 动态接口请求](#静态资源 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 参数 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

javascript 复制代码
// 浏览器请求
GET https://www.myshop.com
Headers:
  (无 Cookie)

服务器能拿到什么?

  • ✅ IP 地址
  • ✅ User-Agent
  • ✅ Referer (如果有)
  • ❌ Cookie (首次访问没有)
  • ❌ 用户 ID (还没登录)
  • ❌ Token (JS 还没执行)

服务器如何处理?

  1. 按比例或规则分配版本(如 10% 灰度)
  2. 在响应中写入 Cookie 标记用户
javascript 复制代码
// 服务器响应
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)

场景:用户第二天再次访问

javascript 复制代码
// 浏览器请求
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

javascript 复制代码
// 服务器处理优先级
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 复制代码
<!-- 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 可以 手动添加任何请求头
javascript 复制代码
// 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

javascript 复制代码
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 对象)

示例:简单请求

javascript 复制代码
// ✅ 简单请求: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)

示例:预检请求

javascript 复制代码
// 实际请求(会触发预检)
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 请求

http 复制代码
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 复制代码
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)

javascript 复制代码
// 自动处理预检请求
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

javascript 复制代码
// 前端配置
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

javascript 复制代码
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 服务器

javascript 复制代码
// ❌ 错误:浏览器的 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. 登录成功 ✓
       └──────────────────────────────────┘
后端实现
javascript 复制代码
// 数据库表: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 });
});
前端实现
javascript 复制代码
// 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 方案二:魔法链接(备选)

场景:无法使用摄像头,通过链接同步

javascript 复制代码
// 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 灰度分配策略

优先级顺序
javascript 复制代码
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);
}
比例分配算法
javascript 复制代码
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/
发布流程
bash 复制代码
# 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 配置

nginx 复制代码
server {
  listen 80;
  server_name www.myshop.com;
  
  # 1. 入口 HTML(短缓存)
  location / {
    root /var/www/dist/current;
    try_files $uri /index.html;
    
    # 不缓存 HTML
    add_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 前端检测版本更新

javascript 复制代码
// 方案 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 后端实现

javascript 复制代码
// 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 前端实现

javascript 复制代码
// 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 灰度回滚(部分用户)

javascript 复制代码
// 场景:只回滚灰度用户,不影响稳定版用户

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 完整代码实现

数据库设计
sql 复制代码
-- 用户版本映射表
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)
javascript 复制代码
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);
  
  // 设置 Cookie
  setVersionCookie(res, version);
  
  // 返回对应版本的 HTML
  const 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]
  );
  
  // 生成 Token
  const 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)
vue 复制代码
<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 发布脚本
bash 复制代码
#!/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_abc123
   Content: /versions/1.0.5/index.html

4. 浏览器加载:
   ✓ 解析 HTML
   ✓ 请求资源:/assets/chunk-xyz789.js
   ✓ 执行 JS,连接 WebSocket

5. 用户正常使用 v1.0.5
场景 2:用户第二天再次访问
复制代码
1. 用户访问 https://www.myshop.com
   └─ 浏览器请求:GET /
      Headers: Cookie: user_version=1.0.5; guest_id=guest_abc123

2. 后端处理:
   ✓ 读取 Cookie: user_version=1.0.5
   ✓ 直接返回对应版本

3. 用户继续使用 v1.0.5(版本保持一致)
场景 3:发现 Bug,紧急回滚
复制代码
1. 监控系统检测到错误率升高(v1.0.5)

2. 管理员执行回滚:
   POST /admin/rollback

3. 后端处理:
   ✓ 设置 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.4

6. 用户加载 v1.0.4,Bug 解决 ✓
场景 4:跨设备登录
复制代码
1. 用户在手机(A 设备)点击"同步到其他设备"

2. 前端请求:
   POST /api/account/create-sync-code
   Headers: Authorization: Bearer <token>

3. 后端生成同步码:
   ✓ auth_code: A3X9K2
   ✓ 有效期:5 分钟
   ✓ 返回前端

4. 前端生成二维码显示

5. 用户在电脑(B 设备)扫描二维码

6. B 设备请求:
   POST /api/account/sync
   Body: { 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 短缓存,静态资源长缓存

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

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

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

    javascript 复制代码
    hash(userId) % 100 < grayPercent
  5. WebSocket + 轮询双保险

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

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

    javascript 复制代码
    // ❌ 错误
    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: 可以。提供一个开关,让用户选择退出灰度:

javascript 复制代码
// 用户点击"退出灰度"
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)或统一网关:

javascript 复制代码
// Redis 存储
redis.set(`user:${userId}:version`, '1.0.5', 'EX', 86400);

// 其他服务读取
const version = await redis.get(`user:${userId}:version`);

Q5: 如何监控灰度效果?

A: 埋点统计:

javascript 复制代码
// 前端上报
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. 延伸阅读

推荐资源

相关技术

  • 特性开关(Feature Flags):比灰度发布更细粒度的功能控制
  • A/B 测试:对比不同版本的用户行为和转化率
  • 蓝绿部署:两套环境无缝切换
  • 金丝雀部署:灰度发布的别名

结语

前端灰度发布和用户身份识别是一个系统工程,涉及:

  • 浏览器机制:Cookie、LocalStorage、请求生命周期
  • 网络协议:HTTP 缓存、跨域、WebSocket
  • 后端架构:版本管理、资源聚合、数据库设计
  • 运维流程:CI/CD、监控告警、回滚机制

理解这些概念的核心原理,才能在实际项目中灵活运用,打造稳定、可控、用户体验良好的发布流程。

希望这篇指南能帮助你从零开始,掌握现代 Web 应用的身份识别与灰度发布技术!

💡 提示:本文档使用 Markdown 编写,推荐使用支持 Mermaid 图表的阅读器查看完整效果。
📝 贡献:如有问题或建议,欢迎提交 Issue 或 Pull Request。

相关推荐
JackieDYH2 小时前
CSS实现跑马灯效果-案例
前端·css·css3
羽沢312 小时前
Vue3组件间通信——pinia
前端·javascript·vue.js
BBB努力学习程序设计2 小时前
简易横向导航制作指南
前端·html
BBB努力学习程序设计2 小时前
深入理解CSS定位叠放次序:z-index完全指南
前端·html
头疼8462 小时前
vue 组件实现 、background-hover随鼠标丝滑移动~
前端
焦糖小布丁3 小时前
加http和https访问的网站不同?
前端
人工智能的苟富贵3 小时前
用 Rust 写一个前端项目辅助工具:JSON 格式化器
前端·rust·json
季春二九3 小时前
Edge 卸载工具 | 版本号1.0 | 专为彻底卸载Microsoft Edge设计
前端·microsoft·edge·edge 卸载工具
雨过天晴而后无语3 小时前
HTML中JS监听输入框值的即时变化
前端·javascript·html