לדלג לתוכן

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

מודל התפשטות events - Event Propagation

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

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

  1. שלב הלכידה - Capturing Phase - הevent יורד מ-document דרך כל ההורים עד האלמנט שעליו לחצנו
  2. שלב המטרה - Target Phase - הevent מגיע לאלמנט עצמו (ה-target)
  3. שלב הbubbling - Bubbling Phase - הevent עולה חזרה מהאלמנט דרך כל ההורים עד 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

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


הbubbling events - Event Bubbling

הbubbling events אומר שכשevent מתרחש על אלמנט, הוא "bubbles" כלפי מעלה - מהאלמנט, להורה שלו, להורה של ההורה, וכן הלאה עד ה-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

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

parent clicked
grandparent clicked

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


event.target מול event.currentTarget

כשevent bubbles, חשוב להבדיל בין שני דברים:

  • event.target - האלמנט שעליו התרחש הevent במקור (מי שלחצנו עליו)
  • 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, שניהם יהיו אותו אלמנט.

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


עצירת הbubbling - event.stopPropagation

לפעמים אנחנו לא רוצים שהevent ימשיך לbubble. אפשר לעצור את ההתפשטות עם 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" יודפס. הevent לא ימשיך לbubble ל-parent.

מתי להשתמש?

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

היזהרו

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


עצירת כל ה-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() עוצר את הbubbling להורים, אבל 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
});

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


דלגציית events - Event Delegation

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

הבעיה

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

<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");
  }
});

למה זה עובד? בגלל הbubbling. כשלוחצים על li, הevent bubbles ל-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

אמרנו שevent עובר שלושה שלבים. כברירת מחדל, addEventListener רושם handler שמופעל בשלב הbubbling.
אפשר לרשום handler שמופעל בשלב הלכידה - כלומר לפני שהevent מגיע ל-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)

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

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


סיכום

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