לדלג לתוכן

3.5 בעבוע ודלגציה הרצאה

מודל התפשטות אירועים - Event Propagation

בשיעור הקודם למדנו איך לרשום אירועים על אלמנטים עם addEventListener.
אבל מה בעצם קורה כשלוחצים על כפתור שנמצא בתוך div, שנמצא בתוך body?
האם האירוע מופעל רק על הכפתור? התשובה היא לא - והמנגנון הזה הוא אחד הדברים הכי חשובים להבין ב-DOM.

כשאירוע מתרחש על אלמנט, הוא לא נשאר רק שם. הוא עובר מסלול שלם דרך עץ ה-DOM.
המסלול הזה מורכב משלושה שלבים:

  1. שלב הלכידה - Capturing Phase - האירוע יורד מ-document דרך כל ההורים עד האלמנט שעליו לחצנו
  2. שלב המטרה - Target Phase - האירוע מגיע לאלמנט עצמו (ה-target)
  3. שלב הבעבוע - Bubbling Phase - האירוע עולה חזרה מהאלמנט דרך כל ההורים עד document

ניתן לתאר את זה כך:

document          (1) capturing down...     (7) ...bubbling up
  html            (2) capturing down...     (6) ...bubbling up
    body          (3) capturing down...     (5) ...bubbling up
      button      (4) TARGET - the element we clicked

ברוב המוחלט של המקרים, אנחנו עובדים עם שלב הבעבוע. שלב הלכידה הוא נדיר ונדבר עליו בהמשך.


בעבוע אירועים - Event Bubbling

בעבוע אירועים אומר שכשאירוע מתרחש על אלמנט, הוא "מבעבע" כלפי מעלה - מהאלמנט, להורה שלו, להורה של ההורה, וכן הלאה עד ה-document.

נראה את זה בפעולה:

<div id="grandparent" style="padding: 30px; background: #e74c3c;">
  grandparent
  <div id="parent" style="padding: 30px; background: #3498db;">
    parent
    <button id="child">click me</button>
  </div>
</div>
document.getElementById("grandparent").addEventListener("click", function () {
  console.log("grandparent clicked");
});

document.getElementById("parent").addEventListener("click", function () {
  console.log("parent clicked");
});

document.getElementById("child").addEventListener("click", function () {
  console.log("child clicked");
});

כשלוחצים על הכפתור, נראה בקונסול:

child clicked
parent clicked
grandparent clicked

האירוע קרה על הכפתור, אבל הוא בעבע למעלה - קודם להורה, ואז לסבא.
אם נלחץ על ה-div של parent (לא על הכפתור), נראה:

parent clicked
grandparent clicked

כי הבעבוע מתחיל מהאלמנט שעליו לחצנו ועולה למעלה.


event.target מול event.currentTarget

כשאירוע מבעבע, חשוב להבדיל בין שני דברים:

  • event.target - האלמנט שעליו התרחש האירוע במקור (מי שלחצנו עליו)
  • event.currentTarget - האלמנט שה-handler הנוכחי רשום עליו
document.getElementById("parent").addEventListener("click", function (event) {
  console.log("target:", event.target);           // the element we actually clicked
  console.log("currentTarget:", event.currentTarget); // always the parent div
});

אם לוחצים על הכפתור, ה-target יהיה הכפתור, אבל ה-currentTarget יהיה ה-div של parent (כי שם ה-handler רשום).
אם לוחצים ישירות על ה-div, שניהם יהיו אותו אלמנט.

ההבחנה הזו היא הבסיס לדלגציה שנלמד בהמשך.


עצירת הבעבוע - event.stopPropagation

לפעמים אנחנו לא רוצים שהאירוע ימשיך לבעבע. אפשר לעצור את ההתפשטות עם event.stopPropagation():

document.getElementById("child").addEventListener("click", function (event) {
  event.stopPropagation(); // stop the bubble here
  console.log("child clicked - bubble stopped");
});

document.getElementById("parent").addEventListener("click", function () {
  console.log("parent clicked"); // this will NOT run when clicking the child
});

עכשיו כשלוחצים על הכפתור, רק "child clicked" יודפס. האירוע לא ימשיך לבעבע ל-parent.

מתי להשתמש?

  • כשיש כפתור בתוך כרטיס שלוחצים עליו - לא רוצים שהלחיצה על הכפתור תפעיל גם את ה-handler של הכרטיס
  • כשיש תפריט נפתח ולוחצים בתוכו - לא רוצים שהלחיצה תגיע ל-document ותסגור את התפריט

היזהרו

שימוש מוגזם ב-stopPropagation יכול ליצור באגים קשים לאיתור. אם מישהו רשם handler על document ולא מבין למה הוא לא נקרא - זה כי מישהו אחר עצר את הבעבוע באמצע הדרך.
השתמשו בזה רק כשבאמת צריך.


עצירת כל ה-handlers - event.stopImmediatePropagation

מה קורה אם יש כמה handlers על אותו אלמנט?

let button = document.getElementById("child");

button.addEventListener("click", function (event) {
  console.log("handler 1");
});

button.addEventListener("click", function (event) {
  console.log("handler 2");
});

כשלוחצים, שניהם ירוצו (בסדר שנרשמו).

event.stopPropagation() עוצר את הבעבוע להורים, אבל handlers נוספים על אותו אלמנט עדיין ירוצו.
אם רוצים לעצור גם אותם, משתמשים ב-event.stopImmediatePropagation():

button.addEventListener("click", function (event) {
  event.stopImmediatePropagation();
  console.log("handler 1 - everything stops after me");
});

button.addEventListener("click", function (event) {
  console.log("handler 2"); // this will NOT run
});

זה שימושי במקרים נדירים, אבל טוב לדעת שקיים.


דלגציית אירועים - Event Delegation

זה הנושא הכי חשוב בשיעור הזה. דלגציית אירועים היא טכניקה שמנצלת את מנגנון הבעבוע כדי לטפל באירועים בצורה יעילה.

הבעיה

נגיד שיש לנו רשימת פריטים ורוצים שלחיצה על כל פריט תעשה משהו:

<ul id="todo-list">
  <li>buy milk</li>
  <li>clean the house</li>
  <li>learn JavaScript</li>
</ul>

הגישה הנאיבית - לרשום handler על כל li:

let items = document.querySelectorAll("#todo-list li");
items.forEach(function (item) {
  item.addEventListener("click", function () {
    this.classList.toggle("done");
  });
});

מה הבעיה בגישה הזאת?

  1. ביצועים - אם יש 1000 פריטים, יש 1000 handlers בזיכרון. זה בזבזני.
  2. אלמנטים דינמיים - אם מוסיפים פריטים חדשים לרשימה אחרי שה-handler נרשם, הם לא יגיבו ללחיצה. כי ה-addEventListener רץ רק על האלמנטים שהיו קיימים ברגע שהקוד רץ.
// this item will NOT have a click handler
let newItem = document.createElement("li");
newItem.textContent = "new task";
document.getElementById("todo-list").appendChild(newItem);
// clicking this item does nothing - no handler was registered on it

הפתרון - דלגציה

במקום לרשום handler על כל פריט, רושמים handler אחד על ההורה (ה-ul), ובודקים מי ה-target:

document.getElementById("todo-list").addEventListener("click", function (event) {
  // check if the clicked element is an li
  if (event.target.tagName === "LI") {
    event.target.classList.toggle("done");
  }
});

למה זה עובד? בגלל הבעבוע. כשלוחצים על li, האירוע מבעבע ל-ul, ושם ה-handler תופס אותו. אנחנו בודקים את event.target כדי לדעת על מי בדיוק לחצנו.

היתרונות

  1. handler אחד במקום מאות או אלפים - חוסך זיכרון
  2. עובד על אלמנטים דינמיים - כל פריט חדש שנוסף ל-ul יגיב ללחיצה אוטומטית, כי ה-handler רשום על ההורה
  3. קוד נקי יותר - לא צריך לרשום ולנקות handlers בכל פעם שמוסיפים או מורידים אלמנטים

שימוש ב-closest לדלגציה מדויקת

מה קורה כשהמבנה יותר מורכב? למשל כשלוחצים על span בתוך li:

<ul id="todo-list">
  <li>
    <span class="text">buy milk</span>
    <button class="delete">X</button>
  </li>
  <li>
    <span class="text">clean the house</span>
    <button class="delete">X</button>
  </li>
</ul>

אם לוחצים על ה-span, ה-event.target הוא ה-span, לא ה-li.
הבדיקה event.target.tagName === "LI" תיכשל.

הפתרון הוא event.target.closest() - שמחפש את האלמנט הכי קרוב שמתאים לסלקטור (כולל האלמנט עצמו):

document.getElementById("todo-list").addEventListener("click", function (event) {
  // find the closest li from whatever was clicked
  let li = event.target.closest("li");
  if (li) {
    li.classList.toggle("done");
  }
});

closest("li") עולה למעלה בעץ ה-DOM מהאלמנט שלחצנו עליו, ומוצא את ה-li הראשון שנתקל בו. אם לחצנו על ה-span, הוא עולה ומוצא את ה-li ההורה. אם לחצנו ישירות על ה-li, הוא מחזיר את ה-li עצמו.

אם לחצנו על מקום ב-ul שאינו בתוך li, הוא יחזיר null - ולכן הבדיקה if (li) חשובה.

דלגציה עם כמה סוגי כפתורים

נוסיף גם טיפול בכפתור המחיקה:

document.getElementById("todo-list").addEventListener("click", function (event) {
  // handle delete button
  if (event.target.closest(".delete")) {
    let li = event.target.closest("li");
    li.remove();
    return;
  }

  // handle clicking on the item itself
  let li = event.target.closest("li");
  if (li) {
    li.classList.toggle("done");
  }
});

עם handler אחד על ה-ul, אנחנו מטפלים גם בלחיצה על הפריט וגם בלחיצה על כפתור המחיקה.


דוגמה מעשית - טבלה עם דלגציה

נבנה טבלה שבה לחיצה על שורה מדגישה אותה, ולחיצה על כפתור מוחקת אותה:

<table id="users-table">
  <thead>
    <tr>
      <th>name</th>
      <th>email</th>
      <th>actions</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Dan</td>
      <td>dan@email.com</td>
      <td><button class="delete-btn">delete</button></td>
    </tr>
    <tr>
      <td>Sara</td>
      <td>sara@email.com</td>
      <td><button class="delete-btn">delete</button></td>
    </tr>
  </tbody>
</table>
<button id="add-user">add user</button>
let table = document.getElementById("users-table");
let tbody = table.querySelector("tbody");

// single handler for the entire table body
tbody.addEventListener("click", function (event) {
  // handle delete button
  let deleteBtn = event.target.closest(".delete-btn");
  if (deleteBtn) {
    let row = deleteBtn.closest("tr");
    row.remove();
    return;
  }

  // handle row click - highlight the row
  let row = event.target.closest("tr");
  if (row) {
    // remove highlight from all rows first
    tbody.querySelectorAll("tr").forEach(function (r) {
      r.classList.remove("selected");
    });
    // highlight clicked row
    row.classList.add("selected");
  }
});

// adding new rows - they automatically work with the delegated handler
let counter = 3;
document.getElementById("add-user").addEventListener("click", function () {
  let newRow = document.createElement("tr");
  newRow.innerHTML =
    "<td>User " + counter + "</td>" +
    "<td>user" + counter + "@email.com</td>" +
    '<td><button class="delete-btn">delete</button></td>';
  tbody.appendChild(newRow);
  counter++;
});

שורות חדשות שנוצרות דינמית מגיבות מיד ללחיצה - בלי לרשום שום handler נוסף. זה הכוח של דלגציה.


דוגמה מעשית - רשימה דינמית

<div id="app">
  <input type="text" id="task-input" placeholder="new task...">
  <button id="add-task">add</button>
  <ul id="tasks"></ul>
</div>
let taskInput = document.getElementById("task-input");
let tasksList = document.getElementById("tasks");

// add new task
document.getElementById("add-task").addEventListener("click", function () {
  let text = taskInput.value.trim();
  if (!text) return;

  let li = document.createElement("li");
  li.innerHTML =
    '<span class="task-text">' + text + '</span>' +
    '<button class="complete-btn">V</button>' +
    '<button class="remove-btn">X</button>';
  tasksList.appendChild(li);
  taskInput.value = "";
});

// delegated handler for all task actions
tasksList.addEventListener("click", function (event) {
  let li = event.target.closest("li");
  if (!li) return;

  // complete button
  if (event.target.closest(".complete-btn")) {
    li.classList.toggle("completed");
    return;
  }

  // remove button
  if (event.target.closest(".remove-btn")) {
    li.remove();
    return;
  }
});

כל הלוגיקה של הלחיצות נמצאת ב-handler אחד על ה-ul. לא משנה כמה משימות יש - handler אחד מנהל את הכל.


שלב הלכידה - Capturing Phase

אמרנו שאירוע עובר שלושה שלבים. כברירת מחדל, addEventListener רושם handler שמופעל בשלב הבעבוע.
אפשר לרשום handler שמופעל בשלב הלכידה - כלומר לפני שהאירוע מגיע ל-target:

// capture phase - fires BEFORE the target
document.getElementById("parent").addEventListener("click", function () {
  console.log("parent - capture phase");
}, true);   // true = capture phase

// or with the options object
document.getElementById("parent").addEventListener("click", function () {
  console.log("parent - capture phase");
}, { capture: true });

// default bubble phase
document.getElementById("parent").addEventListener("click", function () {
  console.log("parent - bubble phase");
});   // no third argument = bubble phase (default)

סדר ההרצה עם capturing

document.addEventListener("click", function () {
  console.log("document - capture");
}, true);

document.getElementById("parent").addEventListener("click", function () {
  console.log("parent - capture");
}, true);

document.getElementById("child").addEventListener("click", function () {
  console.log("child - target");
});

document.getElementById("parent").addEventListener("click", function () {
  console.log("parent - bubble");
});

document.addEventListener("click", function () {
  console.log("document - bubble");
});

כשלוחצים על child, הסדר הוא:

document - capture       (capturing - going down)
parent - capture         (capturing - going down)
child - target           (at the target)
parent - bubble          (bubbling - going up)
document - bubble        (bubbling - going up)

מתי להשתמש בלכידה?

ברוב המוחלט של המקרים אין צורך. שימושים נדירים:
- כשרוצים ליירט אירוע לפני שהוא מגיע ליעד (למשל לחסום לחיצות מסוימות ברמת ה-document)
- במערכות ניהול focus מורכבות


סיכום

  • כשאירוע מתרחש, הוא עובר שלושה שלבים: לכידה (למטה), מטרה, בעבוע (למעלה)
  • בעבוע אירועים - האירוע עולה מהאלמנט דרך כל ההורים עד ה-document
  • event.target - האלמנט שעליו התרחש האירוע; event.currentTarget - האלמנט שה-handler רשום עליו
  • event.stopPropagation() - עוצר את הבעבוע; event.stopImmediatePropagation() - עוצר גם handlers נוספים על אותו אלמנט
  • דלגציית אירועים - רושמים handler על ההורה במקום על כל ילד. יעיל, עובד על אלמנטים דינמיים
  • event.target.closest(selector) - הכלי המרכזי לדלגציה מדויקת
  • שלב הלכידה מופעל עם { capture: true } - נדיר בשימוש