Skip to main content
返回博客
Next.jsA/B TestingProxyPerformanceSSG

一个其实是 A/B 测试的静态页面

A/B 测试通常的做法会把页面从 CDN 上拽下来,变成按请求渲染。其实不必如此。让每个变体都保持完全静态,把分配决策交给一个 proxy(Next.js 改名后的 middleware)——它通过实验平台、基于稳定的 device id 来解析,且只有当访客不在对照组时才做 rewrite。URL 从不改变,CDN 照旧缓存,而常见路径不付出任何代价。

发布于 2026年8月14日8 分钟阅读

每次你对一个页面做 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 就是你的归因键:每一次转化都能回溯到该设备当时所在的桶。

页面

对照组是一个普通的静态页面。变体们放在一个没有任何地方直接链接的路由下:

app/pricing/page.tsx
// The control — a plain static page.
export const dynamic = "force-static";

export default function Pricing() {
  return <Control />;
}
app/ab/pricing/[variant]/page.tsx
// 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
// 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" 退出它,自己在内存里缓存:

lib/experiments/config-cache.ts
// 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 指向一个重建这份映射的路由。

instrumentation.ts
// 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();
}
app/api/experiments/refresh/route.ts
// 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 携带着解析出的桶。在你发送事件的地方读它——服务端从请求里读,或客户端读来给分析打标签——这样每次转化都归因到 controlbc。而页面为此从不离开 CDN。

别让它泄漏到 SEO 里

  • 在变体页面上设置指向 /pricingcanonical,让搜索引擎归并到真实 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——完事后把实验拆掉。

发现错误了吗?

本文里有事实错误、别扭的翻译,或者哪里读起来不对?告诉我——用你自己的语言。