Geef elke gemounte JavaScript-weergave een nieuwe AbortController, registreer de eventlisteners met het signaal van die controller en breek het signaal af tijdens het unmounten. In deze tutorial bouwen we een kleine browserweergave en laten we zien hoe je herhaalde mountcycli controleert in Chrome op een Mac.

Waarom stapelen eventhandlers zich op wanneer een weergave opnieuw wordt geopend?

Een weergave kan er bij de eerste opening goed uitzien en na het opnieuw openen twee keer reageren. Een veelvoorkomende oorzaak is setupcode die bij elke mount een nieuwe callback maakt zonder de vorige registratie te verwijderen. Functies die afzonderlijk worden aangemaakt, zijn verschillende listenerobjecten, ook wanneer hun brontekst identiek is. De documentatie van MDN voor addEventListener() legt deze regel voor callbackidentiteit uit.

De DOM voorkomt dubbele registraties met hetzelfde eventtype, dezelfde callback en dezelfde capturewaarde op hetzelfde doel. Die bescherming lost het probleem van herhaald mounten niet op wanneer elke setup een nieuwe callback maakt. De standaard specificeert ook dat het afbreken van het opgegeven signaal van een listener die listener verwijdert. Deze regels ondersteunen een praktische aanpak: één controller beheert alle native eventlisteners voor één gemounte periode. DOM Standard: EventTarget.

In deze tutorial is het de taak van de lezer om een eenvoudige JavaScript-telweergave te openen, te sluiten en opnieuw te openen zonder extra button- of window-handlers toe te voegen. Het voorbeeld gebruikt synthetische labels, vereist geen framework en bewaart de tellers tussen mounts.

Welk levenscycluscontract moet de weergave volgen?

Bepaal vóór de implementatie van de setup wat herhaalde levenscyclusaanroepen betekenen. Met het volgende contract zijn onbedoelde herhaalde aanroepen onschadelijk en heeft cleanup een duidelijke eigenaar.

Bewerking Vereist gedrag
Eerste mount() Koppel de weergave aan en registreer één set listeners.
Nogmaals mount() terwijl de weergave gemount is Keer terug zonder iets te wijzigen.
unmount() Verwijder de listeners van de gemounte periode en koppel daarna de weergave los.
Nogmaals unmount() Keer terug zonder iets te wijzigen.
mount() na het unmounten Registreer een nieuwe set met een nieuwe controller; behoud de tellers.
destroy() Unmount permanent; bij later mounten wordt een fout gegenereerd.

Dit zijn ontwerpkeuzes voor de applicatie. De browser biedt registratie en verwijdering van events; je weergave levert de levenscyclusmethoden en bepaalt hun volgorde. In een grotere applicatie moet het component of de router die mount() aanroept ook zorgen voor de bijbehorende unmount().

Een nieuwe controller is belangrijk omdat een afgebroken signaal niet opnieuw actief kan worden. Wanneer addEventListener() een al afgebroken signaal ontvangt, keert het DOM-registratiealgoritme terug zonder de listener toe te voegen. Als je de oude controller opnieuw gebruikt, kan de heropende weergave dus niets meer doen. DOM Standard: een eventlistener toevoegen.

Bouw een volledig voorbeeld in Chrome op je Mac

Open een nieuw Chrome-tabblad, typ about:blank in de adresbalk en druk op Return. Druk terwijl de pagina focus heeft op Command + Option + J om de Console te openen. Google documenteert deze Mac-sneltoets in de sneltoetsenreferentie van Chrome DevTools.

Voer het volledige programma hieronder uit in de Console. Het maakt de interface met JavaScript, dus er is geen documentmarkup, installatie van packages of buildstap nodig. De console van Chrome kan JavaScript uitvoeren en de DOM van de huidige pagina wijzigen, zoals wordt getoond in de tutorial van Google JavaScript uitvoeren in de Console.

(() => {
  // Clean up the previous demo if this entire program is run again.
  window.lifecycleDemo?.destroy();

  const host = document.body;
  const root = document.createElement("section");
  const button = document.createElement("button");
  const output = document.createElement("p");

  button.type = "button";
  button.textContent = "Add one";
  root.append(button, output);

  let controller = null;
  let destroyed = false;
  let clicks = 0;
  let resizes = 0;

  function render() {
    output.textContent =
      `Clicks: ${clicks}; resize events: ${resizes}`;
  }

  function mount() {
    if (destroyed) {
      throw new Error("This view has been destroyed.");
    }
    if (controller !== null) return;

    const next = new AbortController();
    const options = { signal: next.signal };

    try {
      button.addEventListener("click", () => {
        clicks += 1;
        render();
      }, options);

      window.addEventListener("resize", () => {
        resizes += 1;
        render();
      }, options);

      render();
      host.append(root);
      controller = next;
    } catch (error) {
      // Roll back any registrations made before setup failed.
      next.abort();
      root.remove();
      throw error;
    }
  }

  function unmount() {
    if (controller === null) return;
    controller.abort();
    controller = null;
    root.remove();
  }

  function destroy() {
    unmount();
    destroyed = true;
  }

  window.lifecycleDemo = {
    mount,
    unmount,
    destroy,
    button,
    get counts() {
      return { clicks, resizes };
    }
  };

  mount();
})();

De pagina bevat nu een knop Add one en een tellerregel. Klik op de knop: het aantal klikken moet met één toenemen. Pas het formaat van het Chrome-venster aan: het aantal resize-events moet veranderen. Bij een fysieke formaatwijziging kunnen meerdere events worden gegenereerd, dus dit is een eventtelling en geen telling van muisbewegingen.

De buttonlistener en de window-listener gebruiken hetzelfde signaal. Bij het unmounten wordt die controller afgebroken voordat de root wordt losgekoppeld. De volgende mount maakt een andere controller en een nieuwe set callbacks. De tellers blijven in de closure van de weergave staan, waardoor hun waarden behouden blijven wanneer je de weergave opnieuw opent; als je het hele programma vernietigt en opnieuw uitvoert, begint een nieuw exemplaar.

Het setupblok handelt ook een gedeeltelijke fout af: als setup een fout genereert nadat een listener is geregistreerd, breekt het catch-blok de nieuwe controller af, koppelt het de root los en genereert het de fout opnieuw. Zo voorkomt het dat een mislukte mount ongemerkt een deel van de setup actief laat.

Hoe controleer je cleanup na herhaald mounten?

Probeer eerst de levenscyclus handmatig. Voer de volgende reeks uit nadat je het hoofdprogramma hebt geïnstalleerd. De weergave verdwijnt, herhaald unmounten heeft geen extra effect en bij het opnieuw openen verschijnt de weergave weer met de vorige aantallen.

window.lifecycleDemo.unmount();
window.lifecycleDemo.unmount();
window.lifecycleDemo.mount();
window.lifecycleDemo.mount();

Voer daarna deze volledige controle uit in dezelfde console. Deze is afhankelijk van het bovenstaande hoofdprogramma. Elke cyclus roept setup en cleanup bewust tweemaal aan, verstuurt één event naar elk doel terwijl de weergave gemount is en verstuurt dezelfde events wanneer de weergave niet gemount is. Bij een afwijking wordt onmiddellijk een fout gegenereerd.

(() => {
  const view = window.lifecycleDemo;
  if (!view) throw new Error("Run the main program first.");

  function check(actual, expected, label) {
    if (actual !== expected) {
      throw new Error(`${label}: expected ${expected}, got ${actual}`);
    }
  }

  for (let cycle = 0; cycle < 20; cycle += 1) {
    view.mount();
    view.mount();
    const before = view.counts;

    view.button.click();
    window.dispatchEvent(new Event("resize"));

    check(view.counts.clicks, before.clicks + 1, "Mounted click");
    check(view.counts.resizes, before.resizes + 1, "Mounted resize");

    view.unmount();
    view.unmount();
    const stopped = view.counts;

    // The button reference remains available while detached.
    view.button.click();
    window.dispatchEvent(new Event("resize"));

    check(view.counts.clicks, stopped.clicks, "Unmounted click");
    check(view.counts.resizes, stopped.resizes, "Unmounted resize");
  }

  view.mount();
  console.log("Passed 20 mount/unmount cycles.");
})();

Het synthetische resize-event maakt de verwachte verandering voorspelbaar zonder het venster te verplaatsen. De controle met de losgekoppelde button is nuttig omdat dit voorbeeld de button bewust bewaart om opnieuw te kunnen mounten. Beide controles moeten slagen: als je alleen de zichtbare button test, blijft de window-listener buiten beschouwing. De test telt bij beide tellers 20 op en eindigt met de weergave gemount.

Voor rechtstreekse inspectie biedt Chrome in de DevTools-console getEventListeners(object). Dit geeft de listeners terug die op het opgegeven object zijn geregistreerd, gegroepeerd per eventtype. Dit is een DevTools-hulpmiddel, dus gebruik het niet in applicatiecode. Referentie van de Chrome Console Utilities API.

Voer na het hoofdprogramma deze inspectiereeks uit om de registraties vóór en na cleanup te vergelijken. Verwacht op de lege demonstratiepagina tijdens het mounten één clicklistener voor de button en één resize-listener voor het window, en na het unmounten van elk nul.

(() => {
  const view = window.lifecycleDemo;
  if (!view) throw new Error("Run the main program first.");

  view.mount();
  console.log("Mounted", {
    button: (getEventListeners(view.button).click ?? []).length,
    window: (getEventListeners(window).resize ?? []).length
  });

  view.unmount();
  console.log("Unmounted", {
    button: (getEventListeners(view.button).click ?? []).length,
    window: (getEventListeners(window).resize ?? []).length
  });

  view.mount();
})();

Op een bestaande applicatiepagina kan andere code listeners voor het window beheren. Vergelijk het verschil vóór en na en inspecteer de callbacks; ga er niet van uit dat het totaal op nul moet uitkomen.

Wanneer gebruik je expliciete verwijdering in plaats daarvan?

Gebruik removeEventListener() wanneer je bestaande code integreert waarin callbacks al worden opgeslagen, of wanneer de vereiste omgeving de listeneroptie signal niet ondersteunt. Bewaar voor cleanup het oorspronkelijke doel, eventtype, de callbackreferentie en de capturewaarde. MDN documenteert dat capture moet overeenkomen, terwijl opties zoals passive niet bepalen of een listener overeenkomt voor verwijdering. Referentie van MDN voor removeEventListener().

Bewaar bijvoorbeeld een gebonden callback één keer als de setup bind() gebruikt. Nogmaals bind() aanroepen tijdens cleanup levert een andere functiereferentie op. Ook kun je met het opnieuw uitschrijven van dezelfde arrowfunctie in de verwijderaanroep niet de eerder geregistreerde functie terugkrijgen. Houd expliciete cleanup naast de setup, zodat ze samen kunnen worden aangepast.

Het controllerpatroon beheert de native listeners die met het bijbehorende signaal zijn geregistreerd. Als een weergave ook timers, observers of bibliotheekabonnementen aanmaakt, koppel de bijbehorende, gedocumenteerde cleanupbewerkingen dan aan hetzelfde unmountmoment. Een signaal meegeven aan native listeners regelt de cleanup van niet-gerelateerde resources niet.

Wat moet je controleren als callbacks zich toch blijven vermenigvuldigen?

Gebruik het symptoom om het onderzoek af te bakenen in plaats van meerdere levenscyclusmethoden tegelijk te wijzigen.

Symptoom Volgende controle
Eén klik verhoogt de teller tweemaal Zoek naar een tweede registratieroute of een tweede actieve instantie van de weergave.
De bediening werkt niet meer nadat de weergave opnieuw is geopend Controleer of setup een afgebroken signaal opnieuw heeft gebruikt.
De aantallen veranderen na het unmounten Controleer elk eventdoel en of elke registratie het bedoelde signaal heeft gekregen.
Expliciete verwijdering heeft geen effect Vergelijk het doel, eventtype, de callbackidentiteit en de capturewaarde.
De eerste actie werkt, maar latere acties niet Controleer of de listener once: true gebruikt.

De optie once verwijdert een listener na de eerste aanroep. Deze optie is geschikt voor een interactie die werkelijk maar één keer mag plaatsvinden, maar beschrijft niet de gemounte levensduur van een weergave: het event kan vóór het unmounten nog optreden. MDN beschrijft zowel once als verwijdering op basis van een signaal in de referentie voor addEventListener()-opties.

Als je klaar bent met de demonstratie, voer dan window.lifecycleDemo.destroy() uit om de weergave los te koppelen en de listeners te verwijderen. Houd voor je eigen weergave hetzelfde praktische contract aan: één actieve setup, herhaalbare cleanup, nieuwe registraties wanneer de weergave opnieuw wordt geopend en een test die zowel de zichtbare bedieningselementen als de langer actieve eventdoelen test.

Bronnen