
一句话:Feature Flag 的价值是「把『发布代码』与『放出功能』解耦」——所以它的核心不是「一个开关」,而是「一致的灰度分桶(同一用户稳定在同组)+ 本地快速求值 + 拉取失败时的兜底 + A/B 的曝光记录」,而最大的风险是「开关变技术债」。
一、四种 Flag:只有两种能长期留
⭐⭐⭐ Feature Flag 的四类(⭐ 用错了会「技术债爆表」):
① 【Release Flag(发布开关)】⭐ 短期
「新结账流程先给 5% 用户看」
⭐ 用途:**灰度发布 / 出问题快速关掉(⭐ 这是它最大的价值)**
⚠️ **必须「用完就删」**(发布完成后就该清理)——否则它会「永久留在代码里」
② 【Experiment Flag(实验开关)】短期
「A/B 测试两种文案/布局」
⚠️ 实验结束就删(⭐ 保留 = 每次改代码都要考虑「两个分支」)
③ 【Ops Flag(运维开关)】⭐ 长期合法
「紧急关闭评论功能」「降级到静态数据」「限流阈值」
✅ 这类**本来就应该长期存在**(它是「运维手段」,不是「发布手段」)
④ 【Permission Flag(权限开关)】⚠️ 长期但「应该换成权限系统」
「只有付费用户能用导出」
⚠️ 它本质是「权限」,应该用「权限系统」而不是「flag」(见 6.12)
⭐ 用 flag 做权限的后果:「权限规则藏在 flag 配置里」,无法审计、无法按资源授权
⭐ 一句话:
· **① ② 是「技术债」,必须「创建时定清理时间」**
· **③ 是合理长期存在**
· **④ 应该迁移到权限系统**
⭐⭐⭐ 「开关的技术债」有多真实(这是面试里最能体现经验的一点):
场景:一个项目上线 2 年,积累了 300 个 flag:
· 其中 200 个是「已经全量放出的发布开关」(⭐ 但没人删)
· 代码里到处 `if (flags.newCheckout) { 新逻辑 } else { 老逻辑 }`
⚠️ 后果:
· **每个功能有两套代码**(改一处要改两处,测试要测两条路径)
· ⭐ **没人敢删**(「万一还有人没放开?」)
· **新人无法理解**(不知道哪个是「活的」)
· ⭐ 「老逻辑」在「无人测试」的情况下悄悄腐烂(仍然被打包进产物)
→ ⭐ 这就是「Feature Flag 从资产变成负债」的经典过程。
✅ 控制手段(四条,缺一不可):
① ⭐ **创建时必填「过期时间」(或「清理 issue 编号」)**
② ⭐ **定期扫描「长期未变更的 flag」并告警**(如「90 天没被改过 + 全量 100%」→ 提示删除)
③ ⭐ **「全量后自动清理」的流程**(「发布完成 → 建一个删 flag 的 issue」)
④ ⭐ **代码里能一眼看出「这是临时 flag」**(命名前缀如 `tmp_xxx` / `release_xxx`)
二、分桶:一致性哈希是核心
⭐⭐⭐ 灰度必须满足「三个一致性」:
① 【同一用户 → 同一组】⭐ 最重要
用户 A 今天在「新版」,刷新后不能变成「老版」(⭐ 否则体验错乱 + 数据混乱)
② 【不同 flag → 不同分组】⭐ 防止「实验偏差」
⚠️ 如果「所有 flag 都用『user_id 的 hash』」→
「被分到实验组的永远是同一批人」(⭐ 导致「实验结论不可信」)
✅ 把 **flag key 也参与哈希**(`hash(flagKey + userId)`)
③ 【增减用户 → 大体稳定】
⚠️ 如果用了「取模用户总数」的做法 → 「用户数变化会导致大面积重新分组」
✅ 用「哈希到固定区间(如 0~10000)」+「与百分比比较」→ 天然稳定
/** ⭐⭐ 一致性哈希分桶(零依赖,FNV-1a) */
function fnv1a(input: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash >>> 0;
}
/** ⭐ 把「flag + 用户」映射到「0~1 的桶」 */
export function bucketOf(flagKey: string, userId: string): number {
// ⭐⭐ 关键:**flag key 参与哈希**(否则「所有 flag 的实验组是同一批人」)
const hash = fnv1a(`${flagKey}:${userId}`);
// ⭐ 映射到 [0, 1)(用 10000 个桶 → 精度 0.01%)
return (hash % 10_000) / 10_000;
}
/** ⭐ 判断「某用户是否在灰度范围内」 */
export function isInRollout(flagKey: string, userId: string, rolloutPercent: number): boolean {
if (rolloutPercent >= 100) return true;
if (rolloutPercent <= 0) return false;
return bucketOf(flagKey, userId) < rolloutPercent / 100;
}
/** ⭐⭐ 完整的「规则求值」(支持多种定向条件) */
export interface FlagRule {
/** ⭐ 规则类型(按「优先级」从上到下匹配,匹配到就返回) */
kind: 'user' | 'email-domain' | 'region' | 'platform' | 'percent' | 'default';
/** 白名单(用户 id 列表) */
userIds?: string[];
/** 邮箱域名(如 `@acme.com` → 内部用户总是优先体验) */
emailDomains?: string[];
/** 地区(来自「服务端解析的 IP 地区」,⭐ 不是前端猜的) */
regions?: string[];
/** 平台 */
platforms?: Array<'ios' | 'android' | 'web'>;
/** ⭐ 百分比灰度 */
percent?: number;
/** 该规则下「是否开启」 */
value: boolean;
}
export interface FlagConfig {
key: string;
/** ⭐⭐ 「默认值」—— ⭐ 配置拉取失败时用它(绝不能白屏) */
defaultValue: boolean;
/** 规则列表(⭐ 顺序即优先级) */
rules: FlagRule[];
/** 元信息(⭐ 用于「治理」) */
meta: {
/** 类型(⭐ 决定「该不该长期存在」) */
type: 'release' | 'experiment' | 'ops' | 'permission';
owner: string;
/** ⭐ 「过期时间」——release/experiment 必填(治理的关键) */
expiresAt?: string;
createdAt: string;
};
}
/** ⭐⭐ 求值(纯函数,容易测试) */
export function evaluateFlag(
config: FlagConfig,
context: {
userId: string;
email?: string;
region?: string;
platform: 'ios' | 'android' | 'web';
}
): boolean {
for (const rule of config.rules) {
switch (rule.kind) {
case 'user':
// ⭐ 白名单优先(「内部员工先体验」)
if (rule.userIds?.includes(context.userId)) return rule.value;
break;
case 'email-domain':
if (context.email && rule.emailDomains?.some((d) => context.email!.endsWith(d))) return rule.value;
break;
case 'region':
if (context.region && rule.regions?.includes(context.region)) return rule.value;
break;
case 'platform':
if (rule.platforms?.includes(context.platform)) return rule.value;
break;
case 'percent':
// ⭐⭐ 一致性哈希(同一用户稳定)
if (rule.percent !== undefined && isInRollout(config.key, context.userId, rule.percent)) return rule.value;
break;
case 'default':
return rule.value;
default: {
// ⭐ 穷举保护(新增 kind 时 TS 会报错)
const _exhaustive: never = rule.kind;
void _exhaustive;
}
}
}
// ⭐ 没有任何规则匹配 → 用默认值
return config.defaultValue;
}
三、完整实现:一个 Feature Flag SDK(250 行)
// flags.ts —— 前端 Feature Flag SDK(拉取 + 缓存 + 本地求值 + 兜底 + 曝光)
import { evaluateFlag, type FlagConfig } from './evaluate';
/** ============ 1. 用户上下文 ============ */
export interface FlagContext {
userId: string;
email?: string;
/** ⭐ 地区由「服务端解析的 IP」给出(⚠️ 前端猜不准,且时区不等于地区) */
region?: string;
platform: 'ios' | 'android' | 'web';
}
/** ============ 2. SDK 选项 ============ */
export interface FlagsOptions {
/** ⭐ 拉取配置的端点 */
endpoint: string;
release: string;
/** ⭐⭐ 「兜底配置」—— ⭐ 首次加载(无缓存)且拉取失败时用它(绝不能白屏) */
fallback: Record<string, boolean>;
/** 缓存 key(localStorage) */
cacheKey?: string;
/** 缓存有效期(ms),⭐ 超期就用 fallback 但「仍然尝试拉取」 */
cacheTtlMs?: number;
/** ⭐ 暴露给「曝光上报」的回调(用于 A/B 分析) */
onExposure?: (info: { flagKey: string; value: boolean; variant?: string }) => void;
/** 拉取配置的函数(默认用 fetch) */
fetchConfig?: () => Promise<{ flags: FlagConfig[] }>;
}
/** ============ 3. SDK 主体 ============ */
export function createFlags(options: FlagsOptions) {
const {
endpoint,
release,
fallback,
cacheKey = '__flags_cache',
cacheTtlMs = 6 * 60 * 60 * 1000, // ⭐ 6 小时
onExposure,
fetchConfig,
} = options;
/** 内存里的「已求值结果」(⭐ 求值一次,后续 O(1) 读取) */
let evaluated: Record<string, boolean> = { ...fallback };
let configs = new Map<string, FlagConfig>();
let context: FlagContext | null = null;
/** ⭐ 曝光记录(⭐ 每个 flag 只记一次,避免刷爆) */
const exposed = new Set<string>();
const listeners = new Set<() => void>();
const notify = () => listeners.forEach((fn) => fn());
/** ⭐⭐ 读缓存(「先渲染、后台刷新」—— 让首屏不受 flag 网络延迟影响) */
function loadCache(): { flags: FlagConfig[]; at: number } | null {
try {
const raw = localStorage.getItem(cacheKey);
if (!raw) return null;
const parsed = JSON.parse(raw) as { release: string; at: number; flags: FlagConfig[] };
// ⚠️ 「release 变化」时「缓存失效」(⭐ 因为 flag 配置可能整体变了)
if (parsed.release !== release) return null;
return parsed;
} catch {
return null;
}
}
function saveCache(flags: FlagConfig[]) {
try {
localStorage.setItem(cacheKey, JSON.stringify({ release, at: Date.now(), flags }));
} catch { /* 隐私模式/配额满 */ }
}
/** ⭐ 用「当前 context」重新求值所有 flag */
function reevaluate() {
if (!context) return;
const next: Record<string, boolean> = {};
for (const [key, config] of configs) {
next[key] = evaluateFlag(config, context);
}
evaluated = { ...fallback, ...next }; // ⭐ 「配置里没有的 flag」用 fallback
notify();
}
/** ⭐⭐ 拉取配置(⭐ 失败时「静默降级」——保留当前值,不能影响业务) */
async function refresh(): Promise<void> {
try {
const data = fetchConfig
? await fetchConfig()
: await fetch(`${endpoint}?release=${encodeURIComponent(release)}`, {
credentials: 'same-origin',
}).then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<{ flags: FlagConfig[] }>;
});
configs = new Map(data.flags.map((f) => [f.key, f]));
saveCache(data.flags);
reevaluate();
} catch (err) {
// ⭐⭐ 「拉取失败」→ 什么都不做(保持「缓存/fallback 的值」)
// ⚠️ 绝不能「把 flag 全设为 false」或「抛错」(那会让页面功能错乱/白屏)
console.warn('[flags] 配置拉取失败,继续使用当前值', err);
}
}
/** ⭐⭐⭐ 核心:设置用户上下文 → 求值 → 暴露 */
function identify(next: FlagContext) {
context = next;
// ⭐ 用户变了 → 曝光记录要重置(否则「新用户」的曝光会被去重掉)
exposed.clear();
reevaluate();
}
/** ⭐ 读一个 flag(⭐ 业务只调它) */
function getFlag(key: string): boolean {
const value = evaluated[key] ?? fallback[key] ?? false;
// ⭐ 曝光记录(⭐ A/B 分析的依据:「这个用户看到的是哪一组」)
if (!exposed.has(key)) {
exposed.add(key);
onExposure?.({ flagKey: key, value });
}
return value;
}
/** ⭐⭐ 启动:先用缓存(同步)→ 后台拉取(异步) */
function bootstrap(ctx: FlagContext) {
context = ctx;
// ① ⭐ 先用「缓存」或「fallback」求出「首屏可用的值」(同步,零延迟)
const cached = loadCache();
if (cached) {
configs = new Map(cached.flags.map((f) => [f.key, f]));
reevaluate();
// ⭐ 缓存「过期」→ 仍然先用(⭐ 旧值好过没值),但「立刻去拉」
if (Date.now() - cached.at > cacheTtlMs) void refresh();
} else {
// ⭐ 没缓存 → 用 fallback(⭐ 绝不白屏),并「立刻拉」
evaluated = { ...fallback };
notify();
void refresh();
}
}
return {
bootstrap,
identify,
getFlag,
refresh,
/** ⭐ 订阅变化(供 React 的 useSyncExternalStore 用) */
subscribe: (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; },
getSnapshot: () => evaluated,
/** ⭐ 治理:列出「已过期但仍在用」的 flag(⭐ 用于「清理技术债」) */
listExpired: () => [...configs.values()].filter((c) =>
(c.meta.type === 'release' || c.meta.type === 'experiment') &&
c.meta.expiresAt !== undefined &&
new Date(c.meta.expiresAt).getTime() < Date.now()
).map((c) => ({ key: c.key, type: c.meta.type, owner: c.meta.owner, expiresAt: c.meta.expiresAt })),
};
}
/** ============ 4. React 集成 ============ */
import { createContext, useContext, useEffect, useMemo, useSyncExternalStore } from 'react';
type FlagsClient = ReturnType<typeof createFlags>;
const FlagsContext = createContext<FlagsClient | null>(null);
export function FlagsProvider({ client, context, children }: { client: FlagsClient; context: FlagContext; children: React.ReactNode }) {
useEffect(() => { client.bootstrap(context); }, [client, context]);
return <FlagsContext.Provider value={client}>{children}</FlagsContext.Provider>;
}
export function useFlag(key: string): boolean {
const client = useContext(FlagsContext);
if (!client) throw new Error('useFlag 必须在 <FlagsProvider> 内使用');
// ⭐ 用 useSyncExternalStore「订阅 flag 变化」(⭐ 与 11.13 的 store 是同一模式)
const snapshot = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
// ⚠️ 注意:`snapshot` 是「整个求值结果对象」→ 它变了就重渲染
// ✅ 本项目里「flag 变化很少」,所以可接受;
// 如果 flag 很多且「频繁刷新」,应该「按 key 订阅」(⭐ 更细的粒度)
void snapshot;
return client.getFlag(key);
}
/** ⭐⭐ 声明式组件(⭐ 让「分支逻辑」显式可见,便于「清理时定位」) */
export function Flag({
name, on, off = null, children,
}: {
name: string;
on: React.ReactNode;
off?: React.ReactNode;
children?: React.ReactNode;
}) {
const enabled = useFlag(name);
return <>{enabled ? (children ?? on) : off}</>;
}
// 用法
function CheckoutButton() {
return (
// ⭐ 用 `<Flag>` 而不是「内联 if」—— 好处:
// ① 「哪些地方用了这个 flag」可以被静态搜索(`<Flag name="new_checkout">`)
// ② 清理时「删掉 `<Flag>` 包裹、只留 on 分支」语义清晰
<Flag
name="release_new_checkout"
on={<NewCheckoutButton />}
off={<LegacyCheckoutButton />}
/>
);
}
/** ============ 5. 与后端的一致性(⭐ 关键纪律) ============ */
/**
* ⭐⭐⭐ 「前端判断 UI,后端判断业务」——不能只靠前端:
*
* ❌ 错误做法:
* 前端:if (flag('new_checkout')) { 调用「新接口」 }
* 后端:没有「新接口」的开关 → 「老用户也能调新接口」
* → ⚠️ 后果:「绕过 UI 就能用新功能」(不是安全问题,但「实验数据被污染」)
*
* ✅ 正确做法:
* ① 前端 flag 只控制「UI 呈现」
* ② ⭐ 请求带上「上下文」(或后端也读同一份 flag 配置)→ 后端「按同一个规则判断」
* ③ ⭐ 关键业务(扣款/权限)**必须后端判断**(见 6.12 的铁律)
*
* ⭐ 实现方式:
* · 后端也接「flag 服务」(同一份配置 + 同一套分桶算法)
* · 或「前端把 flag 结果放进请求头/body」+ ⭐ 后端「校验」(而不是「信任」)
*/
export function attachFlagContext(headers: Record<string, string>, client: FlagsClient, keys: string[]) {
// ⭐ 把「参与业务判断的 flag」放进请求头(⚠️ 后端要「用它做校验」而不是「盲目信任」)
const values = keys.map((k) => `${k}=${client.getFlag(k) ? '1' : '0'}`).join(',');
return { ...headers, 'x-flag-context': values };
}
四、治理:让开关不变技术债
⭐⭐⭐ Flag 治理的四条机制(⭐ 这是「大厂做法」与「小团队做法」的分水岭):
① 【创建时强制元信息】
每个 flag 必须填:`type`(release/experiment/ops/permission)、`owner`、
⭐ **`expiresAt`(release/experiment 必填)**
→ ⭐ 在「创建入口」(管理后台/代码 PR 模板)强制
② 【自动化扫描】⭐
· 「已过期但仍在生效」→ 告警给 owner
· 「100% 全量超过 N 天」→ 提示「该清理了」
· ⭐ 「代码里引用了但配置里已删除的 flag」→ 编译/lint 报错
✅ 实现:SDK 提供 `listExpired()`(见上文)+ CI 里跑一个检查脚本
③ 【代码侧的可发现性】⭐
用**命名前缀**(`release_` / `exp_` / `ops_`)+ 声明式组件 `<Flag name="...">` →
⭐ 「清理时」可以「全局搜索 `release_new_checkout`」一次找全所有引用
④ 【「清理」进入发布流程】
⭐ 「全量发布完成」→ 立刻建「删 flag」的任务(而不是「等有空再说」)
✅ 做法:把「删 flag」写进「发布 checklist」
/** ⭐⭐ CI 检查脚本:找出「应该清理的 flag」 */
// scripts/check-flags.mjs
import { readFileSync } from 'node:fs';
const config = JSON.parse(readFileSync('flags.json', 'utf8'));
const RELEASE_MAX_AGE_DAYS = 60;
const problems = [];
for (const flag of config.flags) {
const { key, meta, rules } = flag;
// ⭐ ① release/experiment 必须有过期时间
if ((meta.type === 'release' || meta.type === 'experiment') && !meta.expiresAt) {
problems.push(`[缺 expiresAt] ${key}(type=${meta.type})—— release/experiment 必须设置过期时间`);
}
// ⭐ ② 已过期
if (meta.expiresAt && new Date(meta.expiresAt).getTime() < Date.now()) {
problems.push(`[已过期] ${key}(${meta.expiresAt})—— owner=${meta.owner},请清理或续期`);
}
// ⭐ ③ 「全量 100%」的 release flag(已经没有分流价值了)
const isFullRollout = rules.some((r) => r.kind === 'percent' && r.percent === 100 && r.value === true);
if (meta.type === 'release' && isFullRollout) {
const ageDays = (Date.now() - new Date(meta.createdAt).getTime()) / 86_400_000;
if (ageDays > RELEASE_MAX_AGE_DAYS) {
problems.push(`[可清理] ${key} 已全量 ${Math.round(ageDays)} 天 —— 建议删除 flag 并清理代码分支`);
}
}
}
if (problems.length) {
console.log(`\n⚠️ 发现 ${problems.length} 个 flag 治理问题:`);
problems.forEach((p) => console.log(` · ${p}`));
// ⭐ 「已过期」可以让 CI 失败(⚠️ 但要给「续期」的路径,否则会「为了过 CI 而乱删」)
const hasExpired = problems.some((p) => p.startsWith('[已过期]'));
if (hasExpired && process.env.CI_FAIL_ON_EXPIRED === '1') process.exit(1);
} else {
console.log('✅ flag 治理检查通过');
}
五、本篇特有的坑
// ① 「分桶」不含 flag key(所有实验都是同一批用户)⭐
fnv1a(userId); // ❌
// ✅ fnv1a(`${flagKey}:${userId}`)
// ② 「分桶」用「取模用户总数」(用户数变化 → 大面积重新分组)
userId.charCodeAt(0) % 100; // ❌
// ✅ 哈希到固定区间(0~10000)→ 与百分比比较
// ③ 「同一用户刷新后换了组」(没有一致性哈希)
// ⚠️ 症状:用户「刷新一次变成另一版」
// ✅ 幂等的分桶函数(关键:输入相同 → 结果相同)
// ④ ⭐ 「配置拉取失败」时「把 flag 全设为 false」(功能全没了)
catch { evaluated = {}; } // ❌ 灾难
// ✅ 「保留当前值」+ 用 fallback(⭐ 绝不因「flag 服务不可用」而影响业务)
// ⑤ 「首屏等 flag 拉取」(拖慢首屏)
const flags = await fetchFlags(); render(flags); // ⚠️ 首屏被 flag 网络延迟绑架
// ✅ 「先用缓存/fallback 渲染,后台刷新」(或「SSR 注入 flag」,见下文)
// ⑥ 「flag 变化导致 UI 闪一下」(先用 fallback 渲染,flag 到了再切)
// ⚠️ 症状:「先显示旧版,50ms 后换成新版」
// ✅ ① SSR/边缘注入 flag;② 用「本地缓存的上次值」(大多数情况无闪);
// ③ 对「关键 flag」用「骨架」而不是「错误的默认值」
// ⑦ ⭐ 「前端判断了但后端没判断」(绕过 UI 就能用新功能)
// ✅ 前端只控 UI;⭐ 业务判断必须「后端也用同一规则」
// ⑧ 「flag 当权限用」(无法审计、无法按资源授权)
if (flag('can_export')) { ... } // ⚠️
// ✅ 用权限系统(见 6.12);⭐ 把 flag 类型的 permission 迁走
// ⑨ 「load-bearing flag」(一个 flag 承担多种含义)
if (flag('new_checkout') && flag('new_payment')) { ... } // ⚠️ 组合爆炸
// ✅ 一个 flag 一个明确语义;⭐ 组合逻辑要能「被清理时一次看清」
// ⑩ 「flag 嵌套」(`if (a) { if (b) ... }`)
// ⚠️ 清理时「不敢动」
// ✅ 尽量扁平;⭐ 复杂逻辑用「求值函数」(可测)而不是「散落的 if」
// ⑪ ⭐ 「flag 名不统一」(`newCheckout` / `new_checkout` / `checkout_v2`)
// ✅ 命名规范 + 类型约束(TS 里定义 flag key 联合类型)
// ⑫ 「实验结束后没删 flag」(技术债)⭐
// ✅ 过期时间 + 扫描 + 发布 checklist
// ⑬ 「本地开发」用「生产的 flag 配置」(本地行为与线上不同/误改线上)
// ✅ ⭐ 「本地强制覆盖」(localStorage 或 query 参数 `?flag_new_checkout=1`)
// ⚠️ 但这个「覆盖能力」在生产必须禁用(否则用户能自己开功能)
// ⑭ 「曝光」没记录(无法做 A/B 分析)
// ✅ 每次「读 flag」时记录一次曝光(⭐ 见上文 `getFlag`)
// ⑮ 「曝光」重复记录(每次渲染都记)
// ✅ 「按 flag key 去重」+「用户切换时重置」
// ⑯ ⭐ 「A/B 的指标口径」不清晰(实验结论不可信)
// ⚠️ 「实验组指标变好」可能只是「因为实验组是活跃用户」
// ✅ 「随机分桶 + 留足样本 + 明确主指标 + 注意『新奇效应』」;
// ⭐ 「不要把「灰度发布」当成「A/B 测试」」(前者目的是「安全」,后者是「验证假设」)
// ⑰ 「flag 服务」是「单点」(它挂了 → 所有功能受影响的概率上升)
// ✅ 前端有缓存/fallback;⭐ 后端也必须有本地兜底
// ⑱ 「flag 拉取」用「每次请求都拉」(浪费 + 慢)
// ✅ 「本地缓存 + TTL + 主动刷新」;⭐ 需要「秒级生效」时用「WebSocket/SSE 推送变更」(见 7.14)
// ⑲ 「flag 变更」没有审计(谁改的、什么时候、为什么)
// ✅ 管理后台记录「变更日志」(⭐ 且「改 flag」要能「快速回滚」)
// ⑳ 「紧急关闭」时「找不到开关」(事故时手忙脚乱)
// ✅ ⭐ 「runbook 里列出『关键功能的 ops flag 名』」+「一键关闭的入口」
// ⑤⑥ 的完整处理(SSR/边缘注入 flag —— 彻底消除「首屏延迟」与「闪烁」)
// 服务端(SSR / 边缘函数):
// ⭐ 在「渲染 HTML 之前」就算好 flag(因为服务端有用户上下文,且没有网络往返)
function injectFlagsIntoHtml(html: string, flags: Record<string, boolean>) {
const script = `<script>window.__FLAGS__=${JSON.stringify(flags)}</script>`;
return html.replace('</head>', `${script}</head>`);
}
// 前端 SDK 启动时:
function bootstrapWithSsrFlags(ctx: FlagContext) {
const ssrFlags = (window as unknown as { __FLAGS__?: Record<string, boolean> }).__FLAGS__;
if (ssrFlags) {
// ⭐⭐ 用「服务端算好的值」→ 「首屏渲染出来的就是正确的版本」(零闪烁)
evaluated = { ...fallback, ...ssrFlags };
notify();
}
// ⭐ 仍然「后台刷新」配置(用于「配置变更后」的后续会话)
void refresh();
}
// ⑬ 的完整处理(本地覆盖 flag,⚠️ 生产禁用)
export function getDevFlagOverride(): Record<string, boolean> {
// ⭐ 只在开发/预发生效
if (import.meta.env.PROD) return {};
const out: Record<string, boolean> = {};
// ① query 参数:?flag_new_checkout=1
for (const [k, v] of new URLSearchParams(location.search)) {
if (k.startsWith('flag_')) out[k.slice(5)] = v === '1' || v === 'true';
}
// ② localStorage:`__flag_overrides` = {"new_checkout": true}
try {
const raw = localStorage.getItem('__flag_overrides');
if (raw) Object.assign(out, JSON.parse(raw) as Record<string, boolean>);
} catch { /* 忽略 */ }
return out;
}
六、面试延伸
- 「Feature Flag 有什么用?分几类?」
⭐ 核心价值是「把『发布代码』与『放出功能』解耦」——于是可以「灰度发布」「出问题秒级关闭(不用回滚代码)」「做 A/B 测试」。四类:① Release Flag(发布开关)——⭐ 短期,灰度发布与快速关闭的主力,必须用完就删;② Experiment Flag(实验开关)——短期,实验结束就删;③ Ops Flag(运维开关)——⭐ 「紧急关闭某功能」「降级模式」,这类长期存在是合理的;④ Permission Flag(权限开关)——⚠️ 长期但应该迁移到权限系统(它本质是「权限」,用 flag 做会导致「规则藏在配置里、无法审计」)。⭐ 一句话:「①② 是技术债、③ 是合理长期、④ 该迁走」。 -
「灰度分桶要注意什么?」
⭐ 三个一致性:① 同一用户 → 同一组(否则「刷新一下变成另一版」,体验错乱 + 数据混乱);② ⭐ 不同 flag → 不同分组(必须把 flag key 参与哈希,否则「所有 flag 的实验组都是同一批人」→ 实验结论不可信);③ 增减用户 → 大体稳定(⚠️ ❌ 「取模用户总数」的做法会让「用户数变化导致大面积重新分组」,✅ 用「哈希到固定区间(如 0~10000)+ 与百分比比较」)。实现:bucket = hash(flagKey + ':' + userId) % 10000 / 10000,再与percent/100比较(FNV-1a 这类非加密哈希即可,⭐ 关键是「输入相同 → 结果相同」)。 -
「flag 配置拉取失败怎么办?」
⭐ 绝不能「把 flag 全设为 false」或「抛错」——那会让「功能全没了」甚至「白屏」。正确做法:① ⭐ 保留当前值(内存里的)+ 用 fallback 默认值;② 静默降级(只console.warn,不影响业务);③ ⭐ 本地缓存(localStorage,带release校验与 TTL)——这样「服务挂了」也能撑住。另外:① 前端 SDK 必须有「兜底配置」(fallback: Record<string, boolean>),⭐ 它是「首次加载(无缓存)+ 拉取失败」时的唯一依靠;② 后端也必须有本地兜底(flag 服务是「单点」,它挂了你不能「什么都做不了」)。 -
「首屏渲染时 flag 还没到,怎么办?」
三种方案:① 阻塞渲染(await fetchFlags())——⚠️ 首屏被 flag 的网络延迟绑架(最差);② ⭐ 先用「缓存/fallback」渲染,后台刷新——✅ 不阻塞首屏,⚠️ 但可能「闪一下」(先显示旧版,flag 到了切成新版);③ ⭐⭐ SSR/边缘注入——在「渲染 HTML 之前」用服务端(有用户上下文、无网络往返)算好 flag,写进 HTML(window.__FLAGS__)→ 首屏就是正确的版本,零闪烁(⭐ 最优)。实践组合:「SSR 注入(主)+ 本地缓存(降级)+ fallback(兜底)」。⚠️ 如果只能选 ②,那么对「关键 flag」应该用「骨架」而不是「用一个可能是错的默认值」。 -
「为什么『前端判断了但后端也要判断』?」
因为 ⚠️ 前端 flag 只控制「UI 呈现」,绕过 UI 就能用新功能(直接调接口)。两个后果:① ⭐ 实验数据被污染(「实验组」里混进了「其实没看到新 UI 但调了新接口」的用户,结论不可信);② ⭐ 业务逻辑错乱(「新结账流程」需要「新接口」的支持,但如果「老用户也能调新接口」,可能出现「数据不一致」)。正确做法:① 前端只控 UI;② ⭐ 后端也用同一份 flag 配置与同一套分桶算法(「同一用户 → 同一结论」);③ 关键业务(扣款、权限)永远后端判断(见 6.12 的铁律)。实践:把 flag 服务「同时接给前端与后端」,或「前端把 flag 结果放请求头 + ⭐ 后端用它做校验而不是盲目信任」。 -
「Feature Flag 会变成技术债吗?怎么治理?」
⭐ 会,而且是「最容易被忽视的债」——典型画面:「项目上线 2 年积累 300 个 flag,其中 200 个是已全量但没人删的发布开关,代码里到处if/else两套逻辑,没人敢删(怕还有人没放开),新人无法理解,老逻辑在无人测试下腐烂但仍被打包」。治理四机制:① ⭐ 创建时强制元信息(type/owner/expiresAt,release/experiment 必须有过期时间);② ⭐ 自动化扫描(「已过期仍在生效」→ 告警 owner;「100% 全量超过 60 天」→ 提示清理;「代码引用了但配置已删」→ CI 失败);③ ⭐ 代码侧可发现(命名前缀release_/exp_/ops_+ 声明式<Flag name="...">,⭐ 这样「清理时全局搜索一次就能找全」);④ ⭐ 「清理」进入发布流程(全量完成后「立刻」建删 flag 的任务)。 -
「灰度发布和 A/B 测试是一回事吗?」
⭐ 不是——「灰度发布」目的是「安全」(逐步放量、出问题快速关闭,不需要统计显著性);「A/B 测试」目的是「验证假设」(需要「随机分桶 + 足够样本 + 明确主指标 + 统计检验 + 注意新奇效应」)。混用的后果:① ⚠️ 把「灰度」当实验 → 「实验组是『活跃用户优先』」之类的不公平分组 → 结论不可信;② ⚠️ 把「实验」当灰度 → 「为了让两组可比,不敢快速关掉有问题的实验组」→ 风险上升。工程上的区别:灰度要「快速放量/快速关闭」;实验要「分桶随机、样本量足、曝光记录完整」(⭐ 所以「曝光上报」是实验的基础设施,而灰度不需要)。 -
「如果让你设计一套 Feature Flag 系统,关键决定有哪些?」
六个:① ⭐ 求值在哪——「客户端求值」(快、无网络延迟,但「配置要下发」+ 需处理「首屏时机」)vs「服务端求值」(能拿到服务端才有的条件,但有网络往返);⭐ 实践是「客户端求值 + 服务端下发配置 + SSR 注入关键 flag」;② ⭐ 分桶算法——一致性哈希(flagKey + userId进哈希),且「客户端与服务端用同一个算法」(⭐ 否则「两边结论不一致」);③ ⭐ 降级策略——「缓存 + fallback + 静默降级」(⭐ 绝不让 flag 服务影响业务可用性);④ ⭐ 治理能力——「元信息 + 过期 + 扫描 + 清理流程」(⭐ 决定「长期是否可维护」);⑤ ⭐ 曝光记录——「谁看到了哪一组」(A/B 的基础);⑥ 变更的审计与回滚(「谁在什么时候改了哪个 flag」+ 「一键回滚」)。
一句话速记
Feature Flag 的核心价值是「把『发布代码』与『放出功能』解耦」(灰度发布 + 秒级关闭 + A/B);四类:Release(短期,主力)/ Experiment(短期)/ ⭐ Ops(长期合理)/ Permission(⚠️ 该迁到权限系统),⭐ Release/Experiment 是技术债,必须「创建时定过期」;⭐ 分桶的三个一致性:同一用户稳定同组、⭐ 不同 flag 不同分组(必须
hash(flagKey + ':' + userId),否则实验结论不可信)、增减用户大体稳定(⚠️ 别用「取模用户总数」,用「哈希到固定区间 + 与百分比比较」);⭐ 拉取失败时绝不「全设 false/抛错」(✅ 保留当前值 + fallback + 缓存 + 静默降级,且后端也要有本地兜底);⭐ 首屏与 flag 的三个方案(阻塞渲染最差 / 缓存+fallback 会闪 / ⭐⭐ SSR·边缘注入window.__FLAGS__= 零闪烁);⭐ 前端只控 UI,后端必须用同一规则判断业务(否则「绕过 UI 就能用新功能」→ 实验数据污染 + 逻辑错乱);治理四机制:创建强制owner/expiresAt→ 自动化扫描(过期/全量超 60 天/引用已删)→ 命名前缀 + 声明式<Flag>便于搜索 → 「全量后立刻建删 flag 任务」;A/B 与灰度不是一回事(灰度求「安全」、实验求「假设」,⭐ 实验必须有曝光记录与统计显著性);本地要能强制覆盖 flag(?flag_x=1),但生产必须禁用覆盖。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!