关键是在首次请求时通过 cookie 提前传递分组信息,服务端据此同步渲染;首次无 cookie 时生成并写入 bucket id,后续请求复用该 id 确保 ssr 与 hydration 一致,避免闪烁。

要让服务器在 SSR(服务端渲染)阶段就拿到用户所属的 A/B 实验组别,并渲染出对应版本,关键不是“存”,而是“提前读取并复用”——Cookie 必须在首次请求时就携带分组信息,服务端才能基于它做同步渲染。
为什么必须在 SSR 前写入 Cookie
SSR 是无状态的:每次请求都新建上下文,不保留上次的内存或闭包状态。如果分组逻辑只在客户端执行(比如用闭包缓存),服务端根本看不到结果,必然导致首屏渲染 A 版、 hydration 后切到 B 版,出现闪烁或不一致。
所以分组决策必须发生在服务端可访问的位置——而浏览器第一次发请求时,唯一能被服务端读到的持久化载体,就是 Cookie(前提是它已存在)。
实现流程:从首次访问到稳定分流
-
首次访问(无 Cookie):服务端生成一个唯一 Bucket ID(例如基于用户设备指纹哈希,或随机生成 + 存入后端 session),同时通过
Set-Cookie响应头写入该 ID,并设置HttpOnly=false、SameSite=Lax以便后续客户端 JS 也能读取 -
响应中直接使用该 Bucket ID 渲染页面:服务端模板或框架(如 Next.js 的
getServerSideProps、Nuxt 的asyncData)读取请求头中的 Cookie,拿到 Bucket ID,决定展示 A 还是 B 的 HTML 结构 - 后续访问(有 Cookie):浏览器自动带上该 Cookie,服务端再次读取,返回完全一致的版本,避免水合错位
客户端不干预分组,只读取和同步
客户端 JS 不应重新计算分组(比如用 Math.random() 或本地闭包缓存),否则会和 SSR 结果冲突。它的作用仅限于:
- 读取已存在的 Cookie 中的 Bucket ID,用于埋点上报、动态加载资源等补充逻辑
- 在需要前端控制的交互环节(如按钮点击后切换内容)保持与服务端一致的分组判断
- 若需降级(如 Cookie 被禁用),可 fallback 到服务端生成的临时 ID,但需确保该 ID 在本次会话内稳定
注意事项:避免常见漂移陷阱
- 不要依赖客户端时间或随机数生成 Bucket ID:不同请求时机、不同设备、SSR 与 CSR 执行环境差异都会导致结果不一致
- Cookie 值需带签名或加密:防止用户篡改 Bucket ID 恶意刷实验数据;可用服务端密钥对 ID 做简单 HMAC 校验
-
路径与域设置准确:确保 Cookie 的
path=/且domain覆盖所有子路由,否则跨页请求可能丢失 - 避免多层重定向干扰 Cookie 传递:302 跳转链过长可能导致部分中间环节未携带 Cookie,影响首次分组读取











