Tema representativo de entrevista

Entrevista Frontend: Explicar la propagación de eventos e implementar la delegación de eventos

FrontendIntermedio
Equipo editorial de Offer.ccPublicado Actualizado

Pregunta

Una lista de tareas dinámica agrega, elimina y anida filas con frecuencia. Cada fila contiene botones de editar, eliminar y alternar estado con íconos anidados. Explique las fases de captura, objetivo y burbujeo de los eventos del DOM, y luego implemente una delegación de eventos confiable con un único listener padre. Distinga correctamente target de currentTarget, delimite el límite de delegación y cubra stopPropagation, eventos que no burbujean, Shadow DOM, limpieza y pruebas.

Enunciado y contexto aplicable

Una lista de tareas puede mostrar miles de filas. Los resultados de paginación, las actualizaciones en vivo y las acciones del usuario insertan o eliminan filas dinámicamente. Cada fila tiene botones para editar, eliminar y alternar estado, y un botón puede contener un ícono o un elemento de texto. La lista también puede contener una lista de subtareas anidada e independiente. Registre solo un listener click en la lista externa y despache una acción utilizando el data-action del control y el data-task-id de su fila.

La respuesta debe explicar cómo viaja un evento desde los ancestros hasta su objetivo y de regreso hacia los ancestros, distinguir event.target de event.currentTarget y evitar que la lista externa maneje acciones que pertenecen a la lista anidada. También debe cubrir los eventos que no pueden usar la delegación por burbujeo ordinaria, el efecto de que un descendiente detenga la propagación, cómo Shadow DOM cambia la ruta visible y cómo se elimina el listener cuando el componente se desmonta.

Esta pregunta es adecuada para entrevistas de frontend de nivel intermedio, plataforma Web y full-stack. Su habilidad central es el despacho de eventos del DOM del navegador y los límites de propiedad en la interfaz de usuario, por lo que pertenece al ámbito de frontend. No evalúa un contenedor pub-sub en el espacio de usuario ni la programación de tareas, microtareas o renderizado.

Qué evalúa el entrevistador

Las respuestas sólidas comienzan con un modelo preciso. El despacho sigue una ruta de evento a través de la fase de captura, la fase de objetivo y, cuando el evento puede propagarse por burbujeo, la fase de burbujeo. addEventListener registra un listener de burbujeo de forma predeterminada; capture: true hace que se ejecute mientras el evento viaja hacia el objetivo. Decir únicamente que "los eventos burbujean hacia arriba" no puede explicar los listeners de captura, los eventos que no burbujean ni los límites de Shadow DOM.

La calidad de la implementación se refleja en la lógica de enrutamiento. target identifica el objetivo del despacho del evento; currentTarget identifica el objeto cuyo listener se está ejecutando actualmente. En un manejador de lista padre, el primero podría ser un ícono dentro de un botón mientras que el segundo es la lista. Una comparación por nombre de etiqueta pasa por alto el marcado anidado. Una respuesta robusta comienza en target, usa closest() para encontrar un control de acción y luego demuestra que el control pertenece a la raíz de delegación activa.

Los límites de pertenencia separan una respuesta básica de una lista para producción. contains() puede rechazar una coincidencia fuera del contenedor, pero el contenedor mismo puede albergar otra raíz con delegación independiente. Exigir que la raíz de delegación más cercana coincida con la lista actual expresa la pertenencia y evita que tanto la lista externa como la anidada ejecuten una misma acción. Los nombres de acción también deben pasar por una lista de permisos en lugar de convertirse en nombres de funciones arbitrarios a partir de cadenas del DOM.

El plan de verificación debe apuntar a fallas observables: marcado anidado, filas agregadas dinámicamente, botones deshabilitados, raíces anidadas, propagación detenida, eventos de foco, Shadow DOM, callbacks asíncronos y limpieza al desmontar. La delegación reduce la cantidad de listeners y cubre naturalmente a los descendientes dinámicos, pero una interfaz pequeña y estable puede ser más clara con listeners directos. La delegación es una decisión de diseño, no una optimización obligatoria.

Preguntas para clarificar antes de responder

  • ¿Qué eventos se están delegando? click normalmente burbujea y se ajusta a este enunciado. Delegar focus, blur, mouseenter o mouseleave requiere captura, una alternativa que burbujee o listeners directos; esa elección cambia tanto la implementación como la semántica.
  • ¿Puede la lista contener componentes anidados o Shadow DOM? contains() suele ser suficiente para una lista plana. La pertenencia anidada necesita un marcador de raíz separado, mientras que Shadow DOM necesita un contrato público de eventos de componente.
  • ¿El marcado del botón es estable? Si el objetivo puede ser un ícono o un nodo de texto, el manejador necesita closest(). matches() es suficiente solo cuando se garantiza que el objetivo es el elemento de acción.
  • ¿Quién puede llamar a stopPropagation()? Si a un componente hijo se le permite detener la propagación, un listener de burbujeo ancestro no puede garantizar ver el evento. Aclare la pertenencia en lugar de mover silenciosamente las acciones de negocio a la fase de captura.
  • ¿Cómo se representa el estado deshabilitado? Los botones nativos usan disabled. Un control personalizado también necesita comportamiento accesible y de teclado; una clase CSS por sí sola no es un contrato confiable para el manejador.
  • ¿Quién administra el ciclo de vida del listener? Una raíz a nivel de página puede ser permanente. Un componente montable debe retener una referencia de función removible o usar AbortSignal; de lo contrario, volver a montar procesará un solo clic varias veces.
  • ¿Una acción necesita cancelar el comportamiento predeterminado? Llame a preventDefault() solo cuando la navegación, el envío de formularios u otra acción predeterminada entre en conflicto con el objetivo del producto. La cancelación predeterminada y el control de propagación son decisiones independientes.

Marco de respuesta de 30 segundos

“Un evento se captura hacia abajo en su ruta, se maneja en el objetivo y burbujea de regreso a través de los ancestros cuando bubbles es verdadero. El listener predeterminado de un padre se ejecuta en la fase de burbujeo, por lo que un solo listener puede cubrir descendientes dinámicos. En el manejador primero verifico que target sea un Element, luego llamo a closest('[data-action]'). Reviso contains y exijo que la raíz de delegación más cercana sea la lista actual para que una lista anidada no filtre acciones hacia afuera. currentTarget es la lista con el listener y solo es confiable mientras el manejador se ejecuta. Despacho a través de una lista de permisos de acciones y limpio con un AbortController. Para eventos que no burbujean, stopPropagation y Shadow DOM, elijo captura, una alternativa con burbujeo, un listener directo o un evento personalizado a nivel de componente según el límite, y luego pruebo objetivos anidados, filas dinámicas y desmontaje/remontaje.”

Análisis paso a paso

Comience con la ruta del evento. Supongamos que un clic comienza en un ícono dentro de un botón. La ruta incluye el documento, la lista externa, la fila de la tarea, el botón y el ícono. Los listeners de captura se ejecutan desde la parte externa de la ruta hacia el objetivo. Los listeners correspondientes al objetivo se ejecutan cuando el evento llega a este. Si bubbles es verdadero, los listeners de burbujeo de los ancestros se ejecutan desde adentro hacia afuera. El algoritmo de despacho construye esta ruta, y tanto la estructura del DOM como los límites de Shadow DOM influyen en ella.

target y currentTarget responden a preguntas distintas. target pregunta dónde se despachó esta interacción y normalmente permanece estable durante el burbujeo ordinario del DOM. currentTarget pregunta qué listener de objeto se está ejecutando en este momento; cambia entre manejadores y se convierte en null después de que el manejador retorna. Guarde cualquier referencia de lista o dato requerido de forma sincrónica. No dependa de event.currentTarget después de un await.

La siguiente implementación separa la coincidencia, la pertenencia, el estado y el despacho de acciones. Asuma que la raíz de la lista tiene data-delegation-root, cada fila tiene data-task-id y las acciones usan botones nativos con data-action. openEditor, deleteTask y toggleTask representan funciones existentes del producto:

javascript
const list = document.querySelector("#task-list");

if (!(list instanceof HTMLElement)) {
  throw new Error("task list was not found");
}

const handlers = new Map([
  ["edit", (taskId) => openEditor(taskId)],
  ["delete", (taskId) => deleteTask(taskId)],
  ["toggle", (taskId) => toggleTask(taskId)],
]);

const controller = new AbortController();

list.addEventListener(
  "click",
  (event) => {
    const target = event.target;
    if (!(target instanceof Element)) return;

    const action = target.closest("[data-action]");
    if (!(action instanceof HTMLButtonElement)) return;
    if (!list.contains(action)) return;
    if (action.closest("[data-delegation-root]") !== list) return;
    if (action.disabled) return;

    const row = action.closest("[data-task-id]");
    if (!(row instanceof HTMLElement) || !list.contains(row)) return;

    const actionName = action.dataset.action;
    const taskId = row.dataset.taskId;
    if (!actionName || !taskId) return;

    const handler = handlers.get(actionName);
    if (!handler) return;

    handler(taskId);
  },
  { signal: controller.signal },
);

function cleanupTaskListDelegation() {
  controller.abort();
}

target.closest() recorre desde el ícono real hasta su botón, de modo que los cambios internos en el marcado no rompen el enrutamiento. list.contains(action) demuestra que la coincidencia todavía está dentro de la lista. La comprobación de la raíz más cercana rechaza luego los botones pertenecientes a una lista anidada. El Map permite solo tres acciones conocidas; un data-action desconocido sale de manera segura. La comprobación explícita de deshabilitado documenta el contrato del manejador, por lo que una acción deshabilitada aún es rechazada si el código usa posteriormente un despacho programático o cambia su marcado.

Los controles de propagación requieren explicaciones separadas. stopPropagation() detiene el recorrido posterior a través de la ruta de captura o burbujeo. No detiene una acción predeterminada ni otros listeners en el mismo elemento; esto último requiere stopImmediatePropagation(). preventDefault() cancela la acción predeterminada de un evento cancelable, pero no detiene la propagación. Tratar estas tres API como equivalentes provoca que los enlaces sigan navegando, que los ancestros pierdan eventos o que los manejadores del mismo nodo continúen ejecutándose inesperadamente.

Elija una estrategia para cada evento que no burbujee. Un ancestro puede observar focus y blur durante la captura, o usar los eventos que burbujean focusin y focusout. mouseenter y mouseleave se pueden enlazar directamente. Reemplazarlos con los eventos que burbujean mouseover y mouseout también requiere filtrado con relatedTarget porque el movimiento entre descendientes genera eventos adicionales. Un elemento con desplazamiento a menudo resulta más claro con un listener directo. Nombres de eventos similares no garantizan una semántica idéntica.

Shadow DOM es un límite de componente; la delegación a nivel de documento no debe depender de selectores dentro de un componente. Los eventos de interfaz del agente de usuario, como click, generalmente están compuestos a través de un límite de sombra, pero un listener externo puede ver un host reasignado como target. composedPath() puede exponer la ruta a través de un shadow root abierto, pero no expone nodos dentro de una raíz cerrada. Un Web Component que necesita publicar una acción debe manejar sus elementos internos por sí mismo y emitir un evento de componente documentado que tenga permitido burbujear y cruzar el límite. Los consumidores dependerán entonces de un contrato público estable.

Verifique el comportamiento en lugar de limitarse a contar listeners. Registre el orden de captura, objetivo y burbujeo; haga clic tanto en el texto del botón como en su ícono; agregue una fila y haga clic en ella sin volver a enlazar; elimine una fila y asegúrese de que su nodo anterior ya no actúe; haga clic en una lista anidada y asegúrese de que solo su propietario la maneje; pruebe acciones deshabilitadas y desconocidas; haga que un hijo detenga la propagación y observe el evento faltante en el ancestro; cubra la estrategia de foco y Shadow DOM; llame a cleanupTaskListDelegation() y demuestre que los clics se detienen, luego monte de nuevo una vez y demuestre que un clic se maneja exactamente una vez.

Respuesta de muestra de alta calidad

“Primero confirmaría que estamos delegando un evento de clic que burbujea. El navegador captura a lo largo de la ruta del evento hacia el objetivo, maneja el objetivo y luego regresa a través de los ancestros cuando se permite el burbujeo. El listener predeterminado de un padre se ejecuta durante el burbujeo. event.target es el objetivo del despacho y puede ser un ícono dentro de un botón; event.currentTarget es la lista con este manejador y es válido solo mientras el manejador se ejecuta.

Empiezo desde el objetivo con closest('[data-action]'), luego verifico que el resultado sea un botón dentro de la lista actual. Dado que esta lista puede contener otro componente delegado, también exijo que el data-delegation-root más cercano a la acción sea esta lista. Encuentro su fila data-task-id y uso una lista de permisos con Map para despachar edit, delete o toggle a funciones existentes. Las filas recién insertadas quedan cubiertas naturalmente por el mismo listener ancestro, y un AbortController lo elimina al desmontar.

No forzaría cada evento a una delegación por burbujeo. Focus puede usar captura o focusin, y mouseenter no tiene la misma semántica que mouseover. Un hijo que llama a stopPropagation impide que el manejador de burbujeo ancestro vea el evento. A través de Shadow DOM, target puede reasignarse y una raíz cerrada no expone su ruta interna, por lo que un componente debe publicar un evento externo estable en lugar de hacer que la página dependa de selectores internos.

Mis pruebas cubren un ícono interno, filas dinámicas, raíces anidadas, acciones deshabilitadas y desconocidas, propagación detenida, Shadow DOM y desmontaje/remontaje. Si solo hay unos pocos botones estables con comportamiento no relacionado, uso listeners directos. El valor principal de la delegación aquí radica en un límite de pertenencia claro, soporte para descendientes dinámicos y un ciclo de vida único, no en una afirmación de rendimiento fuera de contexto.”

Errores comunes

  • Verificar únicamente event.target.matches('button') un clic en un ícono hace que target sea el ícono → recorrer hasta el elemento de acción con closest().
  • Ejecutar la primera coincidencia de closest() puede pertenecer a otro contenedor o raíz anidada → comprobar tanto contains() como la raíz de pertenencia más cercana.
  • Tratar a currentTarget como el elemento clickeado → en la delegación en el padre es la lista con el manejador en ejecución → usar target para el origen y currentTarget para el límite de manejo.
  • Leer event.currentTarget después de await el manejador ha retornado y el valor es nullguardar las referencias y datos de elementos requeridos de forma sincrónica.
  • Asumir que preventDefault() detiene el burbujeo → un comportamiento predeterminado puede cancelarse mientras los ancestros siguen ejecutándose → decidir la cancelación predeterminada y la propagación por separado.
  • Aplicar una plantilla de delegación única a cada evento → focus y mouseenter no burbujean como click → elegir captura, un evento alternativo o enlace directo según la semántica del evento.
  • Inspeccionar los elementos internos de Shadow DOM desde la raíz del documento → target es reasignado y una raíz cerrada oculta su ruta interna → permitir que el componente sea dueño de sus elementos internos y publique un evento estable.
  • Agregar listeners anónimos en cada montaje sin limpieza → un clic se ejecuta repetidamente con estado desactualizado → retener una referencia removible o gestionar el ciclo de vida con AbortSignal.
  • Afirmar que la delegación siempre es más rápida → una interfaz pequeña y estable suma complejidad de enrutamiento y filtrado → elegir en función de la dinámica de los nodos, la pertenencia y el costo de mantenimiento.

Preguntas de seguimiento y respuestas

Pregunta de seguimiento 1: Un botón hijo llama a stopPropagation, pero la delegación externa aún debe funcionar. ¿Qué haces?

Primero decide quién es el dueño de la semántica del clic. Si el hijo no tiene derecho a bloquear el evento, elimina esa llamada y repara el contrato del componente. Un listener de captura puede observar el evento antes de que llegue al botón, pero ejecutar la eliminación u otra acción de negocio durante la captura se ejecutará antes que los manejadores del objetivo y el comportamiento predeterminado, alterando el orden y las oportunidades de cancelación. Usa la captura como toma de control solo cuando el producto lo requiera explícitamente, junto con pruebas de ordenamiento, cancelación y acciones duplicadas.

Pregunta de seguimiento 2: Un formulario necesita un manejo centralizado del foco. ¿Cómo lo delegas?

focus no burbujea, pero un ancestro puede observarlo con capture: true; alternativamente usa el evento que burbujea focusin. Si el objetivo es un único manejador de ayuda para campos, el modelo de focusin suele ser más fácil de razonar. Prueba el foco mediante teclado, puntero y forma programática, y evita reportar cada movimiento dentro de un widget compuesto como si saliera de todo el widget.

Pregunta de seguimiento 3: El botón de acción está dentro de un shadow root cerrado. ¿Cómo lo identifica la lista a nivel de página?

La página no debería identificar un botón dentro de una raíz cerrada. El componente maneja el clic internamente y publica un evento semántico desde su host, conteniendo solo una acción estable y un identificador de tarea en sus datos, junto con un contrato explícito que le permita burbujear y cruzar el límite de sombra. La página delega ese evento del componente. La encapsulación permanece intacta y la página no depende de nodos internos que composedPath() no revelará.

Pregunta de seguimiento 4: El manejador espera una confirmación con await. ¿Cómo evitas actuar en la fila incorrecta tras cambios en el DOM?

Lee y valida de manera sincrónica los valores inmutables taskId y actionName antes de comenzar el flujo asíncrono. Después de la confirmación, busca el estado de negocio actual por taskId; no mantengas una referencia a la fila antigua asumiendo que todavía está conectada. Si los duplicados son un problema, marca también la tarea/acción como pendiente o usa una clave de idempotencia para la solicitud.

Pregunta de seguimiento 5: ¿Cuándo deberías abandonar la delegación?

Los listeners directos son más fáciles de auditar cuando los nodos son pocos y estables, los controles tienen comportamientos no relacionados, el evento no burbujea o el límite de un componente requiere manejo local. Para un evento de alta frecuencia, si una raíz evalúa repetidamente selectores complejos para muchos eventos irrelevantes, acota la raíz o enlaza directamente. Mide la ruta de rendimiento real antes de cambiar la estructura por motivos de rendimiento.

Fuentes públicas

Preguntas relacionadas