Portal Performance
eventos

Filtrar en cada tecla

El buscador filtra por pulsación y repinta con innerHTML sumado. Dos problemas distintos en el mismo listener.

El problema

Tienes un input de búsqueda y una lista que se filtra en tiempo real. Escribes la implementación obvia: escuchas input, filtras, pintas. El resultado se comporta bien con 5 productos, pero llegan 5.000 y en un móvil se nota. Con conexión mala, cada pulsación dispara un fetch entero.

Aquí hay dos problemas en el mismo listener, y merecen tratarse por separado. El primero es que se filtra y se repinta en cada tecla, cuando al usuario le interesa ver el resultado cuando termina de escribir, no mientras. El segundo es cómo se pinta: innerHTML += ... dentro de un bucle es una de las formas más caras de escribir HTML.

El código “roto”

const input = document.getElementById("buscador");
const lista = document.getElementById("resultados");

const productos = ["Teclado", "Ratón", "Monitor", "Webcam", "Altavoz"];

input.addEventListener("input", (e) => {
  const query = e.target.value.toLowerCase();
  lista.innerHTML = "";

  productos
    .filter((p) => p.toLowerCase().includes(query))
    .forEach((p) => {
      lista.innerHTML += "<div>" + p + "</div>";
    });
});

Las pistas

Ver la solución
const input = document.getElementById("buscador");
const lista = document.getElementById("resultados");
const productos = ["Teclado", "Ratón", "Monitor", "Webcam", "Altavoz"];

// timeout vive fuera del listener para poder cancelarlo entre pulsaciones
let timeout;

input.addEventListener("input", (e) => {
  // DEBOUNCE: cancela el filtrado pendiente si el usuario sigue tecleando,
  // así solo se ejecuta una vez cuando para, en lugar de en cada tecla
  clearTimeout(timeout);

  timeout = setTimeout(() => {
    const query = e.target.value.toLowerCase().trim();
    const filtrados = productos.filter((p) => p.toLowerCase().includes(query));
    // Construimos todo el HTML en memoria y lo asignamos UNA SOLA VEZ
    lista.innerHTML = filtrados.map((p) => `<div>${p}</div>`).join("");
  }, 200); // espera 200ms desde la última pulsación antes de filtrar y pintar
});

Dos cambios, dos motivos separados.

El debounce con setTimeout + clearTimeout posterga el trabajo hasta que el usuario deja de teclear. Cada pulsación cancela el temporizador anterior y arma uno nuevo; solo si pasan 200ms sin actividad se ejecuta el filtrado. Por eso timeout tiene que vivir fuera del listener: si estuviera dentro, cada invocación crearía su propia variable y no habría nada que cancelar.

El map(...).join("") construye el HTML completo como string en memoria y lo asigna a innerHTML una sola vez. innerHTML += en un bucle hace algo muy caro: lee el HTML actual del nodo (que el navegador tiene que serializar), lo concatena con el nuevo trozo, y parsea el resultado entero de vuelta. Cinco productos son cinco reparseos completos del contenedor.

Si te preocupa la inyección de HTML (los productos vienen del usuario o de una API que no controlas), la versión con textContent y DocumentFragment es la segura:

input.addEventListener("input", (e) => {
  const query = e.target.value.toLowerCase().trim();

  lista.textContent = ""; // Limpia el contenedor de forma segura

  const fragment = document.createDocumentFragment();

  productos
    .filter((p) => p.toLowerCase().includes(query))
    .forEach((p) => {
      const item = document.createElement("div");
      item.textContent = p; // Seguro ante inyecciones
      fragment.appendChild(item);
    });

  lista.appendChild(fragment); // 1 sola modificación al DOM
});

Sigue siendo una sola inserción al DOM, y ya no hay parseo de HTML en absoluto.

Pruébalo

Filtrar en cada tecla vs debounce

Elige un modo y escribe: solo el modo activo procesa el input. Sube la lista a 1.000 elementos y prueba sin debounce: el propio teclear se engancha porque cada pulsación filtra y repinta coninnerHTML += dentro del bucle. Cambia a con debounce: fluido, y el contador solo sube una vez por pausa.

Sin debounce · innerHTML += en bucle
Filtros: 0 · último: — ms
Con debounce 200ms · map().join("")
Filtros: 0 · último: — ms

Comprueba lo que has aprendido

  1. 1. ¿Qué problema tiene `lista.innerHTML += "<div>" + p + "</div>"` dentro de un bucle?

  2. 2. ¿Qué hace exactamente el patrón de debounce con `setTimeout` y `clearTimeout`?

  3. 3. ¿Por qué la variable `timeout` tiene que vivir fuera del listener?

  4. 4. ¿Cuál es la diferencia entre debounce y throttle?

  5. 5. ¿Por qué `.map(...).join("")` es mejor que `+=` en cada iteración?