EduFlow
أساسيات JavaScript
⁦0%⁩

الوحدة 4 · JavaScript في المتصفّح

الأحداث وتفويض الأحداث (Event Delegation)

17 دقراءة 3 دبيئة تجريب الكود

الدرس في لمحة

تستجيب للنقرات وضغطات المفاتيح باستخدام addEventListener، وتقرأ كائن الحدث، وتفهم انتشار الأحداث للأعلى (bubbling)، وتتعامل مع قائمة كاملة بمستمع واحد مفوَّض يظل يعمل مع إضافة العناصر وإزالتها.

  1. 1الاستماع إلى حدث
  2. 2البيانات أولًا، ثم العرض
  3. 3الانتشار للأعلى: الأحداث تصعد في الشجرة
  4. 4تفويض الأحداث: مستمع واحد للقائمة كلها

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

يستطيع Pocket أن يرسم قائمته. والآن يريد الناس أن يفعلوا بها أشياء: حذف مصروف، أو تعليم أحدها بأنه رُوجع، أو التصفية حسب الفئة. كل ذلك يبدأ بأن يفعل المستخدم شيئًا، كالنقر أو الكتابة، فيتفاعل كودك. والمتصفّح يُبلغ عن كل ما يفعله المستخدمون على شكل أحداث (events).

الاستماع إلى حدث

تطلب من عنصر أن يُعلمك بحدث ما باستخدام addEventListener:

app.js
const clearButton = document.querySelector("#clear");

clearButton.addEventListener("click", (event) => {
  console.log("Clear was clicked", event.type);
});

اقرأها هكذا: “حين تقع نقرة على هذا الزر، استدعِ هذه الدالة”. دالتك لا تُنفَّذ الآن. بل يخزّنها المتصفّح ويستدعيها لاحقًا، مرة لكل نقرة، ويمرّر إليها كائن حدث (event object) فيه تفاصيل ما جرى. هذه فكرة الـ callback من درس الدوال، والمتصفّح هنا هو الكود الذي يستدعيك راجعًا.

أنواع الأحداث التي ستستخدمها أكثر من غيرها: click، وinput (تغيّرت قيمة حقل نصي)، وchange (اعتُمدت قيمة، مثلًا في قائمة اختيار)، وsubmit (أُرسل نموذج)، وkeydown (ضُغط مفتاح؛ وevent.key تقول أيّ مفتاح، مثل "Enter" أو "Escape").

البيانات أولًا، ثم العرض

أهم عادة مع الأحداث: لا تعامل الصفحة على أنها المكان الذي تعيش فيه بياناتك. احتفظ بالبيانات في متغيّر، وغيّر المتغيّر في معالج الحدث، واستدعِ دالة العرض من الدرس السابق لتعيد الرسم:

app.js
let expenses = [
  { id: "exp-1", label: "Coffee", amount: 450 },
  { id: "exp-2", label: "Train", amount: 1220 },
];

clearButton.addEventListener("click", () => {
  expenses = [];
  renderExpenses(expenses);
});

معالج الحدث لا يمسّ عناصر <li> مباشرة أبدًا. إنه يغيّر البيانات، ودالة العرض تجعل الصفحة مطابقة لها. ومهما أضفت من أزرار، يبقى هناك مكان واحد يرسم القائمة، فلا يمكن أن يختلف المجموع والقائمة أبدًا.

الانتشار للأعلى: الأحداث تصعد في الشجرة

حين تنقر على زر داخل عنصر قائمة داخل قائمة، فأنت تنقر عليها كلها: الزر، و<li>، و<ul>، و<body>. ويمثّل المتصفّح ذلك بأن يسلّم الحدث أولًا إلى العنصر الذي نقرت عليه، وهو الهدف (target)، ثم إلى كل سلف بدوره، صعودًا حتى document. وهذا يُسمّى الانتشار للأعلى (bubbling).

النقرة على زر الحذف تبدأ عند الزر، وهو هدف الحدث، ثم تنتشر صعودًا إلى li، ثم إلى ul حيث يتعامل معها مستمع مفوَّض، ثم تواصل إلى body وdocument. body ul#expense-list (المستمع هنا) li data-id="l-js-4-2-exp-2" Train $12.20 button.delete الهدف: حيث بدأت النقرة
النقرة تنتشر من العنصر المنقور صعودًا عبر أسلافه

داخل معالج الحدث، event.target هو العنصر الذي بدأ فيه الحدث، وevent.currentTarget هو العنصر الذي يعمل مستمعه الآن. ولذلك يسمع المستمع الموجود على <ul> النقرات على كل زر بداخلها، ويستطيع مع ذلك أن يعرف أيّ زر كان.

تفويض الأحداث: مستمع واحد للقائمة كلها

لنفترض أن لكل مصروف زر حذف. يمكنك أن تُلحق مستمعًا بكل زر، لكن دالة العرض ترمي هذه الأزرار وتصنع أزرارًا جديدة في كل مرة تتغيّر فيها القائمة، والأزرار الجديدة بلا مستمعين. بدلًا من ذلك، ألحِق مستمعًا واحدًا بالقائمة، وهي لا تُستبدل أبدًا، واستنتج ما الذي نُقر عليه:

app.js
const list = document.querySelector("#expense-list");

list.addEventListener("click", (event) => {
  const button = event.target.closest("button.delete");
  if (!button) {
    return; // the click was somewhere else in the list
  }
  const id = button.closest("li").dataset.id;
  expenses = expenses.filter((e) => e.id !== id);
  renderExpenses(expenses);
});

closest(selector) تبدأ من عنصر وتصعد عبر أسلافه حتى تجد واحدًا يطابق، أو تُرجع null. وهذا يغطّي النقرات على أيقونة داخل الزر، والحارس if (!button) يتجاهل النقرات على باقي العنصر. والمعرّف يأتي من data-id الذي ضبطته أثناء العرض، وتغيير البيانات يتم بـ filter، تمامًا كما في القسم 3.

يُسمّى هذا النمط تفويض الأحداث (event delegation). إنه يعني مستمعين أقل، ويظل يعمل مع العناصر المضافة بعد إعداد المستمع، لأن نقراتها تنتشر إلى القائمة نفسها.

لإيقاف انتشار النقرة إلى أعلى يمكنك استدعاء event.stopPropagation()، لكنك نادرًا ما ستحتاج إليها، واستخدامها كثيرًا يُعطّل التفويض في مكان آخر. أما event.preventDefault() فمختلفة وأكثر شيوعًا بكثير: إنها تلغي الفعل الافتراضي للمتصفّح، مثل اتباع رابط أو إرسال نموذج. وهذه الثانية هي نجمة الدرس التالي.

أهم النقاط

  • addEventListener(type, handler) تسجّل دالة يستدعيها المتصفّح لاحقًا، مع كائن حدث يصف ما جرى.
  • مرّر معالج الحدث (handler) نفسه، دون أقواس؛ فكتابة handler() تستدعيه فورًا وتسجّل القيمة التي أرجعها بدلًا منه.
  • تنتشر الأحداث للأعلى من العنصر الذي نُقر عليه عبر أسلافه، لذلك يسمع المستمع الموجود على عنصر أب النقرات على أبنائه.
  • مع تفويض الأحداث، يستخدم مستمع واحد على الحاوية event.target.closest(...) ليعرف أيّ عنصر نُقر عليه، بما في ذلك العناصر المضافة لاحقًا.
  • احتفظ بالبيانات في متغيّرات، وغيّر البيانات داخل معالجات الأحداث، ثم استدعِ دالة العرض؛ ولا تعامل الصفحة أبدًا على أنها مصدر الحقيقة.

قراءات إضافية

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

ما الخطأ في button.addEventListener("click", deleteExpense());؟

السؤال 2 من 4

عنصر قائمة يحتوي على <span>Coffee</span> <button class="delete">×</button>. والمستمع موجود على <ul>. ينقر المستخدم على الزر. ما قيمتا event.target وevent.currentTarget؟

السؤال 3 من 4

لماذا يظل المستمع المفوَّض يعمل مع المصاريف المضافة بعد تحميل الصفحة؟

السؤال 4 من 4

داخل معالج نقرات مفوَّض، لماذا نستخدم event.target.closest(".delete") بدلًا من فحص event.target.classList.contains("delete")؟

تدريب

بيئة تجريب الكود

احذف بمستمع مفوَّض واحد

لكل مصروف زر حذف (Delete). يُلحق كود البداية مستمعًا بكل زر، لكن renderExpenses تستبدل الأزرار بعد أول حذف، فلا يفعل الحذف الثاني شيئًا، ولا يمكن أبدًا حذف المصاريف الجديدة.

استبدل الحلقة في الأسفل بمستمع نقرات واحد على #expense-list. حين تقع نقرة على زر .delete (أو داخله)، أزِل ذلك المصروف من expenses مستخدمًا data-id الخاص بالعنصر، ثم استدعِ renderExpenses(). ويجب ألّا تفعل النقرات في أي مكان آخر من القائمة شيئًا.

<h1>Pocket</h1>
<button type="button" id="add-sample">Add a sample expense</button>
<ul id="expense-list"></ul>
<p id="total"></p>
جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

المعاينة

الـ console

(لا مخرجات)

الفحوص

  • حذف مصروف يُزيله ويحدّث المجموع
  • الحذف الثاني ما زال يعمل بعد إعادة عرض القائمة
  • يمكن حذف المصاريف المضافة لاحقًا أيضًا
  • النقر على تسمية العنصر لا يحذف شيئًا
  • تُحدَّث مصفوفة المصاريف نفسها، لا الصفحة فقط