Assegna a ogni vista JavaScript montata un nuovo AbortController, registra i relativi listener con il segnale di quel controller e interrompilo durante lo smontaggio. Questo tutorial crea una piccola vista nel browser e mostra come verificare cicli di montaggio ripetuti in Chrome su Mac.
Perché i gestori di eventi si accumulano quando una vista viene riaperta?
Una vista può apparire corretta alla prima apertura e iniziare a rispondere due volte dopo essere stata riaperta. Una causa comune è il codice di inizializzazione che crea un altro callback a ogni montaggio senza rimuovere la registrazione precedente. Le funzioni create separatamente sono oggetti listener distinti, anche quando il loro codice sorgente è identico. La documentazione MDN di addEventListener() spiega questa regola sull’identità dei callback.
Il DOM impedisce registrazioni duplicate con lo stesso tipo di evento, callback e valore di capture sullo stesso target. Questa protezione non risolve il problema dei montaggi ripetuti quando ogni inizializzazione crea un nuovo callback. Lo standard specifica inoltre che l’interruzione del segnale fornito al listener lo rimuove. Queste regole consentono un approccio pratico: un controller gestisce tutti i listener di eventi nativi per un singolo intervallo di montaggio. DOM Standard: EventTarget.
In questo tutorial, il compito consiste nell’aprire, chiudere e riaprire una vista contatore in JavaScript semplice senza aggiungere gestori extra per il pulsante o la finestra. L’esempio usa etichette sintetiche, non richiede framework e mantiene i contatori tra un montaggio e l’altro.
Quale contratto del ciclo di vita dovrebbe seguire la vista?
Prima di implementare l’inizializzazione, decidi cosa devono significare le chiamate ripetute al ciclo di vita. Il contratto seguente rende innocue le chiamate ripetute accidentali e assegna la responsabilità della pulizia in modo chiaro.
| Operazione | Comportamento richiesto |
|---|---|
Primo mount() |
Collega la vista e registra un insieme di listener. |
Un altro mount() mentre è montata |
Restituisce il controllo senza modificare nulla. |
unmount() |
Rimuove i listener dell’intervallo di montaggio, poi scollega la vista. |
Un altro unmount() |
Restituisce il controllo senza modificare nulla. |
mount() dopo lo smontaggio |
Registra un nuovo insieme usando un controller nuovo; mantiene i contatori. |
destroy() |
Smonta definitivamente; i montaggi successivi generano un errore. |
Queste sono scelte progettuali dell’applicazione. Il browser fornisce la registrazione e la rimozione degli eventi; la vista definisce i metodi del ciclo di vita e il loro ordine. In un’applicazione più grande, il componente o il router che chiama mount() dovrebbe anche predisporre la corrispondente chiamata a unmount().
Un controller nuovo è importante perché un segnale interrotto non può essere riattivato. Quando addEventListener() riceve un segnale già interrotto, l’algoritmo di registrazione del DOM termina senza aggiungere il listener. Riutilizzando il vecchio controller, la vista riaperta potrebbe quindi avere controlli che non rispondono. DOM Standard: aggiunta di un event listener.
Crea un esempio completo in Chrome sul Mac
Apri una nuova scheda di Chrome, inserisci about:blank nella barra degli indirizzi e premi Return. Quando la pagina è attiva, premi Command + Option + J per aprire la Console. Google documenta questa scorciatoia per Mac nel riferimento alle scorciatoie da tastiera di Chrome DevTools.
Esegui il programma completo seguente nella Console. Crea l’interfaccia tramite JavaScript, quindi non servono markup del documento, installazione di pacchetti o passaggi di compilazione. La console di Chrome può eseguire JavaScript e modificare il DOM della pagina corrente, come mostra il tutorial di Google sull’esecuzione di JavaScript nella 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();
})();
La pagina ora contiene un pulsante Aggiungi uno e una riga con il contatore. Fai clic sul pulsante: il conteggio dei clic dovrebbe aumentare di uno. Ridimensiona la finestra di Chrome: il conteggio degli eventi di ridimensionamento dovrebbe cambiare. Un ridimensionamento fisico può generare diversi eventi, quindi il conteggio indica il numero di eventi, non il numero di gesti del mouse.
Il listener del pulsante e quello della finestra condividono lo stesso segnale. Lo smontaggio interrompe quel controller prima di scollegare l’elemento radice. Il montaggio successivo crea un altro controller e un altro insieme di callback. I contatori rimangono nella closure della vista, quindi la riapertura ne conserva i valori; distruggendo e rieseguendo l’intero programma si avvia una nuova istanza.
Il blocco di inizializzazione gestisce anche un errore parziale: se l’inizializzazione genera un errore dopo aver registrato un listener, il blocco catch interrompe il nuovo controller, scollega l’elemento radice e rilancia l’errore. In questo modo, un montaggio non riuscito non lascia silenziosamente attiva una parte dell’inizializzazione.
Come verificare la pulizia dopo montaggi ripetuti?
Per prima cosa, prova manualmente il ciclo di vita. Dopo aver installato il programma principale, esegui la sequenza seguente. La vista scompare, smontarla ripetutamente non ha altri effetti e riaprirla la ripristina con i conteggi precedenti.
window.lifecycleDemo.unmount();
window.lifecycleDemo.unmount();
window.lifecycleDemo.mount();
window.lifecycleDemo.mount();
Poi esegui questo controllo completo nella stessa console. Dipende dal programma principale qui sopra. Ogni ciclo richiama intenzionalmente due volte l’inizializzazione e la pulizia, invia un evento per ogni target mentre la vista è montata e invia gli stessi eventi quando è smontata. Qualsiasi discrepanza genera subito un errore.
(() => {
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.");
})();
L’evento sintetico di ridimensionamento rende deterministica la variazione attesa senza spostare la finestra. Il controllo sul pulsante scollegato è utile perché l’esempio conserva intenzionalmente il pulsante per rimontarlo. Entrambi i controlli devono riuscire: verificare solo il pulsante visibile lascerebbe senza controllo il listener della finestra. Il test aggiunge 20 a ciascun contatore e termina con la vista montata.
Per un’ispezione diretta, Chrome mette a disposizione getEventListeners(object) nella console di DevTools. Restituisce i listener registrati sull’oggetto specificato, raggruppati per tipo di evento. È un’utilità di DevTools, quindi non inserirla nel codice dell’applicazione. Riferimento all’API Console Utilities di Chrome.
Dopo aver eseguito il programma principale, questa sequenza di ispezione confronta le registrazioni prima e dopo la pulizia. Nella pagina vuota dell’esempio, quando la vista è montata aspettati un listener per il clic sul pulsante e uno per il ridimensionamento della finestra; quando è smontata, nessuno dei due.
(() => {
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();
})();
In una pagina di un’applicazione esistente, altro codice potrebbe gestire listener della finestra. Confronta la differenza tra prima e dopo e ispeziona i callback senza presumere che il totale debba arrivare a zero.
Quando conviene usare invece la rimozione esplicita?
Usa removeEventListener() quando integri codice esistente che già conserva i callback oppure quando l’ambiente richiesto non supporta l’opzione signal per i listener. Per la pulizia, conserva il target originale, il tipo di evento, il riferimento al callback e il valore di capture. MDN documenta che capture deve corrispondere, mentre opzioni come passive non determinano la corrispondenza per la rimozione. Riferimento MDN a removeEventListener().
Per esempio, se l’inizializzazione usa bind(), memorizza una sola volta il callback associato. Richiamare bind() durante la pulizia produce un riferimento a funzione diverso. Allo stesso modo, riscrivere la stessa funzione freccia nella chiamata di rimozione non recupera la funzione registrata in precedenza. Tieni la pulizia esplicita accanto all’inizializzazione, così evolvono insieme.
Il modello del controller copre i listener nativi registrati con il suo segnale. Se una vista crea anche timer, observer o sottoscrizioni a librerie, assegna le rispettive operazioni di pulizia documentate allo stesso confine di smontaggio. Passare un segnale ai listener nativi non garantisce la pulizia di risorse non correlate.
Cosa controllare se i callback continuano a moltiplicarsi?
Usa il sintomo per restringere l’indagine, invece di modificare più metodi del ciclo di vita contemporaneamente.
| Sintomo | Cosa controllare |
|---|---|
| Un clic incrementa il conteggio due volte | Cerca un secondo percorso di registrazione o una seconda istanza attiva della vista. |
| Alla riapertura i controlli smettono di rispondere | Verifica se l’inizializzazione ha riutilizzato un segnale interrotto. |
| I conteggi cambiano dopo lo smontaggio | Controlla ogni target di evento e verifica che ogni registrazione abbia ricevuto il segnale previsto. |
| La rimozione esplicita non cambia nulla | Confronta il target, il tipo di evento, l’identità del callback e il valore di capture. |
| La prima azione funziona, ma le successive no | Verifica se il listener usa once: true. |
L’opzione once rimuove un listener dopo la prima esecuzione. Può essere adatta a un’interazione che si verifica davvero una sola volta, ma non rappresenta il ciclo di vita di una vista montata: l’evento potrebbe non verificarsi prima dello smontaggio. MDN descrive sia once sia la rimozione basata su un segnale nel riferimento alle opzioni di addEventListener().
Quando hai finito con la dimostrazione, esegui window.lifecycleDemo.destroy() per scollegare la vista e rimuoverne i listener. Per la tua vista, adotta lo stesso contratto pratico: un’unica inizializzazione attiva, pulizia ripetibile, nuove registrazioni alla riapertura e un test che verifichi sia i controlli visibili sia i target di eventi con una durata maggiore.
Fonti
- DOM Standard — WHATWG.
- EventTarget: metodo addEventListener() — MDN Web Docs.
- EventTarget: metodo removeEventListener() — MDN Web Docs.
- Scorciatoie da tastiera — Chrome for Developers, Google.
- Eseguire JavaScript nella Console — Chrome for Developers, Google.
- Riferimento all’API Console Utilities — Chrome for Developers, Google.