Skip to content

Event propagatie in het DOM: bubbling en capturing โ€‹

Inleiding โ€‹

Wanneer een event plaatsvindt in het DOM, zoals een klik op een button, volgt dit event een specifiek pad door de DOM-tree. Dit proces wordt "event propagatie" genoemd en bestaat uit drie fasen: capturing, target en bubbling. Inzicht in deze fasen is essentieel voor het effectief afhandelen van events in webapplicaties, vooral bij complexe geneste structuren en webcomponenten.

In deze gids duiken we diep in de mechanismen van event propagatie, met speciale aandacht voor de capturing en bubbling fasen, en hoe je deze kennis kunt toepassen in je LIT componenten.

De drie fasen van event propagatie โ€‹

Wanneer een event wordt geactiveerd, doorloopt het drie fasen:

  1. Capturing fase: Het event reist van het window object naar beneden door de DOM-tree naar het target element.
  2. Target fase: Het event bereikt het element waarop de actie plaatsvond.
  3. Bubbling fase: Het event bubbelt terug omhoog door de DOM-tree, van het target element naar het window object.
          | Capturing fase (1)
          โ†“
window โ†’ document โ†’ html โ†’ body โ†’ div โ†’ button
          โ†‘
          | Bubbling fase (3)

De target fase (2) vindt plaats wanneer het event het target element bereikt.

Event capturing fase โ€‹

De capturing fase, ook wel "trickle down" genoemd, is de eerste fase van event propagatie. Tijdens deze fase reist het event van het window object door de DOM-tree naar beneden naar het target element.

Hoe capturing werkt โ€‹

  1. Het event begint bij het window object
  2. Vervolgens naar het document object
  3. Dan naar het <html> element (document.documentElement)
  4. Dan naar het <body> element
  5. En zo verder door elke voorouder van het target element
  6. Totdat het het parent element van het target bereikt

Voorbeeld van capturing โ€‹

Laten we een geneste structuur bekijken:

html
<div id="outer">
  <div id="middle">
    <button id="inner">Klik mij</button>
  </div>
</div>

Als een gebruiker op de button klikt, verloopt de capturing fase als volgt:

  1. window
  2. document
  3. <html>
  4. <body>
  5. <div id="outer">
  6. <div id="middle">
  7. (Dan bereikt het event de target fase op <button id="inner">)

Capturing listeners registreren โ€‹

Om een event listener te registreren voor de capturing fase, geef je true mee als derde parameter aan addEventListener:

typescript
document.getElementById('outer').addEventListener(
  'click',
  (event) => {
    console.log('Outer div capturing fase');
  },
  true
); // true activeert capturing fase

document.getElementById('middle').addEventListener(
  'click',
  (event) => {
    console.log('Middle div capturing fase');
  },
  true
);

document.getElementById('inner').addEventListener(
  'click',
  (event) => {
    console.log('Button capturing fase');
  },
  true
);

Bij een klik op de button zal de console tonen:

Outer div capturing fase
Middle div capturing fase
Button capturing fase

Target fase โ€‹

De target fase is de tweede fase van event propagatie. Deze fase vindt plaats wanneer het event het element bereikt waarop de actie oorspronkelijk plaatsvond.

Wat gebeurt er in de target fase โ€‹

  1. Het event bereikt het element waarop de actie plaatsvond
  2. Alle event handlers die op het target element zijn geregistreerd worden uitgevoerd
  3. Eerst worden capturing listeners uitgevoerd, dan reguliere (bubbling) listeners

Eigenschappen van de target fase โ€‹

  • event.target verwijst naar het element waarop de actie plaatsvond
  • event.currentTarget verwijst ook naar het target element tijdens deze fase
  • event.eventPhase heeft de waarde Event.AT_TARGET (2)

Voorbeeld van de target fase โ€‹

Met dezelfde HTML structuur als hierboven:

typescript
document.getElementById('inner').addEventListener(
  'click',
  (event) => {
    console.log('Target fase - capturing listener');
    console.log('event.eventPhase:', event.eventPhase); // 2 (AT_TARGET)
    console.log('event.target === event.currentTarget:', event.target === event.currentTarget); // true
  },
  true
); // capturing listener

document.getElementById('inner').addEventListener(
  'click',
  (event) => {
    console.log('Target fase - bubbling listener');
    console.log('event.eventPhase:', event.eventPhase); // 2 (AT_TARGET)
  },
  false
); // bubbling listener (default)

Bij een klik op de button worden beide listeners uitgevoerd tijdens de target fase, eerst de capturing listener, dan de bubbling listener.

Event bubbling fase โ€‹

De bubbling fase is de derde en laatste fase van event propagatie. Tijdens deze fase "bubbelt" het event omhoog door de DOM-tree, van het target element terug naar het window object.

Hoe bubbling werkt โ€‹

  1. Het event begint bij het parent element van het target
  2. Dan naar de grootouder van het target
  3. En zo verder omhoog door elke voorouder
  4. Dan naar het <body> element
  5. Dan naar het <html> element
  6. Dan naar het document object
  7. Tenslotte naar het window object

Voorbeeld van bubbling โ€‹

Met dezelfde geneste structuur:

html
<div id="outer">
  <div id="middle">
    <button id="inner">Klik mij</button>
  </div>
</div>

Als een gebruiker op de button klikt, verloopt de bubbling fase als volgt:

  1. <div id="middle"> (parent van target)
  2. <div id="outer"> (grootouder van target)
  3. <body>
  4. <html>
  5. document
  6. window

Bubbling listeners registreren โ€‹

Standaard worden event listeners geregistreerd voor de bubbling fase. Je kunt false meegeven als derde parameter aan addEventListener (of deze parameter weglaten):

typescript
document.getElementById('inner').addEventListener('click', (event) => {
  console.log('Button bubbling fase');
}); // bubbling is standaard

document.getElementById('middle').addEventListener('click', (event) => {
  console.log('Middle div bubbling fase');
});

document.getElementById('outer').addEventListener('click', (event) => {
  console.log('Outer div bubbling fase');
});

Bij een klik op de button zal de console tonen:

Button bubbling fase
Middle div bubbling fase
Outer div bubbling fase

Welke events bubbelen? โ€‹

De meeste events bubbelen, maar er zijn uitzonderingen:

EventBubbeltOpmerkingen
click, mousedown, mouseup, mousemoveJaAlle mouse events bubbelen
keydown, keypress, keyupJaAlle keyboard events bubbelen
focus, blurNeeGebruik focusin en focusout voor bubbling alternatieven
load, unload, abort, errorNeeResource events bubbelen niet
scrollJaBubbelt, maar vaak gestopt in browsers
Custom eventsConfigureerbaarAfhankelijk van de bubbles optie

De event flow visualiseren โ€‹

Laten we de volledige event flow visualiseren voor een klik op een button in een geneste structuur:

                  window
                    |
                    | Capturing fase (1)
                    โ†“
                 document
                    |
                    โ†“
                   html
                    |
                    โ†“
                   body
                    |
                    โ†“
                div#outer
                    |
                    โ†“
                div#middle
                    |
                    โ†“
                button#inner  <-- Target fase (2)
                    |
                    | Bubbling fase (3)
                    โ†‘
                div#middle
                    |
                    โ†‘
                div#outer
                    |
                    โ†‘
                   body
                    |
                    โ†‘
                   html
                    |
                    โ†‘
                 document
                    |
                    โ†‘
                  window

Event propagatie controleren โ€‹

Je kunt de propagatie van events controleren met verschillende methoden:

1. event.stopPropagation() โ€‹

Deze methode stopt de verdere propagatie van het event in de huidige fase. Als deze wordt aangeroepen tijdens de capturing fase, zal het event nog steeds de target bereiken, maar niet verder bubbelen. Als deze wordt aangeroepen tijdens de bubbling fase, stopt het bubbling op dat punt.

typescript
document.getElementById('middle').addEventListener('click', (event) => {
  console.log('Middle div bubbling fase');
  event.stopPropagation();
  // Het event zal niet verder bubbelen naar outer div, body, etc.
});

2. event.stopImmediatePropagation() โ€‹

Deze methode stopt niet alleen de verdere propagatie, maar voorkomt ook dat andere event handlers op hetzelfde element worden uitgevoerd.

typescript
document.getElementById('inner').addEventListener('click', (event) => {
  console.log('Eerste click handler op button');
  event.stopImmediatePropagation();
  // Andere handlers op dit element worden niet uitgevoerd
});

document.getElementById('inner').addEventListener('click', (event) => {
  console.log('Tweede click handler op button'); // Deze wordt niet uitgevoerd
});

3. event.preventDefault() โ€‹

Deze methode voorkomt de standaard actie van het event, maar stopt de propagatie niet. Bijvoorbeeld, het voorkomt dat een link wordt gevolgd of een formulier wordt verzonden.

typescript
document.querySelector('form').addEventListener('submit', (event) => {
  if (!isValid()) {
    event.preventDefault(); // Voorkomt formulier verzending
    // Het event blijft wel bubbelen
  }
});

Event listeners toevoegen โ€‹

Syntax en opties โ€‹

De volledige syntax voor addEventListener:

typescript
target.addEventListener(type, listener, options);
// of
target.addEventListener(type, listener, useCapture);

Waarbij:

  • type: Het type event (bijv. 'click', 'keydown')
  • listener: De callback functie
  • options: Een object met opties, of
  • useCapture: Boolean die aangeeft of de listener in de capturing fase moet worden uitgevoerd

Options object โ€‹

Het options object kan de volgende eigenschappen bevatten:

typescript
{
  capture: Boolean, // Zelfde als useCapture
  once: Boolean,    // Automatisch verwijderen na รฉรฉn keer uitvoeren
  passive: Boolean  // Geeft aan dat preventDefault() niet wordt aangeroepen
}

Voorbeeld:

typescript
document.getElementById('scroll-container').addEventListener(
  'scroll',
  (event) => {
    console.log('Scrolling...');
  },
  {
    passive: true, // Verbetert scroll performance
  }
);

document.getElementById('button').addEventListener(
  'click',
  (event) => {
    console.log('Eenmalige klik gedetecteerd');
  },
  {
    once: true, // Listener wordt automatisch verwijderd na รฉรฉn keer
  }
);

Praktische toepassingen โ€‹

1. Event delegation โ€‹

Event delegation is een patroon waarbij je รฉรฉn event listener toevoegt aan een parent element in plaats van aparte listeners voor elk child element. Dit is bijzonder nuttig voor dynamische lijsten.

typescript
// In plaats van een listener op elke <li>
document.querySelector('ul').addEventListener('click', (event) => {
  const target = event.target as HTMLElement;
  if (target.tagName === 'LI') {
    console.log('Lijst item geklikt:', target.textContent);
  }
});

Voordelen:

  • Minder geheugengebruik
  • Geen listeners toevoegen/verwijderen bij DOM-wijzigingen
  • Werkt voor dynamisch toegevoegde elementen

2. Custom event bubbeling โ€‹

Je kunt custom events laten bubbelen door de DOM-tree:

typescript
// Custom event aanmaken en dispatchen
const button = document.getElementById('action-button');
button.addEventListener('click', () => {
  // Custom event met bubbling
  const event = new CustomEvent('action-performed', {
    bubbles: true,
    detail: { action: 'save' },
  });
  button.dispatchEvent(event);
});

// Luisteren naar het custom event hoger in de DOM
document.getElementById('container').addEventListener('action-performed', (event: CustomEvent) => {
  console.log('Actie uitgevoerd:', event.detail.action);
});

3. Capturing voor focus management โ€‹

Capturing is nuttig voor focus management in complexe UI's:

typescript
// Gebruik capturing om focus te detecteren voordat het target element het ontvangt
document.addEventListener(
  'focus',
  (event) => {
    const target = event.target as HTMLElement;
    if (target.matches('.restricted') && !isAuthorized()) {
      event.preventDefault();
      event.stopPropagation();
      console.log('Toegang geweigerd tot restricted element');
      safeElement.focus(); // Focus naar een veilig element verplaatsen
    }
  },
  true
); // Capturing fase

Event propagatie en shadow DOM โ€‹

Shadow DOM introduceert extra complexiteit in event propagatie, vooral met betrekking tot de grens tussen shadow DOM en light DOM.

Event retargeting โ€‹

Wanneer een event een shadow boundary oversteekt, wordt het event "geretarget":

<custom-dialog>  <!-- Light DOM -->
  #shadow-root   <!-- Shadow Boundary -->
    <div class="dialog">
      <button class="close">Sluiten</button>
    </div>
</custom-dialog>

Als een gebruiker op de "Sluiten" knop klikt:

  1. Binnen de shadow DOM:

    • event.target is de <button class="close">
  2. Nadat het event de shadow boundary oversteekt:

    • event.target wordt gewijzigd naar <custom-dialog>
    • Het originele pad is nog beschikbaar via event.composedPath()

Composed events โ€‹

Alleen events met composed: true kunnen shadow boundaries oversteken:

typescript
// Binnen een LIT component
private handleClose() {
  // Dit event kan de shadow boundary oversteken
  this.dispatchEvent(new CustomEvent('dialog-close', {
    bubbles: true,
    composed: true,
    detail: { dialogId: this.id }
  }));
}

Volledige propagatie pad met shadow DOM โ€‹

Met shadow DOM wordt het propagatie pad complexer:

                  window
                    |
                    | Capturing fase
                    โ†“
                 document
                    |
                    โ†“
                   html
                    |
                    โ†“
                   body
                    |
                    โ†“
              custom-dialog  <-- Shadow host
                    |
                    โ†“
              #shadow-root   <-- Shadow boundary
                    |
                    โ†“
                div.dialog
                    |
                    โ†“
               button.close  <-- Target fase
                    |
                    | Bubbling fase
                    โ†‘
                div.dialog
                    |
                    โ†‘
              #shadow-root   <-- Shadow boundary
                    |
                    โ†‘
              custom-dialog  <-- Event retargeting gebeurt hier
                    |        <-- event.target wordt custom-dialog
                    โ†‘
                   body
                    |
                    โ†‘
                   html
                    |
                    โ†‘
                 document
                    |
                    โ†‘
                  window

Best practices โ€‹

1. Gebruik event delegation waar mogelijk โ€‹

typescript
// Efficiรซnt
document.querySelector('table').addEventListener('click', (event) => {
  const cell = (event.target as HTMLElement).closest('td');
  if (cell) {
    console.log('Cel geklikt:', cell.textContent);
  }
});

// Vermijd
document.querySelectorAll('td').forEach((cell) => {
  cell.addEventListener('click', (event) => {
    console.log('Cel geklikt:', cell.textContent);
  });
});

2. Wees voorzichtig met stopPropagation() โ€‹

Overmatig gebruik van stopPropagation() kan leiden tot onverwacht gedrag en moeilijk te debuggen problemen:

typescript
// Vermijd overmatig gebruik
document.querySelector('button').addEventListener('click', (event) => {
  event.stopPropagation(); // Kan analytics of andere functionaliteit breken
  // ...
});

// Beter: gebruik een meer gerichte aanpak
document.querySelector('button').addEventListener('click', (event) => {
  // Alleen specifieke acties voorkomen indien nodig
  if (shouldPreventDefault()) {
    event.preventDefault();
  }
  // Laat het event bubbelen voor andere handlers
});

3. Documenteer event flow in complexe componenten โ€‹

typescript
/**
 * Dialog component met custom events.
 *
 * Event flow:
 * 1. 'close-click' - Intern event wanneer op sluitknop wordt geklikt (niet composed)
 * 2. 'dialog-close' - Extern event wanneer dialog sluit (composed, bubbles)
 *
 * @fires dialog-close - Wanneer de dialog wordt gesloten
 *   detail: { source: 'button'|'escape'|'overlay', dialogId: string }
 */
@customElement('modal-dialog')
class ModalDialog extends LitElement {
  // Implementatie...
}

4. Gebruik de juiste event fase voor je use case โ€‹

typescript
// Capturing voor pre-processing of interceptie
document.addEventListener(
  'keydown',
  (event) => {
    if (event.key === 'Tab' && isModalOpen()) {
      // Houd Tab toets binnen de modal
      trapFocus(event);
    }
  },
  true
); // Capturing fase

// Bubbling voor de meeste normale interacties
document.querySelector('form').addEventListener('submit', (event) => {
  // Form verwerking na submit
  handleFormSubmit(event);
}); // Bubbling fase (standaard)

Veelvoorkomende valkuilen โ€‹

1. Verwarring tussen target en currentTarget โ€‹

typescript
document.getElementById('outer').addEventListener('click', function (event) {
  // Verkeerd
  console.log('Geklikt element:', event.target.id);
  // Dit toont 'inner' als op de button wordt geklikt

  // Juist
  console.log('Element met de listener:', event.currentTarget.id);
  // Dit toont altijd 'outer'

  // Controleren of het target element een specifiek element is
  if (event.target.id === 'inner') {
    console.log('Button werd direct geklikt');
  }
});

2. Verkeerd begrip van event.preventDefault() vs event.stopPropagation() โ€‹

typescript
// Verkeerd gebruik
document.querySelector('a').addEventListener('click', (event) => {
  // Dit voorkomt alleen de standaard actie (navigatie)
  // maar stopt de propagatie niet
  event.preventDefault();

  // Dit is nodig om propagatie te stoppen
  event.stopPropagation();
});

// Correct begrip
document.querySelector('form').addEventListener('submit', (event) => {
  // Voorkomt form submission
  event.preventDefault();

  // Alleen indien nodig: stop propagatie
  if (shouldStopPropagation()) {
    event.stopPropagation();
  }
});

3. Niet rekening houden met event retargeting in shadow DOM โ€‹

typescript
// Probleem: vertrouwen op event.target met shadow DOM
document.querySelector('app-container').addEventListener('click', (event) => {
  // Dit toont altijd het custom element, niet het interne element
  console.log('Geklikt element:', event.target);

  // Oplossing: gebruik composedPath()
  const originalTarget = event.composedPath()[0];
  console.log('Werkelijk geklikt element:', originalTarget);
});

4. Memory leaks door niet-verwijderde event listeners โ€‹

typescript
// Probleem
class Widget {
  constructor(element) {
    this.element = element;
    // 'this' context verloren en listener niet opgeslagen
    document.addEventListener('resize', this.handleResize);
  }

  handleResize() {
    // 'this' verwijst niet naar Widget instantie
  }

  destroy() {
    // Kan listener niet verwijderen, memory leak
  }
}

// Oplossing
class Widget {
  constructor(element) {
    this.element = element;
    // Bind en bewaar referentie
    this.boundHandleResize = this.handleResize.bind(this);
    document.addEventListener('resize', this.boundHandleResize);
  }

  handleResize() {
    // 'this' verwijst correct naar Widget instantie
  }

  destroy() {
    // Listener correct verwijderen
    document.removeEventListener('resize', this.boundHandleResize);
  }
}

Conclusie โ€‹

Event propagatie is een fundamenteel concept in webontwikkeling dat bestaat uit drie fasen: capturing, target en bubbling. Elk heeft zijn eigen specifieke use cases:

  • Capturing: Ideaal voor globale event interceptie en pre-processing
  • Target: Waar de directe interactie wordt afgehandeld
  • Bubbling: Perfect voor event delegation en het reageren op events op hogere niveaus

Door deze fasen te begrijpen en effectief te gebruiken, kun je elegante, performante en onderhoudbare event handling implementeren in je webapplicaties en LIT componenten.

De belangrijkste punten om te onthouden:

  • Events doorlopen eerst de capturing fase, dan de target fase, en tenslotte de bubbling fase
  • De meeste events bubbelen, maar er zijn uitzonderingen
  • event.target is het element waarop de actie plaatsvond, event.currentTarget is het element met de listener
  • stopPropagation() stopt verdere propagatie, maar niet andere handlers op hetzelfde element
  • stopImmediatePropagation() stopt alle verdere event handling
  • Event delegation is een krachtig patroon voor efficiรซnte event handling
  • Shadow DOM introduceert event retargeting en vereist speciale aandacht