边缘 A/B 测试实战:解析 ab-testing-simple 中基于 Next.js Middleware 与 Cookie 的分桶方案
【免费下载链接】examplesEnjoy our curated collection of examples and solutions. Use these patterns to build your own robust and scalable applications.项目地址: https://gitcode.com/GitHub_Trending/examples1/examples
A/B 测试在传统实现中通常由客户端脚本在页面加载后动态插入实验变体,容易引发布局偏移(CLS)并拖慢首屏性能。本仓库的edge-middleware/ab-testing-simple示例给出了一条更优的路径:将分桶逻辑全部收敛到 Next.js Middleware(Edge Middleware)中,在边缘节点通过 Cookie 为用户分配实验桶,并借助NextResponse.rewrite把请求改写为静态生成的变体页面。读完本文,你将掌握如何用 Middleware + Cookie + 静态页面重写搭建一套零客户端实验代码的 A/B 测试方案,并理解其分桶算法、路由匹配与页面落地的完整实现。
方案概览:为什么要在边缘做 A/B 测试
原文档(edge-middleware/ab-testing-simple/README.md)开篇点明了这个示例的核心动机:
By A/B testing at the edge, you'll reduce layout shift from client-loaded experiments and improve your site's performance with smaller JavaScript bundles.
即:在边缘进行 A/B 测试,可以减少客户端加载实验代码带来的布局偏移,并通过更小的 JavaScript 包提升站点性能。因为不同变体是在边缘被静态生成出来的,实验变体不再需要在 DOM 中由客户端脚本插入,从而规避了插入瞬间可能产生的布局抖动(layout shift),同时省去了随页面下发的大段实验 JS,缩小了首屏传输体积。
整个示例的运行流程可以概括为一条闭合链路:
- 用户访问
/home或/marketing; - Middleware 检查请求路径,命中对应的实验路由配置;
- 从 Cookie 中读取已分配的桶(bucket),若无 Cookie 或值非法则调用
getBucket随机分配一个新桶; - 通过
NextResponse.rewrite将请求在边缘静默改写为/home/a、/marketing/b等变体页面; - 如果桶是新建的,把桶值写回响应 Cookie,保证同一用户后续访问稳定命中同一变体;
- 变体页面本身由
getStaticPaths预渲染,边缘无需动态渲染,性能开销极低。
分桶配置:路由、Cookie 名与变体列表
分桶的“总调度表”定义在 middleware.ts 顶部:
import { NextRequest, NextResponse } from 'next/server' import { getBucket } from '@lib/ab-testing' import { HOME_BUCKETS, MARKETING_BUCKETS } from '@lib/buckets' type Route = { page: string cookie: string buckets: readonly string[] } const ROUTES: Record<string, Route | undefined> = { '/home': { page: '/home', cookie: 'bucket-home', buckets: HOME_BUCKETS, }, '/marketing': { page: '/marketing', cookie: 'bucket-marketing', buckets: MARKETING_BUCKETS, }, } export const config = { matcher: ['/home', '/marketing'], }这里暴露了三个关键设计点:
- 路由与变体解耦:
Route中的page是变体页面的目录前缀(如/home),cookie是持久化桶值的 Cookie 名(bucket-home、bucket-marketing),buckets是该实验的变体列表。增加新实验只需在ROUTES中追加一项。 - matcher 精确限定实验范围:
config.matcher声明 Middleware 只对/home与/marketing两个路径生效,其余请求完全不经过这段分桶逻辑,避免对全站流量造成额外开销。 - 类型约束:
buckets被声明为readonly string[],配合@lib/buckets中as const的只读元组,保证变体列表在编译期不可被意外修改。
变体列表集中定义在 lib/buckets.ts:
export const HOME_BUCKETS = ['a', 'b', 'c'] as const export const MARKETING_BUCKETS = ['original', 'b', 'c'] as const/home有a、b、c三个变体;/marketing有original、b、c三个变体,其中original代表未经改动的原版营销页(对应独立的 pages/marketing/original.tsx),适合“变体与原始页差异较大、不适合合并到同一页面”的场景。
Middleware 核心逻辑:Cookie 读取、合法性校验与边缘重写
完整的中间件实现在 middleware.ts 中,可按步骤拆解:
export default function middleware(req: NextRequest) { const { pathname } = req.nextUrl const route = ROUTES[pathname] if (!route) return // Get the bucket from the cookie let bucket = req.cookies.get(route.cookie)?.value let hasBucket = !!bucket // If there's no active bucket in cookies or its value is invalid, get a new one if (!bucket || !route.buckets.includes(bucket as any)) { bucket = getBucket(route.buckets) hasBucket = false } // Create a rewrite to the page matching the bucket const url = req.nextUrl.clone() url.pathname = `${route.page}/${bucket}` const res = NextResponse.rewrite(url) // Add the bucket to the response cookies if it's not there // or if its value was invalid if (!hasBucket) { res.cookies.set(route.cookie, bucket) } return res }各环节的作用如下:
- 路径兜底:
if (!route) return确保未在ROUTES中登记的路径直接放行,不产生任何额外行为; - Cookie 读取:
req.cookies.get(route.cookie)?.value从请求中取出用户上次被分配的桶; - 合法性校验:即使 Cookie 存在,也会用
route.buckets.includes(bucket)校验其值是否仍在当前变体列表中——当实验上线后调整过变体集合时,旧 Cookie 值会被判定为非法并重新分配,这是保证实验数据干净的关键细节; - 边缘重写:
NextResponse.rewrite(url)是整套方案的核心。它在边缘把请求“翻译”成/home/a、/marketing/original这类具体变体路径,对浏览器而言 URL 始终是/home,用户无感知; - Cookie 回写:仅在“原本没有桶”或“桶值非法”时执行
res.cookies.set(route.cookie, bucket),把新桶持久化到响应,使同一访客的后续请求稳定命中同一变体,满足 A/B 实验对用户分组稳定性的基本要求。
分桶算法:基于 Web Crypto 的均匀随机分配
新桶由 lib/ab-testing.ts 中的getBucket产生:
export function getBucket(buckets: readonly string[]) { // Get a random number between 0 and 1 let n = cryptoRandom() * 100 // Get the percentage of each bucket let percentage = 100 / buckets.length // Loop through the buckets and see if the random number falls // within the range of the bucket return ( buckets.find(() => { n -= percentage return n <= 0 }) ?? buckets[0] ) } function cryptoRandom() { return crypto.getRandomValues(new Uint32Array(1))[0] / (0xffffffff + 1) }算法要点:
- 真随机数来源:
cryptoRandom()使用 Web Crypto API 的crypto.getRandomValues生成 32 位无符号整数,再除以0xffffffff + 1归一化到[0, 1)区间。该 API 在 Next.js 的 Edge Runtime 中原生可用,随机性优于Math.random(),且无需引入任何第三方依赖。 - 等概率均分:将随机数放大 100 倍后,用
100 / buckets.length作为每个桶的区间宽度,依次累减,落到哪个区间就命中哪个桶。由于 Middleware 运行在边缘节点,这段逻辑会在距离用户最近的数据中心执行,延迟开销可忽略。 - 兜底保护:
?? buckets[0]保证极端情况下(如浮点边界)也能返回一个合法桶,不会产生未定义行为。 - 天然支持扩展:该算法不依赖桶的绝对数量,新增或删除变体只需修改
buckets数组,分配比例会自动重新均分。若后续需要非均匀分配(如 70/30 流量比),可在此函数内引入权重参数。
页面落地:getStaticPaths 预渲染变体与前端手动切桶
变体页面的静态生成
/home的变体页面由 pages/home/[bucket].tsx 承载,通过getStaticPaths为每个桶生成一个静态页面:
export async function getStaticPaths() { return { paths: HOME_BUCKETS.map((bucket) => ({ params: { bucket } })), fallback: false, } } export async function getStaticProps() { // Here you would return data about the bucket return { props: {} } }paths与buckets一一对应,构建时产出/home/a、/home/b、/home/c三个静态页面;fallback: false意味着未在列表中出现的路径直接返回 404,同时确保 Middleware 改写出的目标路径必然存在;getStaticProps中预留了“按桶返回实验数据”的扩展位,实际项目中可在此按桶下发不同的文案、价格或功能开关配置。
/marketing的变体页面 pages/marketing/[bucket].tsx 略有不同:它在getStaticPaths中过滤掉了original桶(pages/marketing/original.tsx 单独承载原版页),源文件注释解释了原因——当变体与原始页面差异很大、不想合并到同一模板时,用独立页面更清晰:
const buckets = MARKETING_BUCKETS.filter((bucket) => bucket !== 'original') return { paths: buckets.map((bucket) => ({ params: { bucket } })), fallback: false, }这也展示了两种变体组织模式的取舍:同一模板多变体(如/home)与独立页面占位(如/marketing/original)。
前端手动切桶:便于 QA 与演示
为了让测试者能手动切换实验组,两个变体页面都通过js-cookie提供了前端切桶能力,以 pages/home/[bucket].tsx 为例:
import Cookies from 'js-cookie' const setBucket = (bucket: string) => () => { Cookies.set('bucket-home', bucket) router.reload() } const removeBucket = () => { Cookies.remove('bucket-home') router.reload() }Cookies.set('bucket-home', bucket)直接写入与 Middleware 约定的同名 Cookie,随后router.reload()触发一次新的请求,让 Middleware 重新走一遍读取、校验、重写的流程;Cookies.remove('bucket-home')清除 Cookie 后刷新,则会触发 Middleware 中的“无桶则新分配”分支,验证随机分桶是否生效;- 组件渲染时通过
router.query.bucket读取当前变体名并展示(You're currently on bucket A),按钮列表由HOME_BUCKETS/MARKETING_BUCKETS动态生成,新增变体时 UI 自动同步。
这里的核心约定是:前端js-cookie写入的 Cookie 名必须与 MiddlewareROUTES中的cookie字段完全一致(bucket-home/bucket-marketing),二者协同构成闭环。入口页 pages/index.tsx 则提供/home、/marketing两个实验入口的导航说明。
路径别名与全局布局
- 源码中
@lib/ab-testing、@lib/buckets等导入依赖 tsconfig.json 中声明的路径别名:"@lib/*": ["lib/*"],保持引用简洁的同时不受目录层级影响; - 全局布局由 pages/_app.tsx 通过
@vercel/examples-ui的getLayout注入,样式来自@vercel/examples-ui/globals.css; - 依赖方面(package.json)仅需
next、react、react-dom、js-cookie与@vercel/examples-ui,开发依赖含typescript、tailwindcss、turbo等,整体非常轻量,这正是“更小 JS 包”目标的体现。
运行与部署:两种接入方式
原文档提供了两种使用方式,均可直接套用:
方式一:一键部署到 Vercel
点击原文档中的 “Deploy with Vercel” 按钮(对应仓库元数据deployUrl),即可将该示例直接克隆并部署到 Vercel 云环境,无需本地搭建。部署后访问线上地址,/home与/marketing的分桶逻辑会立即生效。
方式二:本地克隆运行
使用create-next-app配合 pnpm 拉取示例(以当前仓库中的edge-middleware/ab-testing-simple为模板):
pnpm create next-app --example https://github.com/vercel/examples/tree/main/edge-middleware/ab-testing-simple ab-testing-simple进入项目目录后启动开发模式:
pnpm dev随后在浏览器打开http://localhost:3000,依次访问/home与/marketing,即可观察到:
- 首次访问后响应中会带出
bucket-home或bucket-marketingCookie; - 刷新页面变体保持稳定(同一 Cookie 命中同一桶);
- 使用页面上的 “Bucket X” 按钮或 “Remove bucket” 按钮,可手动切换或重置实验组。
部署到生产环境时,执行pnpm build进行构建,然后通过pnpm start启动,或直接推送到 Vercel 由平台自动构建部署(项目已内置 vercel.json 等配置,package.json也提供了完整的dev/build/start/lint脚本)。
拓展思路:从这个示例可以延伸出什么
- 接入第三方实验平台:本仓库还提供了基于 Statsig 的更完整示例(edge-middleware/ab-testing-statsig),可以看到同样的边缘分桶思路如何与外部 Feature Flag 服务集成,包括规则下发、数据上报等能力;
- 改为非均匀流量:修改
getBucket中的percentage计算逻辑,即可支持 70/30 等自定义流量比例; - 按地理/设备细分:Middleware 中可读取
req.geo或 UA 信息参与分桶,实现更精细的实验受众控制; - 数据分析埋点:在 Middleware 重写的同时写入审计日志或调用分析 API,记录“哪个用户看了哪个变体”,为后续显著性检验提供数据基础。
小结
ab-testing-simple用最少的代码展示了边缘 A/B 测试的完整范式:config.matcher限定实验路径 → Cookie 读取与校验 →getBucket均匀随机分桶 →NextResponse.rewrite边缘重写 →getStaticPaths静态变体页面。相比客户端注入实验代码的传统方案,它把实验逻辑前移到边缘节点,既消除了布局偏移、又缩小了 JS 体积,是 Next.js 应用中低侵入、高性能 A/B 测试的理想起步模板。
【免费下载链接】examplesEnjoy our curated collection of examples and solutions. Use these patterns to build your own robust and scalable applications.项目地址: https://gitcode.com/GitHub_Trending/examples1/examples
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考