Asigna a cada vista de JavaScript montada un AbortController nuevo, registra sus escuchas con la señal de ese controlador y abórtalo durante el desmontaje. Este tutorial crea una pequeña vista en el navegador y muestra cómo comprobar ciclos de montaje repetidos en Chrome en un Mac.

¿Por qué se acumulan los controladores de eventos al volver a abrir una vista?

Una vista puede parecer correcta al abrirla por primera vez y empezar a responder dos veces después de volver a abrirla. Una causa habitual es que el código de configuración crea otra función de devolución de llamada en cada montaje sin eliminar el registro anterior. Las funciones creadas por separado son objetos de escucha distintos, aunque su texto fuente sea idéntico. La documentación de addEventListener() de MDN explica esta regla sobre la identidad de las funciones de devolución de llamada.

El DOM impide los registros duplicados con el mismo tipo de evento, función de devolución de llamada y valor de captura en el mismo destino. Esa protección no resuelve los montajes repetidos cuando cada configuración crea una función de devolución de llamada nueva. El estándar también especifica que abortar la señal proporcionada a un escucha lo elimina. Estas reglas permiten un diseño práctico: un controlador administra todos los escuchas de eventos nativos de un intervalo de montaje. Estándar DOM: EventTarget.

En este tutorial, la tarea del lector consiste en abrir, cerrar y volver a abrir una vista de contador en JavaScript puro sin añadir controladores adicionales para los botones o la ventana. El ejemplo usa etiquetas sintéticas, no requiere ningún framework y conserva los contadores entre montajes.

¿Qué contrato de ciclo de vida debe seguir la vista?

Decide qué significan las llamadas repetidas al ciclo de vida antes de implementar la configuración. El siguiente contrato hace que las llamadas repetidas accidentales sean inocuas y asigna claramente la responsabilidad de la limpieza.

Operación Comportamiento requerido
Primer mount() Adjuntar la vista y registrar un conjunto de escuchas.
Otro mount() mientras está montada No hacer nada.
unmount() Eliminar los escuchas del intervalo de montaje y luego separar la vista.
Otro unmount() No hacer nada.
mount() después del desmontaje Registrar un conjunto nuevo con un controlador nuevo; conservar los contadores.
destroy() Desmontar de forma permanente; los montajes posteriores generan un error.

Estas son decisiones de diseño de la aplicación. El navegador proporciona el registro y la eliminación de eventos; tu vista define los métodos del ciclo de vida y su orden. En una aplicación más grande, el componente o enrutador que llama a mount() también debe organizar la llamada correspondiente a unmount().

Es importante usar un controlador nuevo porque una señal abortada no se puede volver a activar. Cuando addEventListener() recibe una señal ya abortada, el algoritmo de registro del DOM no añade el escucha. Por tanto, reutilizar el controlador anterior puede hacer que los controles de una vista reabierta no respondan. Estándar DOM: añadir un escucha de eventos.

Crea un ejemplo completo en Chrome en tu Mac

Abre una pestaña nueva de Chrome, escribe about:blank en la barra de direcciones y pulsa Return. Con la página enfocada, pulsa Command + Option + J para abrir la Console. Google documenta este atajo para Mac en su referencia de métodos abreviados de teclado de Chrome DevTools.

Ejecuta el programa completo que aparece a continuación en la Console. Crea la interfaz mediante JavaScript, así que no se necesitan marcado del documento, instalación de paquetes ni pasos de compilación. La consola de Chrome puede evaluar JavaScript y modificar el DOM de la página actual, como se muestra en el tutorial de Google sobre cómo ejecutar JavaScript en la consola.

(() => {
  // 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 página ahora contiene un botón Add one y una línea con el contador. Haz clic en el botón: el contador de clics debería aumentar en uno. Cambia el tamaño de la ventana de Chrome: el contador de eventos de cambio de tamaño debería cambiar. Un cambio físico de tamaño puede generar varios eventos, así que ese contador mide eventos y no gestos del ratón.

El escucha del botón y el de la ventana comparten la misma señal. El desmontaje aborta ese controlador antes de separar el elemento raíz. El siguiente montaje crea otro controlador y otro conjunto de funciones de devolución de llamada. Los contadores permanecen en el cierre léxico de la vista, por lo que al volver a abrirla se conservan sus valores; destruir y volver a ejecutar el programa completo inicia una instancia nueva.

El bloque de configuración también gestiona un fallo parcial: si la configuración genera un error después de registrar un escucha, el bloque catch aborta el controlador nuevo, separa el elemento raíz y vuelve a generar el error. Así se evita que un montaje fallido deje parte de la configuración activa sin avisar.

¿Cómo se comprueba la limpieza tras varios montajes?

Primero, prueba el ciclo de vida manualmente. Ejecuta la siguiente secuencia después de instalar el programa principal. La vista desaparece, los desmontajes repetidos no tienen ningún efecto adicional y, al volver a abrirla, aparece con los contadores anteriores.

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

Luego, ejecuta esta comprobación completa en la misma consola. Depende del programa principal anterior. Cada ciclo llama deliberadamente a la configuración y a la limpieza dos veces, despacha un evento por destino mientras la vista está montada y despacha los mismos eventos mientras está desmontada. Si hay una discrepancia, se genera un error de inmediato.

(() => {
  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.");
})();

El evento sintético de cambio de tamaño hace que el cambio esperado sea determinista, sin mover la ventana. La comprobación del botón separado resulta útil porque este ejemplo lo conserva deliberadamente para volver a montarlo. Ambas comprobaciones deben pasar: probar solo el botón visible dejaría sin examinar el escucha de la ventana. La prueba suma 20 a cada contador y termina con la vista montada.

Para inspeccionarlos directamente, Chrome ofrece getEventListeners(object) en la consola de DevTools. Devuelve los escuchas registrados en el objeto especificado, agrupados por tipo de evento. Es una utilidad de DevTools, así que no la incluyas en el código de la aplicación. Referencia de la API de utilidades de la consola de Chrome.

Después de ejecutar el programa principal, esta secuencia de inspección compara los registros antes y después de la limpieza. En la página en blanco de demostración, espera un escucha de clic en el botón y un escucha de cambio de tamaño en la ventana mientras la vista está montada, y luego cero de cada uno mientras está desmontada.

(() => {
  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();
})();

En una página de una aplicación existente, puede que otros fragmentos de código administren escuchas de la ventana. Compara las diferencias entre antes y después e inspecciona las funciones de devolución de llamada en lugar de suponer que el total debe llegar a cero.

¿Cuándo conviene usar la eliminación explícita?

Usa removeEventListener() al integrar código existente que ya almacena funciones de devolución de llamada o cuando el entorno requerido no admite la opción signal del escucha. Para la limpieza, conserva el destino original, el tipo de evento, la referencia a la función de devolución de llamada y el valor de captura. MDN documenta que el valor de captura debe coincidir, mientras que opciones como passive no determinan cómo se emparejan los escuchas para eliminarlos. Referencia de removeEventListener() de MDN.

Por ejemplo, si la configuración usa bind(), almacena una vez la función de devolución de llamada enlazada. Si vuelves a llamar a bind() durante la limpieza, obtienes una referencia de función distinta. Del mismo modo, reescribir la misma función flecha en la llamada de eliminación no recupera la función registrada anteriormente. Mantén la limpieza explícita junto a la configuración para que ambas evolucionen en conjunto.

El patrón del controlador cubre los escuchas nativos registrados con su señal. Si una vista también crea temporizadores, observadores o suscripciones de bibliotecas, asigna sus operaciones de limpieza documentadas al mismo límite de desmontaje. Pasar una señal a los escuchas nativos no establece la limpieza de recursos no relacionados.

¿Qué debes comprobar si las funciones de devolución de llamada siguen multiplicándose?

Usa el síntoma para acotar la investigación en lugar de cambiar varios métodos del ciclo de vida a la vez.

Síntoma Qué comprobar a continuación
Un clic incrementa el contador dos veces Busca otra vía de registro o una segunda instancia activa de la vista.
Los controles dejan de responder al volver a abrir la vista Comprueba si la configuración reutilizó una señal abortada.
Los contadores cambian después del desmontaje Comprueba todos los destinos de eventos y si cada registro recibió la señal prevista.
La eliminación explícita no cambia nada Compara el destino, el tipo de evento, la identidad de la función de devolución de llamada y el valor de captura.
La primera acción funciona, pero las siguientes no Comprueba si el escucha usa once: true.

La opción once elimina un escucha después de su primera invocación. Puede ser adecuada para una interacción que realmente se use una sola vez, pero no representa la duración de montaje de una vista: es posible que el evento no ocurra antes del desmontaje. MDN describe tanto once como la eliminación basada en señales en su referencia de opciones de addEventListener().

Cuando termines la demostración, ejecuta window.lifecycleDemo.destroy() para separar la vista y eliminar sus escuchas. Para tu propia vista, sigue el mismo contrato práctico: una configuración activa, una limpieza repetible, registros nuevos al volver a abrirla y una prueba que ejercite tanto los controles visibles como los destinos de eventos de mayor duración.

Fuentes