ניתוח מעמיק של אירוע JavaScript

preventDefault ו-stopPropagation: מתי כדאי להשתמש בכל אחת מהשיטות ומה בדיוק כל שיטה עושה.

‫Event.stopPropagation()‎ ו-Event.preventDefault()‎

בדרך כלל, הטיפול באירועים ב-JavaScript הוא פשוט. זה נכון במיוחד כשמדובר במבנה HTML פשוט (יחסית שטוח). אבל הדברים מסתבכים קצת יותר כשאירועים עוברים (או מתפשטים) בהיררכיה של רכיבים. בדרך כלל, המפתחים משתמשים ב-stopPropagation() או ב-preventDefault() כדי לפתור את הבעיות שהם נתקלים בהן. אם אי פעם חשבתם לעצמכם "אנסה את preventDefault() ואם זה לא יעבוד אנסה את stopPropagation() ואם זה לא יעבוד אנסה את שניהם", אז המאמר הזה הוא בשבילכם! אסביר בדיוק מה כל שיטה עושה, מתי להשתמש בכל אחת מהן, ואספק לך מגוון דוגמאות שימושיות כדי שתוכל לבדוק אותן. המטרה שלי היא להסביר לך את הנושא בצורה ברורה.

לפני שנכנס לעומק, חשוב להסביר בקצרה על שני סוגי הטיפול באירועים שאפשר לבצע ב-JavaScript (בכל הדפדפנים המודרניים – דפדפן Internet Explorer לפני גרסה 9 לא תמך בלכידת אירועים בכלל).

סגנונות של אירועים (לכידה והעברה)

כל הדפדפנים המודרניים תומכים בלכידת אירועים, אבל מפתחים משתמשים בה לעיתים רחוקות מאוד. מעניין לציין שזו הייתה הצורה היחידה של אירועים ש-Netscape תמכה בה במקור. המתחרה הגדול ביותר של Netscape,‏ Microsoft Internet Explorer, לא תמך בכלל בלכידת אירועים, אלא רק בסגנון אחר של אירועים שנקרא 'העברת אירועים'. כש-W3C הוקם, נמצאו יתרונות בשני הסגנונות של אירועים, והוחלט שדפדפנים צריכים לתמוך בשניהם באמצעות פרמטר שלישי לשיטה addEventListener. במקור, הפרמטר הזה היה רק בוליאני, אבל כל הדפדפנים המודרניים תומכים באובייקט options כפרמטר השלישי, שאפשר להשתמש בו כדי לציין (בין היתר) אם רוצים להשתמש בלכידת אירועים או לא:

someElement.addEventListener('click', myClickHandler, { capture: true | false });

האובייקט options הוא אופציונלי, וכך גם המאפיין capture שלו. אם אחד מהם מושמט, ערך ברירת המחדל של capture הוא false, כלומר נעשה שימוש ב-event bubbling.

תיעוד אירועים

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

כל האירועים מתחילים בחלון ועוברים קודם את שלב הלכידה. כלומר, כשאירוע נשלח, הוא מתחיל את החלון ונע 'למטה' לכיוון רכיב היעד קודם. זה קורה גם אם אתם מאזינים רק בשלב ה-bubbling. דוגמה ל-markup ול-JavaScript:

<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('#C was clicked');
  },
  true,
);

כשמשתמש לוחץ על רכיב #C, נשלח אירוע שמקורו ב-window. האירוע הזה יועבר לצאצאים שלו באופן הבא:

window => document => <html> => <body> => וכן הלאה, עד שהבקשה מגיעה ליעד.

לא משנה אם אין האזנה לאירוע קליק ברכיב window או document או <html> או <body> (או בכל רכיב אחר בדרך ליעד שלו). האירוע עדיין נוצר ב-window ומתחיל את המסע שלו כמו שמתואר למעלה.

בדוגמה שלנו, אירוע הקליק יתפשט (זהו מונח חשוב שקשור ישירות לאופן הפעולה של השיטה stopPropagation(), ויוסבר בהמשך המאמר) מ-window אל רכיב היעד שלו (במקרה הזה, #C) דרך כל רכיב שבין window ל-#C.

המשמעות היא שאירוע הקליק יתחיל בשעה window והדפדפן ישאל את השאלות הבאות:

"Is anything listening for a click event on the window in the capturing phase?" אם כן, יופעלו הגורמים המתאימים לטיפול באירועים. בדוגמה שלנו, אף אחד מהם לא מתקיים, ולכן לא יופעלו מטפלים.

לאחר מכן, האירוע יועבר אל document והדפדפן ישאל: "האם יש משהו שמקשיב לאירוע לחיצה על document בשלב הלכידה?" אם כן, יופעלו מטפלי האירועים המתאימים.

לאחר מכן, האירוע מועבר לאלמנט <html> והדפדפן ישאל: "האם יש משהו שמחכה לקליק על אלמנט <html> בשלב הלכידה?" אם כן, יופעלו מטפלי האירועים המתאימים.

לאחר מכן, האירוע יתפשט לאלמנט <body> והדפדפן ישאל: "האם יש משהו שמקשיב לאירוע קליק באלמנט <body> בשלב הלכידה?" במקרה כזה, יופעלו גורמים מתאימים לטיפול באירועים.

לאחר מכן, האירוע יתפשט לאלמנט #A. שוב, הדפדפן ישאל: "האם יש משהו שמקשיב לאירוע קליק ב-#A בשלב הלכידה? אם כן, יופעלו המטפלים המתאימים באירועים".

לאחר מכן, האירוע יופץ לרכיב #B (ותישאלו את אותה שאלה).

לבסוף, האירוע יגיע ליעד שלו והדפדפן ישאל: "האם יש משהו שמקשיב לאירוע קליק על רכיב #C בשלב הלכידה?" התשובה הפעם היא 'כן!' הפרק הזמן הקצר הזה שבו האירוע ביעד נקרא 'שלב היעד'. בשלב הזה, handler האירועים יופעל, הדפדפן יציג את ההודעה '‎#C was clicked' במסוף, ואז סיימנו, נכון? לא נכון! אנחנו ממש לא סיימנו. התהליך נמשך, אבל עכשיו הוא עובר לשלב ה-bubbling.

העברת אירועים (Event bubbling)

בדפדפן תופיע השאלה:

"Is anything listening for a click event on #C in the bubbling phase?" חשוב לשים לב. אפשר להאזין לקליקים (או לכל סוג אירוע) גם בשלב הלכידה וגם בשלב הבועות. ואם חיברתם את ה-event handlers בשני השלבים (למשל, על ידי קריאה ל-.addEventListener() פעמיים, פעם אחת עם capture = true ופעם אחת עם capture = false), אז כן, שני ה-event handlers יופעלו בוודאות עבור אותו רכיב. אבל חשוב גם לציין שהם מופעלים בשלבים שונים (אחד בשלב הלכידה ואחד בשלב ההעברה).

לאחר מכן, האירוע יתפשט (בדרך כלל מכנים זאת 'התפשטות' כי נראה שהאירוע עובר 'למעלה' בעץ ה-DOM) לרכיב האב שלו, #B, והדפדפן ישאל: 'האם יש משהו שמקשיב לאירועי קליק ב-#B בשלב ההתפשטות?' בדוגמה שלנו, אין שום דבר, ולכן לא יופעלו מטפלים.

לאחר מכן, האירוע יתפשט אל #A והדפדפן ישאל: "האם יש משהו שמקשיב לאירועי קליק ב-#A בשלב ההתפשטות?"

לאחר מכן, האירוע יתפשט ל-<body>: "האם יש משהו שמקשיב לאירועי קליקים ברכיב <body> בשלב ההתפשטות?"

לאחר מכן, האלמנט <html>: "האם יש משהו שמקשיב לאירועי קליקים באלמנט <html> בשלב ההתפשטות?

אחר כך, השאלה document: "Is anything listening for click events on the document in the bubbling phase?"

לבסוף, window: "האם יש משהו שמקשיב לאירועי קליקים בחלון בשלב הבועות?"

סוף סוף! זה היה מסע ארוך, והאירוע שלנו כנראה מאוד עייף עכשיו, אבל תאמינו או לא, זה המסע שכל אירוע עובר! ברוב המקרים לא שמים לב לזה, כי בדרך כלל מפתחים מתעניינים רק בשלב אחד של האירוע (ובדרך כלל זה השלב של העברת האירוע למעלה).

כדאי להקדיש זמן לניסוי של לכידת אירועים, העברת אירועים (bubbling) ורישום ביומן, ולרשום הערות במסוף כשהמטפלים מופעלים. התובנות לגבי הנתיב של אירוע מסוים יכולות להיות מאוד מועילות. בדוגמה הבאה, המאזין מופעל על כל אלמנט בשני השלבים.

<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in capturing phase');
  },
  true,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in capturing phase');
  },
  true,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in capturing phase');
  },
  true,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in capturing phase');
  },
  true,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in capturing phase');
  },
  true,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in capturing phase');
  },
  true,
);

document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in bubbling phase');
  },
  false,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in bubbling phase');
  },
  false,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in bubbling phase');
  },
  false,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in bubbling phase');
  },
  false,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in bubbling phase');
  },
  false,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in bubbling phase');
  },
  false,
);

הפלט במסוף יהיה תלוי באלמנט שעליו לוחצים. אם תלחצו על הרכיב 'העמוק ביותר' בעץ ה-DOM (הרכיב #C), תראו שכל אחד ממטפלי האירועים האלה מופעל. הנה הפלט של רכיב #C המסוף (עם צילום מסך), אחרי שסגננו אותו באמצעות CSS כדי להבליט את ההבדלים בין הרכיבים:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"
"click on <body> in bubbling phase"
"click on <html> in bubbling phase"
"click on document in bubbling phase"

event.stopPropagation()

אחרי שהבנו מאיפה האירועים מגיעים ואיך הם עוברים (כלומר, מתפשטים) דרך ה-DOM בשלב הלכידה ובשלב הבועות, אפשר לעבור ל-event.stopPropagation().

אפשר להפעיל את השיטה stopPropagation() על (רוב) אירועי DOM מקוריים. אני אומר 'רוב' כי יש כמה שבהם קריאה לשיטה הזו לא תעשה כלום (כי האירוע לא מועבר מלכתחילה). אירועים כמו focus,‏ blur,‏ load,‏ scroll ועוד כמה אירועים אחרים נכללים בקטגוריה הזו. אפשר להתקשר אל stopPropagation() אבל לא יקרה שום דבר מעניין, כי האירועים האלה לא מועברים.

אבל מה עושה stopPropagation?

הוא עושה בדיוק את מה שכתוב. כשמפעילים את הפונקציה הזו, האירוע מפסיק להתפשט לכל הרכיבים שהוא אמור להגיע אליהם. זה נכון לגבי שני הכיוונים (לכידה והעברה). לכן, אם קוראים ל-stopPropagation() בכל מקום בשלב הלכידה, האירוע לא יגיע לשלב היעד או לשלב הבועות. אם קוראים לה בשלב ה-bubbling, היא כבר עברה את שלב ה-capturing, אבל היא תפסיק את ה-bubbling מהנקודה שבה קראתם לה.

נחזור לאותו קוד markup לדוגמה. מה לדעתכם יקרה אם נקרא ל-stopPropagation() בשלב הלכידה ברכיב #B?

הפלט שיתקבל יהיה:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"

מה דעתך להפסיק את ההתפשטות ב-#A בשלב הבועות? הפלט שיתקבל יהיה:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"

עוד אחד, בשביל הכיף. מה קורה אם קוראים לפונקציה stopPropagation() בשלב היעד של #C? זוכרים ש'שלב היעד' הוא השם שניתן לתקופת הזמן שבה האירוע מגיע ליעד שלו. הפלט שיתקבל יהיה:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"

שימו לב שהגורם המטפל באירועים של #C שבו מתבצע רישום ביומן של 'קליק על #C בשלב הלכידה' עדיין מופעל, אבל הגורם שבו מתבצע רישום ביומן של 'קליק על #C בשלב ההתפשטות' לא מופעל. ההסבר הזה אמור להיות ברור. התקשרנו אל stopPropagation() מהקודם, ולכן זה השלב שבו ההפצה של האירוע תיפסק.

מומלץ להתנסות בכל אחת מההדגמות החיות האלה. נסו ללחוץ רק על הרכיב #A או רק על הרכיב body. נסו לנחש מה יקרה ואז תבדקו אם צדקתם. בשלב הזה, אתם אמורים להיות מסוגלים לחזות את התוצאה בצורה די מדויקת.

event.stopImmediatePropagation()

מהי השיטה המוזרה הזו, שלא נמצאת בשימוש לעיתים קרובות? היא דומה ל-stopPropagation, אבל במקום למנוע מאירוע להגיע לרכיבי צאצא (לכידה) או לרכיבי אב (העברה), השיטה הזו רלוונטית רק אם יש לכם יותר ממטפל אירועים אחד שמחובר לרכיב יחיד. מכיוון שספריית addEventListener() תומכת בסגנון שידור מרובה של אירועים, אפשר לחבר את handler האירועים לרכיב יחיד יותר מפעם אחת. כשזה קורה (ברוב הדפדפנים), פונקציות לטיפול באירועים מופעלות לפי הסדר שבו הן הוגדרו. התקשרות אל stopImmediatePropagation() מונעת הפעלה של מטפלים עוקבים. דוגמה:

<html>
  <body>
    <div id="A">I am the #A element</div>
  </body>
</html>
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I shall run first!');
  },
  false,
);

document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I shall run second!');
    e.stopImmediatePropagation();
  },
  false,
);

document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I would have run third, if not for stopImmediatePropagation');
  },
  false,
);

הדוגמה שלמעלה תפיק את הפלט הבא במסוף:

"When #A is clicked, I shall run first!"
"When #A is clicked, I shall run second!"

שימו לב שהמטפל השלישי באירועים אף פעם לא מופעל, כי המטפל השני באירועים קורא ל-e.stopImmediatePropagation(). אם במקום זאת היינו קוראים ל-e.stopPropagation(), המטפל השלישי עדיין היה מופעל.

event.preventDefault()

אם stopPropagation() מונע מאירוע לנוע 'למטה' (לכידה) או 'למעלה' (העברה), מה עושה preventDefault()? נשמע שהוא עושה משהו דומה. האם זה נכון?

לא ממש. למרות שלעתים קרובות יש בלבול בין השניים, למעשה אין ביניהם קשר רב. כשרואים את הסמל preventDefault(), מוסיפים בראש את המילה 'פעולה'. תחשבו על זה כעל 'מניעת פעולת ברירת המחדל'.

מהי פעולת ברירת המחדל, אתם יכולים לשאול? לצערנו, התשובה לשאלה הזו לא כל כך ברורה, כי היא תלויה מאוד בשילוב של הרכיב והאירוע ששואלים עליו. כדי להוסיף עוד בלבול, לפעמים אין בכלל פעולת ברירת מחדל.

נתחיל בדוגמה פשוטה מאוד כדי להבין את התהליך. מה אתם מצפים שיקרה כשאתם לוחצים על קישור בדף אינטרנט? ברור שאתם מצפים שהדפדפן ינווט לכתובת ה-URL שצוינה בקישור. במקרה הזה, האלמנט הוא תג עוגן והאירוע הוא אירוע קליק. לצירוף הזה (<a> + click) יש 'פעולת ברירת מחדל' של ניווט ל-href של הקישור. מה קורה אם רוצים למנוע מהדפדפן לבצע את פעולת ברירת המחדל הזו? כלומר, נניח שאתם רוצים למנוע מהדפדפן לנווט לכתובת ה-URL שצוינה במאפיין href של רכיב <a>. זה מה שpreventDefault() יעשה בשבילכם. עיין בדוגמה זו:

<a id="avett" href="https://www.theavettbrothers.com/welcome">The Avett Brothers</a>
document.getElementById('avett').addEventListener(
  'click',
  function (e) {
    e.preventDefault();
    console.log('Maybe we should just play some of their music right here instead?');
  },
  false,
);

בדרך כלל, לחיצה על הקישור עם התווית The Avett Brothers תוביל לדף www.theavettbrothers.com. אבל במקרה הזה, חיברנו handler של אירוע קליק לרכיב <a> וציינו שצריך למנוע את פעולת ברירת המחדל. לכן, כשמשתמש לוחץ על הקישור הזה, הוא לא מועבר לשום מקום, ובמקום זאת, במסוף פשוט יירשם "Maybe we should just play some of their music right here instead?" (אולי כדאי פשוט להשמיע כאן חלק מהמוזיקה שלו?).

אילו שילובים אחרים של רכיבים או אירועים מאפשרים למנוע את פעולת ברירת המחדל? אי אפשר לפרט את כל האפשרויות, ולפעמים צריך פשוט להתנסות כדי לראות מה עובד. אבל בקצרה, הנה כמה מהן:

  • רכיב <form> + אירוע submit: preventDefault() השילוב הזה ימנע שליחה של טופס. האפשרות הזו שימושית אם רוצים לבצע אימות, ואם משהו נכשל, אפשר לקרוא ל-preventDefault באופן מותנה כדי למנוע את שליחת הטופס.

  • האלמנט <a> + האירוע 'click': preventDefault() השילוב הזה מונע מהדפדפן לנווט לכתובת ה-URL שצוינה במאפיין href של האלמנט <a>.

  • document + אירוע 'גלגל העכבר': preventDefault() שילוב המקשים הזה מונע גלילה בדף באמצעות גלגל העכבר (אבל עדיין אפשר לגלול באמצעות המקלדת). ‫
    ↜ כדי לעשות את זה, צריך להפעיל את addEventListener() עם { passive: false }.

  • document + אירוע keydown: preventDefault() השילוב הזה קטלני. היא הופכת את הדף ללא שימושי במידה רבה, ומונעת גלילה, מעבר בין רכיבים והדגשה של רכיבים באמצעות המקלדת.

  • document + אירוע mousedown: השילוב הזה של מקשים ימנע הדגשה של טקסט באמצעות העכבר וכל פעולה אחרת שמוגדרת כברירת מחדל שאפשר להפעיל באמצעות לחיצה על העכבר.preventDefault()

  • רכיב <input> + אירוע keypress: preventDefault() השילוב הזה ימנע מהתווים שהמשתמש מקליד להגיע לרכיב הקלט (אבל לא מומלץ לעשות את זה, כי כמעט אף פעם אין סיבה מוצדקת לכך).

  • document + אירוע contextmenu: preventDefault() השילוב הזה מונע את ההופעה של תפריט ההקשר המקורי של הדפדפן כשמשתמש לוחץ לחיצה ימנית או לוחץ לחיצה ארוכה (או בכל דרך אחרת שבה תפריט ההקשר עשוי להופיע).

זו רשימה חלקית בלבד, אבל היא אמורה לתת לכם מושג טוב לגבי השימושים האפשריים ב-preventDefault().

תעלול משעשע?

מה קורה אם stopPropagation() and preventDefault() בשלב הצילום, החל מהמסמך? התוצאה היא מצחיקה. קטע הקוד הבא יהפוך כל דף אינטרנט לבלתי שמיש כמעט לחלוטין:

function preventEverything(e) {
  e.preventDefault();
  e.stopPropagation();
  e.stopImmediatePropagation();
}

document.addEventListener('click', preventEverything, true);
document.addEventListener('keydown', preventEverything, true);
document.addEventListener('mousedown', preventEverything, true);
document.addEventListener('contextmenu', preventEverything, true);
document.addEventListener('mousewheel', preventEverything, { capture: true, passive: false });

אני לא יודע למה תרצו לעשות את זה (אולי כדי לעשות למישהו תרגיל), אבל כדאי להבין מה קורה כאן ולמה זה יוצר את המצב שנוצר.

כל האירועים מתחילים ב-window, ולכן בקטע הקוד הזה אנחנו עוצרים את כל האירועים מסוג click, keydown, mousedown, contextmenu ו-mousewheel לפני שהם מגיעים לרכיבים שעשויים להאזין להם. אנחנו גם מתקשרים אל stopImmediatePropagation כדי שגם כל מטפלים שמחוברים למסמך אחרי זה יסוכלו.

חשוב לשים לב שהערכים stopPropagation() ו-stopImmediatePropagation() לא הופכים את הדף ללא שימושי (לפחות לא ברוב המקרים). הן פשוט מונעות מאירועים להגיע למקומות שאליהם הם היו מגיעים אחרת.

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

תודות

תמונה ראשית (Hero) של Tom Wilson ב-Unsplash.