مستمع واحد بدلاً من مكوّن عميل لكل زر
في اللحظة التي تضيف فيها تحليلات النقر إلى مكوّن، يتحوّل إلى مكوّن عميل يشحن جافاسكريبت. افعل ذلك عبر الموقع بأكمله، ويكفي التتبّع وحده لتضخيم الحزمة. إليك البديل الموجود في المنصّة منذ الأزل: مستمع واحد مفوَّض، وسمات data تصريحية، ومكوّنات تبقى HTML صرفًا مُصيَّرًا على الخادم.
إليك تسلسلًا يتكرّر في كل قاعدة شيفرة تقريبًا. يطلب منك مدير المنتج تتبّع النقرات على زر. الزر مكوّن ثابت جميل مُصيَّر على الخادم. فتضيف معالج onClick. ولكي يوجد معالج onClick، صار على المكوّن الآن أن يعمل في المتصفّح — فتضيف "use client" (أو تلجأ إلى خطّاف أو غلاف). يترطّب المكوّن. ويشحن جافاسكريبت. وقد فعل كل ذلك لا ليفعل أي شيء على العميل، بل ليهمس بسطرٍ واحد إلى تحليلاتك.
والآن اضرب ذلك في كل زر وبطاقة ورابط وتبويب مُتتبَّع عبر موقع كبير. جزء لا يُستهان به من حزمة جافاسكريبت لديك موجود لمجرّد الإبلاغ عن النقرات. تدور هذه المقالة حول البديل — البديل القابع في المتصفّح منذ تسعينيات القرن الماضي ولا يحتاج إلى أي إطار عمل: تفويض الأحداث. مستمع صغير واحد يراقب الصفحة بأكملها؛ وتعود مكوّناتك إلى كونها HTML صرفًا.
المعالج الذي تضيفه دون تفكير
تبدو هذه العادة غير ضارّة. لديك رابط، وتريد أن تعرف متى يُنقر عليه، فتُرفق معالجًا:
"use client"; // ← the moment analytics arrives, so does this line
import { track } from "@/lib/analytics";
export function CtaButton({ href, label, place }: Props) {
return (
<a
href={href}
onClick={() =>
track("cta_clicked", { place, label })
}
>
{label}
</a>
);
}سطر واحد من التتبّع، وقد تغيّرت فئة المكوّن بأكملها. لم يعد ترميزًا ثابتًا يمكن للخادم أن يبثّه ثم ينساه — بل صار جزيرة تفاعلية يجب على العميل تنزيلها وتحليلها وترطيبها قبل أن يوجد ذلك المعالج onClick. لا يستطيع الإطار أن يعرف أن المعالج مجرّد قياسات عن بُعد من نوع أطلِق وانسَ؛ فبقدر ما يعلم، يحتاج هذا المكوّن إلى أن يكون حيًّا على العميل.
تحمل تلك العادة تكاليف تتراكم عبر قاعدة الشيفرة:
- يصبح كل مكوّن مُتتبَّع حدًّا للترطيب — يُنزَّل جافاسكريبت ويُحلَّل ويُنفَّذ من أجل شيء لا يغيّر الواجهة أبدًا.
- يتناثر منطق التتبّع عبر مئات المكوّنات، وكلٌّ منها يستورد حزمة تطوير التحليلات (SDK)، فينتهي بها المطاف في حزم كثيرة.
- تُحمَّل مكتبة التحليلات على المسار الحَرِج، منافِسةً التصيير الذي ينتظره المستخدم فعلًا.
- "أيّ الأحداث نُطلق، ومن أين؟" يتحوّل إلى مشروع تنقيب أثري — فالجواب متناثر عبر شجرة المكوّنات بأكملها.
لماذا يبدو صوابًا
يجدر بنا أن نكون صادقين حول سبب فعلنا هذا. التموضع المشترك (co-location) غريزة جيدة بحق: النقرة والشيء الذي تريد تسجيله عن النقرة يقعان في المكان نفسه، فوضع نداء التتبّع هناك مباشرةً يُقرأ جيدًا ويسهل التفكير فيه. وتعزّز الأُطر ذلك — فخاصية onClick هي الطريقة البديهية المُوثَّقة للتفاعل مع نقرة، ويحدث أنها تجرّ الترطيب معها.
لكن التموضع المشترك لنيّة التتبّع لا يستلزم التموضع المشترك لالشيفرة التي تتتبّع. يمكنك إبقاء التصريح بجوار العنصر — "هذا الزر نقرةُ دعوةٍ إلى إجراء" — بينما يجري الإنصات الفعلي في مكان آخر تمامًا. لطالما كان المتصفّح قادرًا على هذا؛ لكننا كففنا عن اللجوء إليه حين جعلت المكوّنات المعالجاتِ لكل عنصر تبدو مجانية.
الأحداث تتصاعد فقاعيًّا — تلك هي الحيلة كلها
حين تنقر عنصرًا، لا يُطلق المتصفّح حدثًا على ذلك العنصر وحده. بل يسافر الحدث صعودًا في شجرة DOM — من العنصر إلى أبيه، إلى أبِ أبيه، وصولًا إلى document. هذا هو التصاعد الفقاعي (bubbling)، وهو يعني أن مستمعًا واحدًا في القمة يمكنه رصد النقرات على كل ما تحته. لست بحاجة إلى مستمع لكل زر؛ أنت بحاجة إلى مستمع واحد يراقب المستند ويحدّد، عند كل نقرة، ما الذي نُقر عليه فعلًا.
والأداة لذلك هي Element.closest(). انطلاقًا من حيثما وقعت النقرة — وقد يكون ذلك أيقونة أو <span> متداخلًا داخل رابطك — يصعد closest() حتى يجد سلَفًا مطابقًا لمُحدِّد. اطلب منه أقرب عنصر يحمل علامة تتبّع، فيعيد إليك الزر المنقور بعينه، بصرف النظر عمّا كان تحت المؤشّر مباشرة. تلك البِنية الأوّلية الواحدة تحلّ محلّ كل معالج على مستوى المكوّن.
العقد هو سمة data
إذا كان مستمع عمومي سيتولّى النقرة، فإن مهمّة المكوّن الوحيدة هي أن يصرّح بما ينبغي تتبّعه — في الترميز، حيث يستطيع الخادم تصييره. لدى HTML الآليّة أصلًا: سمات data-*. يصف الزر نفسه ولا يشحن أي سلوك:
// No "use client". No handler. No hooks. Pure server-rendered HTML.
export function CtaButton({ href, label, place }: Props) {
return (
<a
href={href}
data-track="cta_clicked"
data-place={place}
data-label={label}
>
{label}
</a>
);
}لا وجود ل"use client"، ولا معالج، ولا حزمة SDK مستوردة — لا شيء ليرطّبه العميل. يبثّ الخادم HTML صِرفًا، وتنتقل نيّة التتبّع معه في هيئة سمات. هذا ما يصل إلى المتصفّح فعلًا:
<!-- What the server sends. The browser needs nothing else to make it work. -->
<a href="/pricing" data-track="cta_clicked" data-place="hero" data-label="Start free">
Start free
</a>مستمع واحد للصفحة بأكملها
الآن يقيم السلوك في مكان واحد بالضبط. مستمع نقر واحد على document يقرأ العلامة من أيّما نُقر عليه ويُبلّغ عنها. تصبح data-track الخاصة بالمكوّن اسمَ الحدث؛ وتصبح بقية سمات data-* الحمولةَ — إذ تسلّمها واجهة dataset ككائن عادي:
// The entire client-side cost of analytics for the whole site.
function handleClick(e: MouseEvent) {
// Find the nearest tracked element from wherever the click landed —
// works even if the user clicked an icon or <span> inside the link.
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>("[data-track]");
if (!el) return;
const { track: event, ...data } = el.dataset;
send(event!, data);
}
export function registerTracking() {
document.addEventListener("click", handleClick);
}تسجّله مرة واحدة، عند بدء تشغيل التطبيق، قبل الترطيب — فيكون مراقبًا منذ أول رسمة. أين يقيم ذلك النداء يعتمد على حزمتك التقنية، لكنه دائمًا نداء واحد:
import { registerTracking } from "./track-delegation";
// Next.js: instrumentation-client.ts · Vite/SPA: main.ts · plain HTML: a <script>.
// One call, once, for the entire application.
registerTracking();حمّل الـ SDK عند نقرة حقيقية فقط
ثمّة مكسب ثانٍ خفيّ هنا. المستمع نفسه صغير — بضعة أسطر بلا تبعيات. الجزء الثقيل من التحليلات هو الـ SDK، ومع التفويض لم تعد بحاجة إليه أثناء تحميل الصفحة. يمكنك تأجيل استيراده حتى تقع أول نقرة فعلًا:
// The listener ships ~1 KB. The heavy SDK is pulled only when a real
// click happens — never during page load.
async function send(event: string, data: Record<string, string | undefined>) {
const { track } = await import("@/lib/analytics"); // code-split, on demand
track(event, data);
}الآن صارت كيلوبايتات مزوّد التحليلات خارج المسار الحَرِج تمامًا. لا شيء يخصّ التتبّع ينافس التصيير الأول؛ يصل الـ SDK بتكاسل، عند الطلب، لحظة تفاعل المستخدم لأول مرة — وبالنسبة إلى مستخدم يغادر دون نقر، فإنه لا يُحمَّل البتّة.
التفاصيل التي تعضّ
من السهل أن تُصيب 90% من تفويض النقرات ثم تكسر تجربة المستخدمين المتمرّسين. النقرة اليسرى العادية من حقّك أن تعالجها، أما نقرة Cmd/Ctrl/Shift أو نقرة الزر الأوسط فهي المستخدم يطلب من المتصفّح فتح رابط في تبويب جديد أو تنزيله. إذا استدعى مستمعك preventDefault() دون شرط، فإنك تختطف تلك النيّة بصمت. تراجَعْ مبكّرًا عند النقرات المُعدَّلة وغير الأساسية، ودَعْ href الأصلي للمرساة يؤدّي عمله:
function handleClick(e: MouseEvent) {
// Cmd/Ctrl/Shift/Alt-click and middle-click express a native intent
// (open in new tab, download). Let the browser handle those untouched.
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
// ...find [data-track], send event
}الفخّ الثاني هو التوقيت. إذا نقلت النقرةُ الصفحةَ بعيدًا، فقد يُجهِض التفريغ طلبَ تحليلات كان لا يزال قيد الإرسال — فتكون النقرات التي تتوق أكثر ما تتوق إلى قياسها هي الأكثر عرضة للضياع. يوجد navigator.sendBeacon لهذا الغرض تحديدًا: فهو يسلّم حمولة صغيرة إلى المتصفّح ليوصّلها في الخلفية، فتنجو من الانتقال:
function send(event: string, data: Record<string, string | undefined>) {
// A click that unloads the page can abort an in-flight fetch. sendBeacon
// hands the request to the browser to deliver even as navigation happens.
navigator.sendBeacon(
"/track",
JSON.stringify({ event, ...data }),
);
}أما الروابط الداخلية التي يتولّاها موجِّه من جانب العميل، فلا ينطبق عليها أيٌّ من هذا — إذ لا يوجد تفريغ، فالطلب العادي كافٍ ويجلس المستمع المفوَّض جنبًا إلى جنب مع الموجِّه بلا مشكلة. الـ beacon يهمّ الروابط التي تُحدث انتقالًا حقيقيًّا للصفحة بأكملها. ومعرفة أيٌّ من روابطك يفعل أيًّا من الأمرين هي الدقّة كلها.
يجب ألّا يكسر المستمع الرابط أبدًا. احترم النقرات المُعدَّلة، واستخدم beacon للانتقالات، وعامِل التحليلات على أنها بذلُ الوسع الأقصى لا غير — فينبغي أن يكون فشل التتبّع غير مرئيّ للمستخدم، لا زرًّا ميّتًا أبدًا.
ما وراء النقرات
النقرات هي الحالة الشائعة، لكن النمط يتعمّم إلى أي حدث يستطيع مستمع مفوَّض رصده. فأكورديون <details> الأصلي، مثلًا، يُفتح ويُغلق بصفر جافاسكريبت — ومع ذلك يمكنك قياس كم مرّة يفتحه الناس، لأن حدث toggle يمكن تفويضه بالطريقة نفسها. العقبة الوحيدة: toggle لا يتصاعد فقاعيًّا، لذا تُنصت في طور الالتقاط (capture):
// The same pattern, a different event. A native <details> accordion needs
// zero JavaScript to open — and one delegated listener to be measurable.
document.addEventListener("toggle", (e) => {
const el = e.target as HTMLElement;
if (el.matches("[data-track-open]") && (el as HTMLDetailsElement).open) {
send(el.dataset.trackOpen!, { ...el.dataset });
}
}, true); // capture: the toggle event does not bubbleإرسال النماذج، تغيّرات الظهور، تشغيل الوسائط — تنطبق الصيغة التصريحية نفسها. يذكر المكوّن، في الترميز، ما يستحقّ التسجيل؛ وتقوم حفنة من المستمعين العموميين بالتسجيل. وهكذا يتوقّف السطح التفاعلي لصفحتك وقابلية رصد صفحتك عن أن يكونا الشيء نفسه.
لماذا يهمّ هذا أكثر الآن
تفويض الأحداث عمره عقود، وظلّ ردحًا طويلًا ميزةً مستحسَنة — طريقةً لإرفاق مستمع واحد بقائمة بدلًا من ألف. أما في عصر مكوّنات الخادم فرهانه أعلى. حين يكون الأصل مكوّنًا يُصيَّر على الخادم ولا يشحن جافاسكريبت، لم يعد onClick واحدٌ خطأ تقريبٍ يُهمَل: إنه السطر الذي يقلب قسمًا بأكمله من HTML ثابت إلى جزيرة عميل مُرطَّبة.
التفويض هو ما يتيح لك الإبقاء على ذلك الأصل. فالتحليلات — التي كانت تاريخيًّا أحد أشيع الأسباب التي تجعل مكوّنًا عرضيًّا "مضطرًّا" لأن يكون مكوّن عميل — تكفّ عن فرض الحدّ. وأقسام بأكملها لم تكن تحتاج إلا إلى أن تُتتبَّع لا إلى أن تكون تفاعلية، يمكنها أن تبقى مُصيَّرة على الخادم ولا تشحن شيئًا. وهكذا تنفصل تكلفة رصد واجهتك عن تكلفة ترطيبها.
التفويض في مقابل المعالج لكل مكوّن، بصراحة
| الجانب المعنيّ | معالج في كل مكوّن | مستمع مفوَّض واحد |
|---|---|---|
| جافاسكريبت لكل عنصر مُتتبَّع | يُشحَن (حدّ ترطيب) | لا شيء — HTML خادمي صرف |
| حزمة تطوير التحليلات (SDK) | تُستورَد في حزم كثيرة | تُحمَّل مرة واحدة، بتكاسل، عند أول نقرة |
| على المسار الحَرِج | نعم | لا |
| أين يقيم التتبّع | متناثر عبر الشجرة | ملف واحد |
| تدقيق كل الأحداث | grep لقاعدة الشيفرة كاملة | اقرأ العلامات / معالج واحد |
| يعمل مع العناصر المُضافة لاحقًا | يحتاج معالجًا لكلٍّ | تلقائيًّا (تفويض) |
| الاقتران بالإطار | مرتبط بدورة حياة المكوّن | DOM صِرف — مستقل عن الإطار |
التكلفة الصادقة للتفويض قدرٌ يسير من الانضباط: صار العقد الآن سماتٍ مُنمَّطة بالنصوص بدلًا من نداء دالّة مُنمَّط، فخطأٌ مطبعيّ في اسم data-track يفشل بصمت لا وقت الترجمة. ودالّة مساعِدة صغيرة تبني السمات من وسيط مُنمَّط تستعيد معظم ذلك الأمان، ومستمع واحد جيّد التنميط سطحٌ أصغر بكثير للحفاظ على استقامته من معالجات منثورة عبر مئات الملفات.
لا شيء من هذا جديد
إن بدا هذا مألوفًا، فحقٌّ له. فهكذا عملت مديرات الوسوم وتحليلات الالتقاط التلقائي (autocapture) على الدوام. Google Tag Manager وSegment وPostHog وHeap — تحت الغطاء تُرفق بضعة مستمعين عموميين وتقرأ السمات (أو تستنتج المحدِّدات) من أيّما نُقر عليه. من شبه المؤكّد أنك شحنت هذا النمط سلفًا؛ لكنك تركت سكربتًا من طرف ثالث يملكه.
مغزى فعله عن قصد هو التحكّم والوزن. فنحو ثلاثين سطرًا تمنحك آليّات التفويض نفسها دون سكربت مزوّد على مسارك الحَرِج، ودون التقاط تلقائي غامض يُطلق أحداثًا لم تقصدها، ومع عقد سمات data يمكنك قراءته وتنميطه واختباره. تحتفظ بالأريحية التي جعلت الالتقاط التلقائي رائجًا، وتُسقط الضريبة.
الخلاصات
- إضافة
onClickللتتبّع إلى مكوّن عرضيّ تُرقّيه بصمت إلى مكوّن عميل يشحن جافاسكريبت. - لست بحاجة إلى معالج لكل عنصر — فالنقرات تتصاعد فقاعيًّا إلى
document، وclosest("[data-track]")يستعيد ما نُقر عليه بالضبط. - دَعِ المكوّنات تصرّح بالتتبّع عبر سمات
data-*، وأبقِ السلوك في مستمع عمومي واحد يُسجَّل مرة واحدة عند بدء التشغيل. - استورِد حزمة التحليلات (SDK) بتكاسل داخل المستمع لتبقى خارج المسار الحَرِج ولا تُحمَّل أبدًا للمستخدمين الذين لا يتفاعلون.
- احترم النقرات المُعدَّلة/غير الأساسية، واستخدم
sendBeaconللانتقالات، وأبقِ التحليلات على مستوى بذل الوسع كي لا تكسر رابطًا أبدًا. - في عصر مكوّنات الخادم، التفويض هو ما يمنع التحليلات من تحويل الأقسام الثابتة إلى جزر مُرطَّبة.
لا شيء من العناصر هنا غريب: التصاعد الفقاعي للأحداث، وclosest()، وسمات data-*، وimport() الكسول، وsendBeacon كلّها ميزات منصّة مملّة جيّدة الدعم. التحوّل في المكان الذي تضع فيه السلوك. توقّف عن شحن معالج نقر مع كل مكوّن، اشحن مستمعًا واحدًا للصفحة بأكملها، ودَعْ مكوّناتك تعود إلى ما تُصيّره خير تصيير — HTML.