Filtrar en cada tecla
El buscador filtra por pulsación y repinta con innerHTML sumado. Dos problemas distintos en el mismo listener.
- debounce
- innerHTML
- DocumentFragment
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
- El usuario no necesita ver el resultado en cada tecla, solo cuando para.
innerHTML +=en un bucle fuerza reparsear todo el nodo en cada iteración.- Construye el string completo en memoria y pinta una sola vez.
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.
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.
Comprueba lo que has aprendido
1. ¿Qué problema tiene `lista.innerHTML += "<div>" + p + "</div>"` dentro de un bucle?
2. ¿Qué hace exactamente el patrón de debounce con `setTimeout` y `clearTimeout`?
3. ¿Por qué la variable `timeout` tiene que vivir fuera del listener?
4. ¿Cuál es la diferencia entre debounce y throttle?
5. ¿Por qué `.map(...).join("")` es mejor que `+=` en cada iteración?