灰度发布的核心思想是:新功能先放给一小部分用户使用,观察稳定后再逐步扩大范围,出问题则立即回滚。在 Vue 3 项目中,这套机制通常由两个部分组成:一是用户分组,解决「哪些人能看到新功能」的问题;二是功能开关,解决「某个功能是否启用」的问题。两者结合,就能在不重新发版的前提下,动态控制功能的可见范围,把发布风险降到最低。

一、用户分组:确定灰度流量的切分规则
用户分组是灰度发布的第一步,目标是把全量用户划分成若干互不重叠的集合,每次只让其中一部分进入新版本。最常用的方式是对用户唯一标识做哈希取模,比如用户 ID、设备号或者登录后下发的 uuid。这种方式的好处是结果稳定:同一个用户每次访问得到的分组结果一致,不会出现「上一次能看到新功能、刷新一次又消失了」的诡异体验。
下面是一个典型的分组工具函数,支持按百分比放量,也支持白名单直接命中:
/**
* 判断用户是否命中灰度
* @param {string} userId 用户唯一标识
* @param {number} percent 灰度百分比,0-100
* @param {string[]} whitelist 白名单用户
*/
export function isInGrayGroup(userId, percent, whitelist = []) {
if (!userId) return false;
// 白名单用户直接命中
if (whitelist.includes(userId)) return true;
// 简单字符串哈希
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = (hash * 31 + userId.charCodeAt(i)) % 100000;
}
// 映射到 0-99 的桶,桶号小于百分比则命中
return (hash % 100) < percent;
}这里故意没有直接用 userId % 100,因为真实业务中的用户 ID 往往有明显的规律性(比如纯数字自增 ID、带日期前缀的编号),直接取模会导致某一类用户集中落在同几个桶里,灰度样本产生偏差。先做一次字符串哈希再取模,可以让分布更均匀。如果对均匀性要求更高,可以换成 MD5 或 SHA 等成熟哈希算法。
除了哈希取模,还有一些场景化的分组方式:按地域灰度(先在某个城市试点)、按客户端版本灰度(只对 App 5.2 以上版本生效)、按用户属性灰度(比如只对会员开放)。这些规则可以组合使用,但要注意保持规则的确定性——同一个请求在任何一台服务器上计算出的结果必须一致,否则依赖服务端渲染或接口逻辑的功能会出现前后端展示不一致的问题。
二、功能开关:在 Vue 3 中控制功能的启停
有了分组结果,接下来要把它落到组件层面。Vue 3 推荐用组合式 API 封装一个响应式的开关管理器,任何组件都能通过 v-if 或组合式函数读取开关状态。先定义全局的开关上下文:
// flags.js
import { reactive } from 'vue';
// 全局开关状态,初始值来自构建时注入或首次接口返回
export const flagState = reactive({
'new-homepage': false, // 新版首页
'cart-recommend': false, // 购物车推荐
});
export function setFlag(key, value) {
if (key in flagState) {
flagState[key] = value;
}
}
// 组合式函数:组件内使用
export function useFlag(key) {
return computed(() => flagState[key]);
}在组件中使用非常直观,由于 flagState 是 reactive 对象,开关状态一旦变化,所有依赖它的组件会自动更新:
<template>
<OldHomepage v-if="!showNew" />
<NewHomepage v-else />
</template>
<script setup>
import { useFlag } from './flags';
const showNew = useFlag('new-homepage');
</script>组件级开关之外,路由级灰度也很常见。比如新版个人中心是一个完整页面,希望通过新路由 /profile-v2 访问,但只对灰度用户开放。可以在全局前置守卫中统一拦截:
router.beforeEach(async (to) => {
const grayRoutes = ['/profile-v2', '/order-beta'];
if (grayRoutes.includes(to.path)) {
const hit = await checkGrayFromServer(to.path);
if (!hit) {
// 非灰度用户重定向回老页面
return to.path.replace('-v2', '').replace('-beta', '');
}
}
return true;
});这里建议灰度判断尽量走服务端接口而不是纯前端计算。原因是前端打包后的分组逻辑是静态的,想调整灰度比例就得重新发版,失去灰度发布「随时可调」的意义。服务端下发开关值,前端只负责渲染,才能真正实现动态放量。
三、开关的远程下发与动态更新
工程化的灰度方案需要一个开关配置中心,常见做法是应用启动时拉取一次全量开关,运行期间通过轮询或长连接感知变更。下面是一个简化实现:
// flagService.js
import { setFlag } from './flags';
export async function initFlags(userId) {
const res = await fetch(`/api/flags?userId=${encodeURIComponent(userId)}`);
const flags = await res.json();
Object.entries(flags).forEach(([key, value]) => setFlag(key, value));
}
// 轮询感知开关变化,间隔可按业务调整
export function watchFlags(interval = 30000) {
return setInterval(async () => {
const res = await fetch('/api/flags');
const flags = await res.json();
Object.entries(flags).forEach(([key, value]) => setFlag(key, value));
}, interval);
}轮询实现简单,但存在最长一个轮询周期的延迟。如果对实时性要求高(比如发现线上事故要立刻关闭某个功能),可以用 WebSocket 或者 SSE 推送变更指令。开关关闭后,依赖它的组件会因响应式更新自动回退到旧逻辑,正在操作中的用户可能遇到界面突变,所以对流程类页面(比如支付中间步骤)要谨慎处理,可以在组件卸载或路由切换时再应用新状态,避免打断用户操作。
另外要注意开关的兜底策略:接口请求失败时应保留上一次的开关值,或者回落到构建时注入的默认值,绝不能因为配置中心故障把所有功能一键关闭。本地缓存一份最近一次成功的开关配置(localStorage 即可)是性价比很高的保险措施。
四、灰度发布必须配套的工程细节
灰度不只是技术开关,还涉及几个容易被忽略的配套环节。
第一是埋点与监控。灰度的目的就是对比验证,新旧两个版本的核心指标(点击率、转化率、报错率、接口耗时)必须分开统计。埋点数据里要带上版本标识或灰度标记,否则数据混在一起,灰度就失去了意义。前端还可以监听全局错误事件,按灰度分组上报异常率,一旦新版本错误率明显偏高,立即触发回滚。
第二是放量节奏。推荐的节奏是 1% 观察、5% 观察、20%、50%、100%,每一步之间留足观察时间(通常一到两天)。放量动作只改配置中心的百分比,前端无需发版。如果灰度期间需要修复新功能的小问题,注意老用户正在使用的旧代码包不会被影响,新包发布后只有灰度用户加载。
第三是开关的生命周期管理。功能开关用完要及时清理。当一个功能已经 100% 放量且运行稳定,就应该把代码里的 v-if 分支和旧组件删掉,把开关标记为废弃。长期堆积的开关会让代码里充满死分支,维护成本越来越高。可以在代码评审中加一条规则:每个新开关必须注明创建时间和预计下线时间。
总结一下,Vue 3 的灰度发布 = 用户分组决定流量切分 + 功能开关决定功能启停 + 配置中心实现动态调整 + 埋点监控保障决策依据。四者齐备,才能真正做到「小步快跑、随时回滚」,让新功能上线不再是一场赌博。