Skip to main content
العودة إلى المدونة
Next.jsA/B TestingProxyPerformanceSSG

صفحة ثابتة هي في الخفاء اختبار A/B

عادةً ما يُبنى اختبار A/B بحيث تسقط الصفحة من الـ CDN إلى تصيير عند كل طلب. لا يجب أن يكون كذلك. أبقِ كل متغيّر ثابتًا بالكامل ودع الـ proxy (الـ middleware الذي أعادت Next.js تسميته) يحلّ الإسناد عبر منصّة تجارب، اعتمادًا على device id ثابت — ثم أعِد الكتابة فقط حين لا يكون الزائر في المجموعة الضابطة. الرابط لا يتغيّر أبدًا، والـ CDN ما زال يخزّن مؤقتًا، والمسار الشائع لا يكلّف شيئًا.

نُشر 14 أغسطس 20268 دقيقة قراءة

في كل مرة تختبر فيها صفحة بـ A/B، يجعلها المسار السهل ديناميكية بهدوء — فتخسر الـ CDN. لست مضطرًا لهذه المقايضة. إليك كيف تُبقي كل متغيّر ثابتًا وتُخفي التجربة كلها خلف proxy.

التوتّر

تريد اختبار صفحة بـ A/B — التسعير، بطل صفحة هبوط، خطوة onboarding. ما إن تفعل ذلك، حتى تتوقف عادةً عن كونها ثابتة:

  • A/B على العميل يشحن JavaScript، ويومض أولًا بالمتغيّر الأصلي، ويُزيح التخطيط. كما يحتاج JS ليعمل أصلًا.
  • التصيير عند كل طلب يعمل، لكن كل زيارة تذهب الآن إلى الـ origin لديك — لا تخزين مؤقت في الـ CDN، وTTFB أسوأ، وحساب أكثر.
  • Vary: Cookie يبدو مغريًا لكنه يُجزّئ الكاش إلى مدخل لكل قيمة cookie.

لكن الصفحة تبقى في جوهرها HTML ثابتًا. الشيء الديناميكي الوحيد هو أيّ HTML ثابت — وهل يختلف أصلًا عن الافتراضي. اعزل ذلك بالضبط.

الفكرة

  • أبقِ المجموعة الضابطة كالصفحة الحقيقية الثابتة على /pricing.
  • أبقِ كل متغيّر غير ضابط كصفحته الثابتة الخاصة تحت مسار مخصّص.
  • داخل proxy، حُلّ متغيّر الزائر عبر SDK تجارب. ضابط؟ لا تفعل شيئًا — تُصيَّر الصفحة الثابتة كما هي. متغيّر؟ أعِد الكتابة إلى صفحته الثابتة. الرابط هو نفسه في الحالتين.

الجزء الذكي هو اللاتماثل: معظم الترافيك ضابط، والضابط لا يكلّف أي إعادة كتابة.

المعرّف الثابت

التوزيع المتّسق إلى مجموعات يحتاج معرّفًا ثابتًا، لا رمية عملة جديدة عند كل طلب. يكفي cookie device_id طويل العمر — يُجزّئه SDK التجارب (مع أي سمات استهداف)، فيقع الجهاز نفسه دائمًا في المتغيّر نفسه ويبقى التقسيم على الأوزان التي حدّدتها.

المعرّف نفسه هو مفتاح الإسناد لديك: كل تحويل يمكن تعقّبه رجوعًا إلى المجموعة التي كان فيها الجهاز.

الصفحات

الضابط صفحة ثابتة عادية. المتغيّرات تعيش تحت مسار لا يربط إليه شيء مباشرةً:

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 .

الـ config الذي يعمل عليه الـ proxy — مُخزَّن في الذاكرة

لا يستطيع الـ proxy أن يعرف بالسحر أيّ الصفحات تحت الاختبار. تلك الخريطة — أيّ صفحة لها تجربة، ومفتاحها، وslug كل متغيّر — تعيش في الـ CMS لديك. لكن لا يمكنك جلبها عند كل طلب (فذلك يضع الـ CMS في المسار الساخن لكل تحميل صفحة)، وزمن تشغيل الـ proxy لا يمنحك كاش fetch الذي كنت ستستخدمه في Server Component. لذا تنسحب منه بـ 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() تعمل مرة واحدة ويجب أن تنتهي قبل أن يبدأ الخادم بقبول الطلبات، فلا يدفع الزائر الأول ثمن الجلب أبدًا. وحدّثه عند تغيّر المحتوى، لا بمؤقّت: وجّه webhook من الـ CMS إلى مسار يعيد بناء الخريطة.

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 تشترك فيه النسخ — إضافةً إلى purge للـ CDN للروابط المتأثّرة. هذا الـ fan-out شأن بنية تحتية، لا كود التطبيق. ليست لديك هذه السباكة؟ فإنّ TTL قصيرًا على الكاش يحدّ من القِدَم بدلًا من ذلك — أبسط، لكنه ليس فوريًّا.

لماذا SDK تجارب وليس Math.random()

كتابة random خاص بك في الـ proxy تقسم الترافيك تقنيًا، لكنك تخسر كل ما يجعل التجربة جديرة بالثقة. منصّة مثل GrowthBook (أو Statsig وLaunchDarkly وUnleash) تمنحك:

  • تجزئة متّسقة حسب device_id، فلا يقفز الزائر أبدًا بين المتغيّرات.
  • أوزان وطرح تدريجي تغيّرها دون نشر — 50/50، وcanary بنسبة 5%، والصعود إلى 100%.
  • استهداف حسب السمات التي تمرّرها (البلد، اللغة، UTM، الخطة)، يُقيَّم على الخادم.
  • مفتاح إيقاف (kill switch) ومكان واحد ترى فيه كل تجربة قيد التشغيل.

الـ proxy يطلب فقط من المنصّة متغيّرًا ثم يعيد الكتابة. منطق التجارب يعيش حيث ينبغي.

لماذا ما زال الكاش يعمل

الـ CDN يفهرس على هدف إعادة الكتابة. الضابط هو /pricing (مخزَّن). كل متغيّر هو /ab/pricing/b و/ab/pricing/c (مخزَّنة). يرى الزائر /pricing طوال الوقت. لا Vary: Cookie، ولا تجزئة لكل مستخدم — والضابط، مسار الأغلبية، لا يمرّ حتى بإعادة كتابة.

التحليلات

الـ cookie قصير العمر ab_pricing يحمل المجموعة المحلولة. اقرأه حيثما تُرسل الأحداث — على الخادم من الطلب، أو على العميل لوسم تحليلاتك — بحيث يُنسب كل تحويل إلى control أو b أو c. والصفحة لا تغادر الـ CDN أبدًا لتحقيق ذلك.

لا تدعه يتسرّب إلى الـ SEO

  • ضَع canonical نحو /pricing على صفحات المتغيّرات، كي يوحّد البحث على الرابط الحقيقي.
  • أبقِ /ab/* خارج خريطة الموقع (sitemap) وأضِف إليه noindex.
  • اجعل الـ SDK يُعيد الضابط لزواحف البحث (أو عند غياب device_id) — تحصل البوتات على صفحة واحدة ثابتة.

المطبّات التي تكلّف أمسية

  • الضابط لا يفعل شيئًا. فرع control هو NextResponse.next() — لا إعادة كتابة، ولا صفحة متغيّر، ولا كلفة على المسار الساخن. هذا اللاتماثل هو بيت القصيد.
  • افرض عليها الثبات. علّم الضابط وصفحات المتغيّرات بـ force-static كي تُصيَّر مسبقًا وتُخزَّن؛ فأي API ديناميكي شارد يُنزلها إلى تصيير عند كل طلب.
  • الـ proxy يعمل الآن على Node أولًا (v16). أبقِه نحيلًا — قراءة cookie واحدة، ونداء SDK واحد، وإعادة كتابة واحدة — كي يبقى رخيصًا أينما نُشِر.
  • الاستهداف يأتي من الطلب. الموقع الجغرافي من ترويسة الـ CDN (cf-ipcountry)، واللغة من المسار، وUTM من الاستعلام — جمّع السمات في الـ proxy وسلّمها للـ SDK.
  • خطّط للخروج. حين يفوز متغيّر، ادمجه في /pricing، ثم احذف مسار المتغيّر والتجربة. اختبار A/B يُترك يعمل إلى الأبد هو دَين تقني مع cookie.

الخلاصة

  • الصفحة التي هي اختبار A/B يمكن أن تبقى ثابتة 100%. الديناميكي هو فقط أيّ صفحة ثابتة — وغالبًا هو الضابط، الذي لا يتطلّب أي عمل إطلاقًا.
  • حُلّ الإسناد في الـ proxy عبر منصّة تجارب اعتمادًا على device id ثابت — لا random عند كل طلب.
  • أعِد الكتابة فقط للمتغيّرات غير الضابطة؛ يخزّن الـ CDN الضابط وكل متغيّر كصفحته الخاصة. لا Vary: Cookie، ولا إصابة للـ origin.
  • cookie الـ device-id يمنح توزيعًا لزِجًا وإسنادًا؛ وcookie قصير كفُتات الخبز يحمل المتغيّر إلى التحليلات.
  • احمِ الـ SEO بـ canonical، وnoindex على مسار المتغيّرات، والضابط-للبوتات — ثم فكّك التجربة عند انتهائها.

لاحظت خطأً؟

معلومة خاطئة، ترجمة ركيكة، أو شيء يبدو غير صحيح في هذه المقالة؟ راسلني — بلغتك أنت.