العودة إلى المدونة

انتقال حالة الظهور: قياس مشاهدات الصفحات بلا إشارات beacon موثوقة

تختلف سلوكيات pagehide و visibilitychange و Beacon API من متصفح إلى آخر. إليك كيفية تسجيل نهاية الجلسة بشكل موثوق دون كوكيز أو معرّفات دائمة.

تسجيل أن صفحة ما قد شوهدت أمر سهل. أما تسجيل اللحظة التي غادر فيها المستخدم فعلًا — بموثوقية، عبر تبديل التبويبات والانتقال إلى الخلفية والإغلاق المفاجئ — فهو من أصعب مسائل تحليلات الويب. وأهميته أن مقاييس التفاعل (مدة البقاء، الارتداد، عمق التمرير) تعتمد على التقاط إشارة نظيفة لنهاية الجلسة. إن أخطأت فيها، فإما أن تفقد بيانات أو أن تُضخّمها.

يحلّ Monoid هذه المسألة بلا كوكيز ولا localStorage ولا أي معرّف دائم. وهذا القيد يُبسّط الأمور في الواقع: فنحن لا نحتاج أبدًا إلى مطابقة جلسة عبر عمليات تحميل الصفحة، وكل ما يهمّنا هو إرسال عيّنة ختامية واحدة دقيقة لكل دورة حياة صفحة. وفيما يلي كيف تتصرف أوليّات المتصفح، وأين تكمن المزالق، وأيّ منها يستخدمه متتبّع Monoid نفسه فعليًا.

لماذا unload هو الحدث الخطأ

كان النهج التاريخي هو الإنصات إلى unload أو beforeunload وإطلاق طلب XHR متزامن. وهذا اليوم ضار فعليًا. فحدث unload غير موثوق على الأجهزة المحمولة: حين ينقل المستخدم التبويب إلى الخلفية ويستعيد نظام التشغيل موارده، كثيرًا ما لا يُطلق unload إطلاقًا. والأسوأ أن تسجيل معالج لـ unload يُخرج الصفحة من ذاكرة التنقل للخلف/للأمام (bfcache) في عدة متصفحات، ما يضرّ بأداء التنقل.

إرشادات web.dev حول Page Lifecycle API صريحة: عامل انتقال visibilitychange إلى hidden بوصفه آخر حدث موثوق ستراه. ولا تعتمد على إطلاق unload أو pagehide على الأجهزة المحمولة.

اقتران visibilitychange مع pagehide

النمط المتين هو الإنصات إلى visibilitychange والتحقق من document.visibilityState === 'hidden'، مع pagehide كإشارة ثانوية. وكلما انتقلت الصفحة إلى حالة الإخفاء، تُفرغ ما لديك لإرساله. وتوثيق MDN لـ Page Visibility API يؤكد أن visibilitychange يُطلق عند نقل التبويب إلى الخلفية أو تصغير المتصفح — وهو أقرب بديل لعبارة «توقّف المستخدم عن النظر».

والتعقيد هنا أن الانتقال إلى الإخفاء قد يُطلق مرات عدة في الجلسة الواحدة (مغادرة التبويب، ثم العودة، ثم المغادرة ثانيةً). لذا عليك أن تجعل عملية الإفراغ عديمة الأثر عند التكرار (idempotent)، أو أن تُطبّق عليها debounce، أو أن تُعيد ضبط ساعتك الخاصة بحيث يُبلّغ كل تشغيل عن مقطع جديد لا يتداخل مع سابقه بدلًا من مجموع متزايد. يتّبع Monoid النهج الأخير: المؤقّت وراء عيّنة مدة البقاء يُعاد ضبطه مع كل انتقال لـ visibilitychange، بحيث لا يُبلّغ حدث hidden ثانٍ لاحقًا في دورة حياة الصفحة نفسها إلا عن الوقت المنقضي منذ آخر إفراغ. ينصت Monoid إلى visibilitychange (إلى جانب احتياطي beforeunload موجود أصلًا) لكنه لا يضيف مستمعًا منفصلًا لـ pagehide — فالاثنان يتداخلان بما يكفي في المسارات المهمّة بحيث لا يفعل مستمعٌ ثانٍ سوى إعادة إدخال مشكلة العدّ المزدوج، دون تغطية أرضية أوسع بشكل ملموس.

إرسال البيانات من صفحة تحتضر: navigator.sendBeacon

لا يمكنك إطلاق fetch غير متزامن أثناء visibilitychange وتتوقع اكتماله — فقد يهدم المتصفح الصفحة قبل ذلك. ولهذا الغرض بالذات وُجدت مواصفة Beacon API. فالدالة navigator.sendBeacon(url, data) تضع في الطابور طلب POST صغيرًا يضمن المتصفح محاولة إرساله حتى بعد زوال المستند، دون حجب الخيط الرئيسي أو تأخير التنقل.

ولـ Beacon حدود يجدر احترامها:

  • حجم الحمولة. تسمح المواصفة لوكيل المستخدم برفض الإشارات التي تتجاوز حدًا يحدده التنفيذ (64 كيلوبايت عادةً). أبقِ الحمولات ضئيلة — حمولات Monoid بضع مئات من البايتات.
  • الطريقة. الإشارات دائمًا POST. وإن كانت نقطة النهاية على الحافة تتوقع GET، فعدّلها.
  • لا استجابة. لا يمكنك قراءة أي شيء عائد. أطلِق وانسَ.

والبديل الحديث هو fetch(url, { keepalive: true })، الذي يُعرّفه معيار Fetch بأنه يسمح للطلب بأن يعيش بعد المستند. ويمنحك keepalive ترويسات وطرائق تفتقر إليها Beacon، لكنه يشترك معها في السقف نفسه للحجم. الحكمة السائدة تقول: استخدم Beacon أولًا ثم عُد إلى fetch مع keepalive — أما Monoid فيقلب هذا الترتيب فعليًا، لأسباب يشرحها القسم التالي.

تنفيذ أدنى بلا كوكيز

إليك نمطًا مرجعيًا يجمع كل ما سبق — sendBeacon أولًا، وfetch مع keepalive كبديل احتياطي له، يُطلقه كلٌّ من visibilitychange وpagehide، مع حارس يمنع الإرسال المزدوج عند تكرار التشغيل:

let sent = false;
function flush() {
  if (sent) return;
  sent = true;
  const payload = JSON.stringify({
    path: location.pathname,
    // بلا معرّفات، بلا كوكيز، بلا بصمة
    visibleMs: Math.round(performance.now())
  });
  const ok = navigator.sendBeacon('/collect', payload);
  if (!ok) {
    fetch('/collect', { method: 'POST', body: payload, keepalive: true });
  }
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});
window.addEventListener('pagehide', flush);

لاحظ ما هو غائب: لا مستمع لـ unload، فتبقى أهلية bfcache محفوظة. ولا معرّف مخزَّن بين عمليات التحميل. وقيمة visibleMs تستعمل performance.now()، وهي ساعة رتيبة لا تحتاج طابعًا زمنيًا من ساعة الحائط ولا حالة دائمة.

أما متتبّع Monoid نفسه فيسلك مسارًا قريبًا لكنه مختلف: fetch مع keepalive فقط (بلا sendBeacon — إذ لا يستطيع التعبير عن mode: 'cors' الذي تحتاجه التثبيتات عبر أصول مختلفة)، وبلا حارس sent، ومع مؤقّت يُعاد ضبطه مع كل انتقال في الظهور بدلًا من أن يُطلَق مرة واحدة ثم يصمت (حُذف فلترة الموافقة وDo Not Track أدناه توخّيًا للوضوح):

function dur() {
  fetch('/duration', {
    method: 'POST',
    body: JSON.stringify({ site_id: siteId, duration_ms: Date.now() - t }),
    headers: { 'Content-Type': 'application/json' },
    keepalive: true,
    mode: 'cors',
  });
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') dur();
  t = Date.now();
});
window.addEventListener('beforeunload', dur);

إعادة ضبط t مع كل انتقال تؤدي الدور نفسه الذي يؤديه حارس sent أعلاه، دون الحاجة إلى علم: فكل استدعاء لا يُبلّغ إلا عن المقطع منذ آخر إعادة ضبط للساعة، بحيث يُبلّغ تشغيل visibilitychange يتبعه beforeunload عند إغلاق التبويب نفسه عن مقطع حقيقي وآخر شبه صفري غير ضار، بدلًا من عدّ مزدوج.

لماذا يناسب هذا التحليلات التي تضع الخصوصية أولًا

لأن Monoid لا يخيط الجلسات معًا أبدًا، فكل طلب ملاحظة كاملة قابلة للتخلص منها. ولا يوجد معرّف عابر للصفحات يجب حمايته، فسقوط عيّنة أو ازدواجها يكلّفك نقطة بيانات واحدة لا ملفًا شخصيًا مشوَّهًا. ويمنحك نمط visibilitychange مع beforeunload الذي يوزّعه Monoid فعليًا قياسًا دقيقًا لمدة البقاء مع إبقاء الصفحة سريعة وصديقة لـ bfcache — وهو ما يُحسّن بذاته مؤشرات Core Web Vitals التي قد تحاول قياسها أصلًا.

قِس المغادرة، لا الشخص. فالمتصفح يمنحك بالفعل كل ما تحتاج إليه.

Sources

Comments

Loading comments…