一个其实是 A/B 测试的静态页面
A/B 测试通常的做法会把页面从 CDN 上拽下来,变成按请求渲染。其实不必如此。让每个变体都保持完全静态,把分配决策交给一个 proxy(Next.js 改名后的 middleware)——它通过实验平台、基于稳定的 device id 来解析,且只有当访客不在对照组时才做 rewrite。URL 从不改变,CDN 照旧缓存,而常见路径不付出任何代价。
每次你对一个页面做 A/B 测试,简单的做法都会悄悄把它变成动态的——于是你丢掉了 CDN。你不必做这种取舍。下面是如何让每个变体保持静态,并把整个实验藏在一个 proxy 后面。
矛盾所在
你想对一个页面做 A/B 测试——定价页、落地页的 hero、某个引导步骤。一旦这么做,它通常就不再是静态的了:
- 客户端 A/B 会下发 JavaScript,先闪一下原始变体,还会造成布局位移。而且没有 JS 根本无法工作。
- 按请求渲染 能用,但现在每次访问都打到你的 origin——没有 CDN 缓存、更差的 TTFB、更多计算。
Vary: Cookie看起来很诱人,却会把缓存按每个 cookie 值切成一条条独立记录。
但页面本质上仍然是静态 HTML。唯一动态的是 哪一份 静态 HTML——以及它到底是否与默认版本不同。把这一点单独隔离出来。
思路
- 把 对照组作为真实的静态页面 放在
/pricing。 - 把每个 非对照的变体作为它自己的静态页面 放在一个专用路由下。
- 在 proxy 里,用一个实验 SDK 解析访客的变体。对照组?什么都不做——静态页面原样渲染。是变体?Rewrite 到该变体的静态页面。两种情况下 URL 都相同。
最妙的是这种 不对称:大部分流量是对照组,而对照组不产生任何 rewrite。
稳定的标识符
一致的分桶需要一个稳定的 id,而不是每次请求都重新抛硬币。一个长期存在的 device_id cookie 就够了——实验 SDK 会对它(加上任意定向属性)做哈希,于是同一设备总是落进同一变体,分流也保持在你设定的权重上。
同一个 id 就是你的归因键:每一次转化都能回溯到该设备当时所在的桶。
页面
对照组是一个普通的静态页面。变体们放在一个没有任何地方直接链接的路由下:
// The control — a plain static page.
export const dynamic = "force-static";
export default function Pricing() {
return <Control />;
}// Non-control variants, also static.
export const dynamic = "force-static";
export const dynamicParams = false; // only the variants you built
export function generateStaticParams() {
return [{ variant: "b" }, { variant: "c" }];
}
export default async function Variant({
params,
}: {
params: Promise<{ variant: string }>;
}) {
const { variant } = await params;
return variant === "c" ? <VariantC /> : <VariantB />;
}对照组和每个变体各自预渲染、各自独立缓存。
Proxy——全部的秘密
// proxy.ts (this was middleware.ts before Next.js 16)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { ensureConfigReady, getSplitConfig } from "@/lib/experiments/config-cache";
import { resolveVariant } from "@/lib/experiments"; // wraps GrowthBook, Statsig, etc.
export const config = { matcher: "/pricing" };
export async function proxy(request: NextRequest) {
// 1. Config first — a synchronous, in-memory lookup. No test here? Do nothing.
await ensureConfigReady(); // no-op once warm
const split = getSplitConfig("pricing");
if (!split) return NextResponse.next();
// 2. A stable id for consistent bucketing — reuse it, or mint one on first visit.
let deviceId = request.cookies.get("device_id")?.value;
const isNewDevice = !deviceId;
if (!deviceId) deviceId = crypto.randomUUID();
// 3. Ask the experimentation platform which variant this device is in.
// The SDK does the consistent hashing — no Math.random() here.
const variant = await resolveVariant(split, {
device_id: deviceId,
country: request.headers.get("cf-ipcountry") ?? undefined,
// locale, utm_source, … — whatever you target on
});
// 4. Control is the real page: render as-is. Only variants rewrite.
const res =
variant === "control"
? NextResponse.next()
: NextResponse.rewrite(new URL(`/ab/pricing/${variant}`, request.url));
// 5. Persist the id; drop a short-lived breadcrumb for analytics.
if (isNewDevice) {
res.cookies.set("device_id", deviceId, {
path: "/",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 365,
});
}
res.cookies.set("ab_pricing", variant, {
path: "/",
sameSite: "lax",
maxAge: 60 * 60, // an attribution breadcrumb, not the source of truth
});
return res;
}在更老的项目里,这是 middleware.ts 里的 export function middleware——逻辑一样(proxy 文档)。Next.js 提供了一个 codemod 来重命名它:npx @next/codemod middleware-to-proxy .
proxy 依赖的 config——缓存在内存里
proxy 无法凭空知道哪些页面在做测试。这份映射——哪个页面有实验、它的 key、每个变体的 slug——存在你的 CMS 里。但你不能在每次请求时都去 fetch 它(那会把 CMS 放进每次页面加载的热路径),而 proxy 运行时也不给你在 Server Component 里会用的那种 fetch 缓存。所以你用 cache: "no-store" 退出它,自己在内存里缓存:
// Module scope, in-memory, no fetch cache.
type SplitConfig = { key: string; variants: { value: string; slug: string }[] };
const configByPage = new Map<string, SplitConfig>();
let ready = false;
let inflight: Promise<void> | null = null;
async function refresh() {
// The proxy runtime has no usable fetch cache — opt out and cache ourselves.
const res = await fetch(`${process.env.CMS_URL}/experiments`, { cache: "no-store" });
const next = toConfigMap(await res.json());
configByPage.clear(); // atomic-ish swap
for (const [page, cfg] of next) configByPage.set(page, cfg);
ready = true;
}
/** Rebuild — call this from a CMS webhook when experiment content changes. */
export async function refreshExperimentConfig() {
try {
await refresh();
} catch {
ready = true; // on failure, keep the old map and carry on
}
}
/** Lazy single-flight init: the first caller fetches, the rest await it. */
export async function ensureConfigReady() {
if (ready) return;
if (!inflight) inflight = refreshExperimentConfig().finally(() => (inflight = null));
await inflight;
}
/** Synchronous read — this is what the proxy calls on the hot path. */
export function getSplitConfig(page: string) {
return configByPage.get(page);
}两件事让它保持新鲜,且热路径上不产生网络往返。在 instrumentation.ts 里于启动时预热一次——它的 register() 只运行一次,并且必须在服务器开始接收请求之前完成,所以第一个访客永远不为这次 fetch 买单。并且在内容变化时刷新它,而不是靠定时器:把 CMS 的 webhook 指向一个重建这份映射的路由。
// register() runs once at boot and must finish before the server serves.
export async function register() {
const { refreshExperimentConfig } = await import("@/lib/experiments/config-cache");
await refreshExperimentConfig();
}// Your CMS calls this whenever experiment content changes.
import { refreshExperimentConfig } from "@/lib/experiments/config-cache";
export async function POST() {
await refreshExperimentConfig();
return new Response("ok");
}一个在规模上会咬人的注意点:这个缓存是每个实例各自持有的。单个 webhook 只会刷新接收到它的那个容器——其他每个容器和区域仍然握着自己那份过期的副本。在多区域部署里,你必须把失效扇出到每个实例,让每个 proxy 都重建——向所有区域部署广播,或者一个各实例都订阅的 pub/sub——外加对受影响 URL 的 CDN purge。这个扇出是基础设施的事,不是应用代码。没有这套管道?那就用缓存上的一个短 TTL 来限制过期程度——更简单,只是不即时。
为什么用实验 SDK,而不是 Math.random()
在 proxy 里自己写 random 技术上确实能分流,但你会失去让实验值得信赖的一切。像 GrowthBook(或 Statsig、LaunchDarkly、Unleash)这样的平台给你:
- 按
device_id的一致哈希,让访客永远不会在变体之间来回横跳。 - 无需部署即可修改的权重与灰度放量——50/50、5% 金丝雀、逐步拉到 100%。
- 基于你传入属性的定向(国家、语言、UTM、套餐),在服务端求值。
- 一个 kill switch,以及一个能看到所有在跑实验的地方。
proxy 只是向平台要一个变体然后 rewrite。实验逻辑待在它该待的地方。
为什么缓存照样有效
CDN 以 rewrite 目标 为键。对照组是 /pricing(已缓存)。每个变体是 /ab/pricing/b、/ab/pricing/c(已缓存)。访客自始至终看到的是 /pricing。没有 Vary: Cookie,没有按用户切碎——而对照组,也就是绝大多数路径,甚至根本不经过 rewrite。
分析
短寿命的 ab_pricing cookie 携带着解析出的桶。在你发送事件的地方读它——服务端从请求里读,或客户端读来给分析打标签——这样每次转化都归因到 control、b 或 c。而页面为此从不离开 CDN。
别让它泄漏到 SEO 里
- 在变体页面上设置指向
/pricing的 canonical,让搜索引擎归并到真实 URL。 - 把
/ab/*排除在 sitemap 之外,并给它加noindex。 - 让 SDK 对爬虫返回对照组(或当没有
device_id时)——机器人得到一个稳定的页面。
会耗掉你一个晚上的坑
- 对照组什么都不做。
control分支就是NextResponse.next()——没有 rewrite、没有变体页面、热路径上零成本。这种不对称正是重点。 - 强制静态。把对照组和变体页面标为
force-static,让它们预渲染并被缓存;一个不小心的动态 API 会把它们降级成按请求渲染。 - proxy 现在默认跑在 Node 上(v16)。让它保持精简——读一次 cookie、调用一次 SDK、做一次 rewrite——这样无论部署在哪都便宜。
- 定向来自请求。地理位置来自 CDN 头(
cf-ipcountry)、语言来自路径、UTM 来自 query——在 proxy 里组装好属性再交给 SDK。 - 规划好退出。当某个变体胜出,把它并回
/pricing,然后删掉变体路由和实验。一个永远跑着的 A/B 测试,就是带 cookie 的技术债。
要点
- 一个是 A/B 测试的页面,仍然可以 100% 静态。动态的只是 哪一份 静态页面——而且通常就是对照组,它根本不需要任何处理。
- 在 proxy 里用一个基于稳定 device id 的实验平台来解析分配——而不是每次请求都 random。
- 只为非对照变体做 rewrite;CDN 把对照组和每个变体各自当作独立页面缓存。没有
Vary: Cookie,不打 origin。 - device-id cookie 提供粘性分桶与归因;一个短寿命的面包屑 cookie 把变体带到分析里。
- 用 canonical、变体路由上的
noindex、以及对爬虫返回对照组来守住 SEO——完事后把实验拆掉。
发现错误了吗?
本文里有事实错误、别扭的翻译,或者哪里读起来不对?告诉我——用你自己的语言。