资讯详情

资讯详情

边缘 A/B 测试实战:解析 ab-testing-simple 中基于 Next.js Middleware 与 Cookie 的分桶方案

边缘 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/examplesA/B 测试在传统实现中通常由客户端脚本在页面加载后动态插入实验变体容易引发布局偏移CLS并拖慢首屏性能。本仓库的edge-middleware/ab-testing-simple示例给出了一条更优的路径将分桶逻辑全部收敛到 Next.js MiddlewareEdge Middleware中在边缘节点通过 Cookie 为用户分配实验桶并借助NextResponse.rewrite把请求改写为静态生成的变体页面。读完本文你将掌握如何用 Middleware Cookie 静态页面重写搭建一套零客户端实验代码的 A/B 测试方案并理解其分桶算法、路由匹配与页面落地的完整实现。方案概览为什么要在边缘做 A/B 测试原文档edge-middleware/ab-testing-simple/README.md开篇点明了这个示例的核心动机By A/B testing at the edge, youll reduce layout shift from client-loaded experiments and improve your sites performance with smaller JavaScript bundles.即在边缘进行 A/B 测试可以减少客户端加载实验代码带来的布局偏移并通过更小的 JavaScript 包提升站点性能。因为不同变体是在边缘被静态生成出来的实验变体不再需要在 DOM 中由客户端脚本插入从而规避了插入瞬间可能产生的布局抖动layout shift同时省去了随页面下发的大段实验 JS缩小了首屏传输体积。整个示例的运行流程可以概括为一条闭合链路用户访问/home或/marketingMiddleware 检查请求路径命中对应的实验路由配置从 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: Recordstring, 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是变体页面的目录前缀如/homecookie是持久化桶值的 Cookie 名bucket-home、bucket-marketingbuckets是该实验的变体列表。增加新实验只需在ROUTES中追加一项。matcher 精确限定实验范围config.matcher声明 Middleware 只对/home与/marketing两个路径生效其余请求完全不经过这段分桶逻辑避免对全站流量造成额外开销。类型约束buckets被声明为readonly string[]配合lib/buckets中as const的只读元组保证变体列表在编译期不可被意外修改。变体列表集中定义在 lib/buckets.tsexport 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 theres 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 its 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读取当前变体名并展示Youre 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),仅供参考
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →