كل تفاعل في صفحة ويب، من نقرة زر إلى تمرير الفأرة إلى ضغطة لوحة مفاتيح، يمر عبر نظام الأحداث في JavaScript. هو النظام الذي يحوّل صفحة جامدة إلى تطبيق حي. ورغم أن معظم المطورين يستخدمون addEventListener يوميًا، فإن قليلين يفهمون كيف ينتقل الحدث عبر DOM، أو لماذا قد لا يعمل مستمع معين، أو لماذا تبدو الصفحة بطيئة بعد إضافة مستمعي تمرير.

هذا الدليل لا يكتفي بشرح “كيف تضيف مستمعًا”. إنه يفكك النظام من الداخل: كيف ينتقل الحدث من أعلى الشجرة إلى العنصر ثم يعود، وكيف يؤثر ذلك على الأداء والمنطق، وكيف تستخدم التفويض (Event Delegation) لتحسين الأداء، وكيف تنظف المستمعات دون تسريب ذاكرة. الهدف أن تتعامل مع الأحداث كالمهندس، لا كمستخدم للـAPI.

نظرة سريعة: خريطة أحداث JavaScript

الجدول التالي يلخص المفاهيم الأساسية التي يغطيها الدليل، مع مثال سريع لكل منها:

المفهوم الوظيفة مثال
التدفق مسار الحدث عبر DOM capture → target → bubble
addEventListener تسجيل مستمع حدث el.addEventListener(...)
كائن الحدث بيانات الحدث e.target، e.type
preventDefault منع السلوك الافتراضي منع إرسال نموذج
stopPropagation إيقاف انتشار الحدث منع وصول النقر للأب
التفويض مستمع واحد لعناصر كثيرة parent.addEventListener
Passive تحسين أداء التمرير { passive: true }
AbortController إزالة مستمعات متعددة signal: controller.signal

ما هي الأحداث؟ ولماذا هي جوهر الويب التفاعلي؟

أحداث JavaScript

الحدث هو إشعار يصدره المتصفح عند حدوث شيء يستحق الانتباه: نقرة، ضغطة مفتاح، اكتمال تحميل صورة، تغير حجم النافذة، استلام بيانات من الشبكة. المتصفح يطلق الإشعار، والكود الذي سجّل مستمعًا له يستجيب.

قبل الأحداث، كانت صفحات الويب جامدة. الـJavaScript الحديثة كلها تقوم على الأحداث: أطر مثل React وVue تستخدم نظامًا اصطناعيًا (synthetic) مبنيًا على أحداث DOM الحقيقية. حتى لو لم تكن تكتب addEventListener مباشرة، فأنت تستخدم نظام الأحداث عندما تكتب onClick في React أو v-on:click في Vue.

فهم الأحداث الحقيقية يجعلك قادرًا على:

  • تشخيص مشاكل لا يفسرها الإطار (لماذا لا يعمل المستمع؟ لماذا يُستدعى مرتين؟).
  • تحسين الأداء عبر التفويض والمستمعات السلبية.
  • تجنب تسريب الذاكرة بتنظيف المستمعات.
  • بناء مكونات قابلة لإعادة الاستخدام مع أحداث مخصصة.

تدفق الحدث: الالتقاط والهدف والفقاعات

عندما تنقر على زر داخل صفحة، لا يحدث الحدث في الزر فقط. المتصفح يمرره في ثلاث مراحل عبر شجرة DOM:

  1. مرحلة الالتقاط (Capturing): يبدأ الحدث من أعلى شجرة (window أو document)، وينزل نحو العنصر المستهدف.
  2. مرحلة الهدف (Target): يصل الحدث إلى العنصر الذي نُقر عليه.
  3. مرحلة الفقاعات (Bubbling): يعود الحدث صاعدًا من الهدف نحو الجذر.

هذا التدفق هو أساس تفويض الأحداث (Event Delegation)، وهو أيضًا مصدر كثير من الأخطاء عند المبتدئين. المثال التالي يوضح:

<div id="outer">
    <button id="inner">Click me</button>
</div>

<script>
    const outer = document.getElementById("outer");
    const inner = document.getElementById("inner");

    outer.addEventListener("click", () => console.log("outer capture"), true);
    outer.addEventListener("click", () => console.log("outer bubble"), false);
    inner.addEventListener("click", () => console.log("inner"));

    // النتيجة عند النقر على الزر:
    // outer capture
    // inner
    // outer bubble
</script>

المعامل الثالث في addEventListener يحدد المرحلة: true (أو {capture: true}) يعني الالتقاط، والافتراضي false يعني الفقاعات. معظم الأكواد تستخدم الوضع الافتراضي، لكن فهم الفرق حاسم عند تشخيص مشاكل “لماذا يُستدعى المستمع في العنصر الأب قبل الابن؟”.

addEventListener مقابل onclick: الفرق الذي يحدد جودة الكود

الطريقة التقليدية element.onclick = handler تبدو أبسط، لكنها تحمل قيودًا تجعلها غير مناسبة في الإنتاج:

المعيار onclick addEventListener
مستمعات متعددة لا، يستبدل السابق نعم، يمكن إضافة عدة مستمعات
التحكم في المرحلة لا نعم، عبر capture
الإزالة el.onclick = null removeEventListener أو AbortSignal
المستمعات السلبية لا نعم، {passive: true}
التوافق مع الأطر قد يتعارض مع مستمعات أخرى مستقل
الشيوع في الإنتاج نادر المعيار الفعلي

القاعدة العملية: استخدم addEventListener دائمًا. الحالة الوحيدة التي قد تستخدم فيها onclick هي كود تعليمي بسيط، أو عند الحاجة إلى استبدال معالج حدث ديناميكيًا دون تراكم مستمعات.

كائن الحدث: ما الذي تحمله كل نقرة؟

كل مستمع يستقبل كائن حدث يحتوي على معلومات غنية. الخصائص الأكثر استخدامًا:

  • type: نوع الحدث (مثل “click”).
  • target: العنصر الذي أُطلق عليه الحدث فعليًا (قد يكون ابنًا للعنصر المسجّل عليه المستمع).
  • currentTarget: العنصر الذي عليه المستمع الحالي (يختلف عن target عند الفقاعات).
  • timeStamp: وقت وقوع الحدث.
  • preventDefault(): منع السلوك الافتراضي.
  • stopPropagation(): إيقاف انتشار الحدث.
  • stopImmediatePropagation(): إيقاف الانتشار ومنع المستمعات الأخرى على نفس العنصر.

لأحداث محددة، هناك خصائص إضافية. مثلًا، MouseEvent تحتوي على clientX/clientY و button، وKeyboardEvent تحتوي على key وcode وaltKey وغيرها.

نقطة مهمة: target هو ما نقر عليه المستخدم، وهو ليس دائمًا العنصر الذي عليه المستمع. هذه الحقيقة هي أساس التفويض.

preventDefault وstopPropagation: الفرق الذي يخلط كثيرين

هاتان الدالتان تبدوان متشابهتين، لكن وظيفتهما مختلفة تمامًا:

  • preventDefault(): تمنع السلوك الافتراضي للمتصفح تجاه الحدث. مثال: منع إرسال نموذج عند الضغط على زر submit، أو منع فتح رابط عند النقر عليه.
  • stopPropagation(): تمنع الحدث من الانتشار إلى العناصر الأخرى في شجرة DOM. الحدث لا يكمل مرحلة الفقاعات.
document.querySelector("form").addEventListener("submit", (e) => {
    e.preventDefault(); // منع إعادة تحميل الصفحة
    // إرسال البيانات عبر fetch
});

document.querySelector(".modal").addEventListener("click", (e) => {
    e.stopPropagation(); // منع النقر من الوصول للخلفية
});

خطأ شائع: استخدام stopPropagation عندما تريد منع السلوك الافتراضي، أو العكس. تذكر القاعدة: preventDefault يوقف المتصفح، stopPropagation يوقف الحدث.

تفويض الأحداث (Event Delegation): النمط الذي يغير كل شيء

بدلًا من إضافة مستمع لكل عنصر في قائمة من 1000 عنصر، أضف مستمعًا واحدًا على العنصر الأب، واستفد من الفقاعات لتحديد أي عنصر تم النقر عليه. هذا هو تفويض الأحداث.

// الطريقة السيئة: مستمع لكل عنصر
document.querySelectorAll(".item").forEach((item) => {
    item.addEventListener("click", () => console.log(item.dataset.id));
});

// الطريقة الأفضل: مستمع واحد على الأب
document.querySelector(".list").addEventListener("click", (e) => {
    const item = e.target.closest(".item");
    if (!item) return;
    console.log(item.dataset.id);
});

مزايا التفويض:

  • أداء أفضل: مستمع واحد بدل آلاف المستمعات.
  • يدعم العناصر المضافة حديثًا: إذا أضفت عنصرًا جديدًا إلى القائمة، سيعمل دون إعادة ربط.
  • كود أنظف: منطق واحد في مكان واحد.
  • ذاكرة أقل: عدد أقل من closures محتجزة.

لكن التفويض ليس سحريًا. تحدياته:

  • بعض الأحداث لا تنتشر (مثل focus وblur، رغم أن focusin وfocusout ينتشران).
  • يجب استخدام closest() بدل matches() للتعامل مع العناصر المتداخلة.
  • قد يكون أبطأ في حالات نادرة إذا كان الشجرة عميقة جدًا.

الأحداث الأكثر استخدامًا في 2026

هذه هي الأحداث التي ستستخدمها في 90% من الحالات:

الفئة الأحداث الاستخدام
الفأرة click، dblclick، mousedown، mouseup، mousemove أزرار، سحب، تلميحات
لوحة المفاتيح keydown، keyup، keypress (مهمل) اختصارات، إدخال
النماذج submit، change، input، focus، blur التحقق، الإرسال
اللمس touchstart، touchmove، touchend تفاعلات الهاتف
التمرير والحجم scroll، resize تأثيرات، تحميل كسول
الحياة DOMContentLoaded، load، beforeunload تهيئة، تحذير مغادرة
الشبكة online، offline إشعارات الاتصال

ملاحظة: keypress مهمل، ويفضَّل استخدام keydown أو beforeinput بدلًا منه. كما أن scroll و resize يعملان بكثافة عالية، ويحتاجان إلى debounce أو requestAnimationFrame.

الأحداث المخصصة: تواصل بين مكوناتك

ليس كل حدث يجب أن يكون من المتصفح. يمكنك إطلاق أحداث مخصصة عبر CustomEvent، وهو نمط شائع في Web Components أو عند تصميم مكونات مستقلة تتواصل مع بقية الصفحة:

// إطلاق حدث مخصص من داخل مكون
const event = new CustomEvent("user:login", {
    detail: { userId: 42, name: "Ali" },
    bubbles: true,
});

document.dispatchEvent(event);

// الاستماع
document.addEventListener("user:login", (e) => {
    console.log(e.detail.name); // "Ali"
});

الفائدة: المكون لا يحتاج إلى معرفة من يستمع، ولا يعتمد على متغيرات عامة. هذا يجعل المكونات قابلة لإعادة الاستخدام والاختبار.

المستمعات السلبية (Passive): كيف تنقذ التمرير؟

افتراضيًا، عندما تضيف مستمع scroll أو touchmove أو wheel، يفترض المتصفح أنك قد تستدعي preventDefault()، فينتظر حتى ينتهي المستمع قبل أن يستمر في التمرير. هذا يسبب تأخرًا في التمرير حتى لو لم تفعل شيئًا.

الحل هو passive: true:

window.addEventListener("scroll", handler, { passive: true });
window.addEventListener("touchmove", handler, { passive: true });

هذا يخبر المتصفح: “لن أمنع السلوك الافتراضي”. النتيجة: التمرير يبقى سلسًا حتى لو كان المستمع يعمل. القاعدة الحديثة: استخدم passive: true لكل مستمع scroll أو touchmove أو wheel ما لم تكن متأكدًا أنك تحتاج preventDefault.

التنظيف: AbortController وremoveEventListener

المستمعات لا تُحذف تلقائيًا عند إزالة العنصر من DOM. وإذا كانت تشير إلى بيانات كبيرة، فإنها تسبب تسريب ذاكرة. هذا شائع في تطبيقات الصفحة الواحدة (SPA).

طريقتان لتنظيف المستمعات:

1. removeEventListener

function handler() { /* ... */ }

element.addEventListener("click", handler);

// لاحقًا
element.removeEventListener("click", handler);

القيد: يجب تمرير نفس المرجع للدالة. إذا استخدمت دالة مجهولة، لا يمكنك إزالتها.

2. AbortController

الطريقة الحديثة والأنظف، خصوصًا عند إضافة مستمعات متعددة:

const controller = new AbortController();

element.addEventListener("click", handler, { signal: controller.signal });
element.addEventListener("scroll", scrollHandler, { signal: controller.signal });

// إزالة كل المستمعات المرتبطة
controller.abort();

الفائدة: متغير واحد يدير دورة حياة كل المستمعات. عند تفكيك المكون، استدعاء controller.abort() يزيلها جميعًا. هذا نمط يجب أن يكون قياسيًا في أي كود حديث.

أخطاء شائعة في التعامل مع الأحداث

الخطأ السبب الحل
استخدام دالة مجهولة ثم محاولة إزالتها لا يوجد مرجع استخدم دالة مسماة أو AbortController
الاعتماد على e.target بدل closest() النقر قد يقع على ابن استخدم e.target.closest(".item")
خلط preventDefault وstopPropagation سلوك غير مقصود افهم الفرق
إضافة مستمع داخل حلقة آلاف المستمعات استخدم التفويض
عدم استخدام passive في scroll تمرير بطيء {passive: true}
تنفيذ منطق ثقيل في scroll إطارات مسقطة debounce أو rAF
عدم إزالة المستمع عند تفكيك المكون تسريب ذاكرة AbortController

الأداء: كيف تتجنب إبطاء الصفحة؟

المستمعات ليست مجانية. لها تكلفة ذاكرة، وتكلفة تنفيذ عند كل حدث. هذه المبادئ تحميك من إبطاء الصفحة:

  • قلل عدد المستمعات: استخدم التفويض بدل مستمع لكل عنصر.
  • قيّد الأحداث الكثيفة: scroll وresize وmousemove تُطلق آلاف المرات في الثانية. استخدم debounce (تنفيذ بعد توقف الإدخال) أو throttle (تنفيذ مرة كل فترة).
  • استخدم passive: خصوصًا في scroll وtouchmove.
  • استخدم rAF للرسوم: إذا كنت تعدل العناصر بصريًا استجابةً لحدث كثيف، استخدم requestAnimationFrame بدلًا من التنفيذ الفوري.
  • تجنب المستمعات في العناصر التي ستُزال قريبًا: أضفها فقط عند الحاجة، وأزل عند الانتهاء.
function debounce(fn, delay = 200) {
    let timeout;
    return (...args) => {
        clearTimeout(timeout);
        timeout = setTimeout(() => fn(...args), delay);
    };
}

window.addEventListener("resize", debounce(() => {
    // منطق مكلف يعمل بعد توقف التمرير
}, 300), { passive: true });

أفضل الممارسات في الإنتاج

  • استخدم addEventListener دائمًا. وليس onclick.
  • استخدم التفويض للأحداث المتكررة. مستمع واحد على الأب أفضل من آلاف على الأبناء.
  • استخدم AbortController. حتى لو كنت ستزيل مستمعًا واحدًا، لأنه يوحّد التنظيف.
  • مرر passive في scroll/touchmove/wheel. هذا تحسين مجاني.
  • استخدم debounce/throttle. لا تنفّذ منطقًا ثقيلًا في كل حدث.
  • افهم الفرق بين preventDefault وstopPropagation. خطأ شائع، وعواقبه كبيرة.
  • استخدم CustomEvent للتواصل بين المكونات. بدل المتغيرات العامة.
  • نظّف عند التفكيك. في React، استخدم cleanup function في useEffect. في Vanilla، استخدم AbortController.

قائمة تحقق نهائية

  • هل تستخدم addEventListener بدلًا من onclick؟ ← نعم، دائمًا
  • هل تفهم مراحل التدفق الثلاث؟ ← ضروري لتشخيص المشاكل
  • هل تستخدم التفويض في القوائم المتكررة؟ ← نعم، لمستمع واحد
  • هل تمرر passive: true في scroll؟ ← تحسين ملموس
  • هل تستخدم debounce في الأحداث الكثيفة؟ ← أو rAF
  • هل تستخدم AbortController للتنظيف؟ ← أفضل نمط حديث
  • هل تعرف الفرق بين preventDefault وstopPropagation؟ ← خطأ شائع
  • هل تنظف المستمعات عند إزالة العناصر؟ ← نعم، لمنع التسريبات
  • هل تستخدم CustomEvent للتواصل بين المكونات؟ ← بديل أنظف من المتغيرات العامة
  • هل تختبر أداء الصفحة بعد إضافة مستمعات كثيرة؟ ← Performance Panel

فيما يلي روابط رسمية ومراجع يمكنك الرجوع إليها:

أسئلة شائعة

هل يجب أن أستخدم onclick أم addEventListener دائمًا؟

addEventListener دائمًا، لأنه يسمح بمستمعات متعددة، ويدعم خيارات مثل passive وcapture، ويمكن إزالته بشكل نظيف. onclick يستبدل أي مستمع سابق، وهذا سبب شائع لأخطاء يصعب تتبعها.

ما الفرق بين event.target وevent.currentTarget؟

target هو العنصر الذي أطلق الحدث فعليًا (قد يكون ابنًا للعنصر الذي عليه المستمع). currentTarget هو العنصر الذي عليه المستمع الحالي. أثناء الفقاعات، target يبقى ثابتًا، لكن currentTarget يتغير مع كل عنصر يمر عليه الحدث.

متى أستخدم preventDefault ومتى stopPropagation؟

استخدم preventDefault عندما تريد منع السلوك الافتراضي للمتصفح (إرسال نموذج، فتح رابط، تحديد نص). استخدم stopPropagation عندما تريد منع الحدث من الوصول إلى العناصر الأخرى (مثل منع نقر داخل مودال من الوصول إلى الخلفية).

هل التفويض يعمل مع كل الأحداث؟

يعمل مع أي حدث ينتشر (bubbles). معظم أحداث الواجهة تنتشر، لكن بعضها لا (مثل focus وblur). لهذه، استخدم focusin وfocusout، وهما ينتشران.

لماذا يجب استخدام AbortController بدل removeEventListener؟

removeEventListener يتطلب تمرير نفس مرجع الدالة، وهذا صعب عندما يكون لديك عدة مستمعات. AbortController يسمح لك بإدارة جميع المستمعات المرتبطة بمتغير واحد، وإزالتها دفعة واحدة عند الحاجة. هذا يقلل الأخطاء ويسهل التنظيف.