Saltar a contenido

WA2.2 APIs del navegador: observadores, formularios, URL y estado

Cómo se han comprobado los ejemplos

Los editores con vista previa se ejecutan en tu navegador al abrir la página: puedes cambiarlos y ver el resultado. Los fragmentos «ejecutado con Node» (lógica sin document) se han ejecutado de verdad al construir la web y su salida es la real.

Observadores: enterarse de cambios sin preguntar todo el rato

Antes se escuchaba el evento scroll y se calculaba la posición de cada elemento en cada píxel de desplazamiento: lento y con tirones. Los observadores invierten el planteamiento: el navegador avisa cuando ocurre lo que te interesa.

Observador Avisa cuando… Para qué
IntersectionObserver Un elemento entra o sale de la zona visible Carga perezosa, animar al hacer scroll, «scroll infinito»
ResizeObserver Un elemento cambia de tamaño Componentes que se adaptan a su caja, no a la pantalla
MutationObserver El DOM cambia (hijos, atributos, texto) Reaccionar a cambios que hace otro script

threshold: 0.5 significa «avisa cuando se vea la mitad». Con una imagen, el mismo patrón sirve para cargarla solo cuando se acerca a la pantalla: se guarda la dirección en data-src y, al aparecer, se copia a src. (Hoy muchas veces basta con <img loading="lazy">, pero el observador sirve para todo lo demás.)

Formularios: FormData y la API de validación

Leer cada campo con getElementById(...).value no escala. FormData recoge todo el formulario de una vez, respetando los nombres (name) de los campos:

Prueba a enviarlo vacío y luego a rellenarlo bien. Las reglas (required, minlength, type="email", min, max, pattern) están en el HTML, y JavaScript solo las consulta:

API Qué da
form.checkValidity() true si todos los campos cumplen sus reglas
campo.validity.valid / .valueMissing / .typeMismatch… El detalle de qué regla falla
campo.validationMessage El mensaje del navegador (en el idioma del usuario)
campo.setCustomValidity("texto") Una regla propia; con "" se quita el error
new FormData(form) Todos los valores. Object.fromEntries(...) lo convierte en un objeto

El atributo novalidate del <form> desactiva los bocadillos del navegador para que muestres tus mensajes; la validación sigue disponible para consultar.

La URL como estado: URL y URLSearchParams

Los filtros, la página de una lista o el término buscado conviene guardarlos en la URL (?q=kotlin&pagina=2): así se puede compartir el enlace y el botón «atrás» funciona. Las clases URL y URLSearchParams evitan trocear cadenas a mano (y los errores de codificación):

const url = new URL("https://ejemplo.com/buscar?q=kotlin&pagina=2&etiqueta=android&etiqueta=compose#resultados");

console.log(url.pathname, "|", url.hash);
console.log(url.searchParams.get("q"), Number(url.searchParams.get("pagina")));
console.log(url.searchParams.getAll("etiqueta"));          // un parámetro repetido
console.log(url.searchParams.get("no-existe"));            // null

// Construir una URL: los caracteres especiales se codifican solos
const nueva = new URL("https://ejemplo.com/buscar");
nueva.searchParams.set("q", "café con leche & más");
nueva.searchParams.set("pagina", 3);
console.log(nueva.href);

// Convertir los parámetros en un objeto y viceversa
console.log(Object.fromEntries(url.searchParams));           // OJO: con repetidos, se queda con el último
console.log(new URLSearchParams({ q: "a b", n: 1 }).toString());

Salida (ejecutado con Node):

/buscar | #resultados
kotlin 2
[ 'android', 'compose' ]
null
https://ejemplo.com/buscar?q=caf%C3%A9+con+leche+%26+m%C3%A1s&pagina=3
{ q: 'kotlin', pagina: '2', etiqueta: 'compose' }
q=a+b&n=1

Fíjate en cómo "café con leche & más" se codifica sin que escribas nada: el & no rompe los parámetros. Hacerlo a mano con + es una fuente de errores y de agujeros de seguridad.

Cambiar la URL sin recargar: la API History

Cuando el usuario filtra una lista, puedes actualizar la URL sin recargar la página:

// Sin ejecutar: necesita el navegador (y un servidor, no file://)
function guardarFiltro(texto) {
  const url = new URL(location.href);
  url.searchParams.set("q", texto);
  history.pushState({ q: texto }, "", url);          // añade una entrada al historial: «atrás» vuelve al filtro anterior
  // history.replaceState(...) sustituye la entrada actual sin añadir otra (útil mientras se escribe)
}

window.addEventListener("popstate", (e) => {         // el usuario pulsó «atrás» o «adelante»
  const q = new URL(location.href).searchParams.get("q") ?? "";
  pintar(q);
});

Estado → vista: la idea detrás de React y Vue

Cuando una página crece, actualizar el DOM «a trocitos» (cambia un texto aquí, un elemento allá) se enreda enseguida. El patrón que resuelve esto es el mismo que viste en Android (AA1): todo lo que se ve se calcula a partir de un único objeto de estado, con una función pura.

La parte sin DOM, la función que convierte estado en HTML, es lógica normal y se puede ejecutar y comprobar:

const escapar = s => String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));

// La vista: una función PURA. Mismo estado, mismo HTML. No toca el DOM.
function vista(estado) {
  const visibles = estado.tareas.filter(t =>
    estado.filtro === "todas" ? true : estado.filtro === "hechas" ? t.hecha : !t.hecha);
  const pendientes = estado.tareas.filter(t => !t.hecha).length;
  return [
    `<p>${pendientes} pendientes</p>`,
    "<ul>",
    ...visibles.map(t => `  <li class="${t.hecha ? "hecha" : ""}">${escapar(t.texto)}</li>`),
    "</ul>"
  ].join("\n");
}

// El «reductor»: (estado, acción) -> estado nuevo, sin modificar el anterior
function reducir(estado, accion) {
  switch (accion.tipo) {
    case "añadir": return { ...estado, tareas: [...estado.tareas, { id: estado.tareas.length + 1, texto: accion.texto, hecha: false }] };
    case "marcar": return { ...estado, tareas: estado.tareas.map(t => t.id === accion.id ? { ...t, hecha: !t.hecha } : t) };
    case "filtrar": return { ...estado, filtro: accion.filtro };
    default: return estado;
  }
}

let estado = { tareas: [], filtro: "todas" };
for (const accion of [
  { tipo: "añadir", texto: "Estudiar <JS>" },
  { tipo: "añadir", texto: "Hacer la compra" },
  { tipo: "marcar", id: 1 },
  { tipo: "filtrar", filtro: "hechas" }
]) {
  estado = reducir(estado, accion);
}
console.log(vista(estado));

Salida (ejecutado con Node):

<p>1 pendientes</p>
<ul>
  <li class="hecha">Estudiar &lt;JS&gt;</li>
</ul>

La versión con DOM es la misma lógica más dos líneas: cada vez que cambia el estado, se vuelve a pintar. Aquí, funcionando en vivo:

Repintar todo el DOM a cada cambio es lo más simple y vale para páginas pequeñas (fíjate en que escapar evita el XSS al usar innerHTML). Librerías como React, Vue o Svelte hacen exactamente esto, pero comparando lo anterior con lo nuevo y tocando solo lo que cambió.

Guardar datos que cambian de forma: versiones en localStorage

localStorage guarda texto, para siempre, en el navegador de cada persona. Si cambias la forma de tus datos en una versión nueva de la página, los usuarios que ya tenían datos viejos guardados romperán la página. La solución es guardar un número de versión y migrar al leer:

// Simulamos localStorage para ejecutarlo en Node
const almacen = new Map();
const ls = { getItem: k => almacen.get(k) ?? null, setItem: (k, v) => almacen.set(k, String(v)) };

const VERSION = 2;

function migrar(datos) {
  let d = datos;
  if (d.version === 1) {                                   // v1: { nombre }  ->  v2: { nombre, apellidos, tema }
    d = { version: 2, nombre: d.nombre, apellidos: "", tema: "claro" };
  }
  return d;
}

function cargar() {
  const texto = ls.getItem("perfil");
  if (texto === null) return { version: VERSION, nombre: "", apellidos: "", tema: "claro" };
  try {
    return migrar(JSON.parse(texto));
  } catch {
    return { version: VERSION, nombre: "", apellidos: "", tema: "claro" };    // datos corruptos: empezar de cero
  }
}

ls.setItem("perfil", JSON.stringify({ version: 1, nombre: "Ana" }));   // lo que guardó la versión antigua
console.log("migrado:", cargar());

ls.setItem("perfil", "{esto no es JSON");
console.log("corrupto:", cargar());

Salida (ejecutado con Node):

migrado: { version: 2, nombre: 'Ana', apellidos: '', tema: 'claro' }
corrupto: { version: 2, nombre: '', apellidos: '', tema: 'claro' }

Dos reglas para guardar datos en el navegador:

  • Siempre try/catch al leer: el usuario (o una extensión) pudo modificar el valor.
  • Nunca guardes contraseñas, tokens ni datos sensibles en localStorage: cualquier script de la página puede leerlos.

Errores frecuentes

Error Cómo evitarlo
Escuchar scroll y calcular posiciones a cada píxel IntersectionObserver
Leer cada campo del formulario a mano new FormData(form) y Object.fromEntries
Validar con expresiones propias lo que el HTML ya valida required, type, min, pattern + checkValidity()
Construir URLs concatenando cadenas URL y URLSearchParams
Modificar el DOM «a trocitos» en una página con mucho estado Un objeto de estado y una función vista(estado)
innerHTML con texto que viene del usuario Escapar o usar textContent
Leer JSON.parse(localStorage...) sin try/catch ni versión Guardar una versión y migrar

Para practicar

Los ejercicios WA2.1, WA2.2 y WA2.4 de WA2 · Ejercicios practican URLSearchParams y la migración de datos.