Módulo 18: APIs del navegador

Objetivos del módulo

  • Guardar datos con localStorage, sessionStorage, cookies e IndexedDB, y saber qué no guardar
  • Usar geolocalización, portapapeles, notificaciones y la gestión de permisos
  • Reaccionar a cambios con Intersection Observer, Resize Observer y Mutation Observer
  • Crear un enrutado sencillo para una SPA con la History API o con el hash
  • Detectar si la pestaña está visible y consultar media queries desde JavaScript
  • Ejecutar tareas pesadas sin bloquear la interfaz con Web Workers y conocer los Service Workers y las PWA
  • Dibujar y animar con Canvas 2D y crear tus propias etiquetas con Web Components
  • Comprobar el soporte de una funcionalidad (feature detection) y entender Baseline

1. El navegador es una plataforma

📘 Concepto: El lenguaje JavaScript (ECMAScript) solo define lo básico: variables, funciones, objetos, promesas… Todo lo demás que usas en una página (document, fetch, localStorage, navigator.geolocation…) son Web APIs: funcionalidades que el navegador pone a disposición de JavaScript. Las definen organismos como el W3C y el WHATWG, y cada navegador las implementa.

Piensa en JavaScript como en un conductor y en el navegador como en un coche: el conductor sabe conducir (el lenguaje), pero lo que puede hacer depende de los mandos del coche (las APIs): luces, GPS, radio… Por eso, las APIs de este módulo no existen en Node.js (no hay document ni localStorage en el servidor) y algunas dependen de los permisos que conceda el usuario.

Contexto seguro (HTTPS)

Muchas APIs “potentes” (geolocalización, portapapeles, notificaciones, Service Workers…) solo funcionan en un contexto seguro: páginas servidas por https:// o desde http://localhost. Puedes comprobarlo con window.isSecureContext.

💡 Consejo: Para practicar este módulo no abras los ficheros con doble clic (file://): usa un servidor local, por ejemplo la extensión Live Server de VS Code o npx vite (lo verás en el módulo 20). Algunas APIs, como los Web Workers con módulos, no funcionan desde file://.


2. Web Storage: localStorage y sessionStorage

📘 Concepto: Web Storage es un almacén muy sencillo de pares clave → valor (ambos siempre de tipo texto) que el navegador guarda para cada origen (protocolo + dominio + puerto). Es síncrono y fácil de usar, ideal para preferencias y datos pequeños.

Dos almacenes con la misma API localStorage sessionStorage
Duración Permanente (hasta que se borre) Mientras dure la pestaña (sobrevive a recargar)
Compartido entre pestañas Sí, entre todas las del mismo origen No, cada pestaña tiene el suyo
Uso típico Tema, idioma, favoritos, borradores Pasos de un asistente, filtros temporales

API básica

// Guardar (la clave y el valor se convierten a texto)
localStorage.setItem('tema', 'oscuro');
localStorage.setItem('visitas', 5); // se guarda como '5'

// Leer
console.log(localStorage.getItem('tema'));     // 'oscuro'
console.log(localStorage.getItem('visitas'));  // '5' (¡texto, no número!)
console.log(localStorage.getItem('noExiste')); // null
const visitas = Number(localStorage.getItem('visitas') ?? 0) + 1; // convierte al leer

// Número de elementos, nombre de la clave en una posición y borrado
console.log(localStorage.length, localStorage.key(0));
localStorage.removeItem('visitas');
localStorage.clear(); // borra todo el almacén del origen

⚠️ Importante: Todo se guarda como cadena. Si guardas el número 5, recuperarás '5'; si guardas true, recuperarás 'true'; y si guardas un objeto directamente obtendrás el inútil '[object Object]'.

Guardar objetos y arrays con JSON

Para guardar estructuras, conviértelas a texto con JSON.stringify y recupéralas con JSON.parse:

const preferencias = { tema: 'oscuro', idioma: 'es', tamañoLetra: 16 };
localStorage.setItem('preferencias', JSON.stringify(preferencias));
const recuperadas = JSON.parse(localStorage.getItem('preferencias'));
console.log(recuperadas.tamañoLetra); // 16 (número de nuevo)

Como getItem puede devolver null y el contenido puede estar corrupto (lo puede editar el usuario desde DevTools), es buena práctica encapsularlo en un pequeño módulo:

// almacen.js — si no existe o está corrupto, devuelve el valor por defecto
export function leer(clave, porDefecto = null) {
  try {
    const texto = localStorage.getItem(clave);
    return texto === null ? porDefecto : JSON.parse(texto);
  } catch {
    return porDefecto; // JSON inválido o almacenamiento bloqueado
  }
}

// Guarda cualquier valor serializable. Devuelve false si no cabe
export function guardar(clave, valor) {
  try {
    localStorage.setItem(clave, JSON.stringify(valor));
    return true;
  } catch (error) {
    if (error.name === 'QuotaExceededError') console.warn('No queda espacio en localStorage');
    return false;
  }
}
import { leer, guardar } from './almacen.js';
const favoritos = leer('favoritos', []);
guardar('favoritos', [...favoritos, { id: 42, titulo: 'El Quijote' }]);

⚠️ Importante: JSON no conserva todos los tipos. Un Date se convierte en texto ISO (tendrás que hacer new Date(texto) al leerlo), y Map, Set, undefined, funciones o clases se pierden. Guarda datos “planos”.

Límites

  • Unos 5 MB por origen (varía según el navegador). Si te pasas, setItem lanza un error con nombre QuotaExceededError.
  • Es síncrono: cada operación bloquea el hilo principal. Con miles de registros se nota.
  • Solo texto: nada de ficheros ni binarios. Y no existe en Web Workers ni en Service Workers.
  • Se puede perder: el usuario puede borrar los datos y Safari elimina el almacenamiento de los sitios con los que no se interactúa en unos días.

💡 Consejo: Puedes ver y editar el contenido en DevTools → pestaña Application (Chrome/Edge) o Almacenamiento (Firefox) → Local Storage.

El evento storage: sincronizar pestañas

Cuando una pestaña modifica localStorage, las demás pestañas del mismo origen reciben un evento storage (la pestaña que hizo el cambio no lo recibe).

// Si el usuario cambia el tema en una pestaña, el resto se actualiza
window.addEventListener('storage', (evento) => {
  if (evento.key === 'tema') {
    console.log(`Tema cambiado de ${evento.oldValue} a ${evento.newValue}`);
    document.documentElement.dataset.tema = evento.newValue;
  }
  if (evento.key === null) console.log('Alguien ha ejecutado localStorage.clear()');
});

✏️ Ejercicio: Abre la misma página en dos pestañas, cambia el tema en una y comprueba que la otra cambia sola.

Qué NO guardar en Web Storage

Cualquier script que se ejecute en tu página (incluido uno malicioso inyectado mediante un ataque XSS) puede leer todo localStorage. Por eso:

No guardes Por qué Alternativa
Contraseñas Cualquier script puede leerlas No se guardan nunca en el cliente
Tokens de sesión de larga duración Robo de sesión si hay XSS Cookie HttpOnly + Secure + SameSite puesta por el servidor
Datos personales sensibles (salud, DNI, tarjetas) Quedan en el disco sin cifrar Mantenerlos en el servidor
Muchos datos o datos que no se pueden perder Límite de 5 MB y el usuario puede borrarlos IndexedDB o servidor

3. Cookies desde JavaScript (y por qué preferir otras opciones)

📘 Concepto: Una cookie es un pequeño texto (unos 4 KB como máximo) que el navegador guarda y envía automáticamente al servidor en cada petición a ese dominio. Nacieron para que el servidor “recuerde” al usuario (sesiones), no como almacén para JavaScript.

Desde JavaScript se accede con document.cookie, una API muy antigua y extraña:

// Escribir: cada asignación AÑADE o MODIFICA una cookie (no borra las demás)
document.cookie = 'tema=oscuro; max-age=31536000; path=/; SameSite=Lax; Secure';
document.cookie = `usuario=${encodeURIComponent('Ana López')}; max-age=3600; path=/`;

// Leer: devuelve TODAS las cookies en una sola cadena "a=1; b=2"
console.log(document.cookie); // 'tema=oscuro; usuario=Ana%20L%C3%B3pez'

// Función para leer una cookie concreta
function leerCookie(nombre) {
  const par = document.cookie.split('; ').find((fila) => fila.startsWith(`${nombre}=`));
  return par ? decodeURIComponent(par.split('=')[1]) : null;
}
console.log(leerCookie('usuario')); // 'Ana López'

// Borrar: se pone una caducidad en el pasado (o max-age=0)
document.cookie = 'tema=; max-age=0; path=/';
Atributo Significado
max-age=segundos / expires=fecha Caducidad. Sin ellos, la cookie dura lo que la sesión del navegador
path=/ Rutas del sitio que reciben la cookie
Secure Solo se envía por HTTPS
SameSite=Lax / Strict / None Controla si se envía en peticiones desde otros sitios (protección CSRF)
HttpOnly JavaScript no puede leerla. Solo la puede poner el servidor

¿Por qué preferir otras opciones?

Las cookies viajan en cada petición HTTP (desperdicias ancho de banda con datos que solo usa el cliente), son diminutas, su API obliga a parsear cadenas a mano y, si JavaScript puede leerlas, un ataque XSS también. Regla práctica: las cookies son cosa del servidor (sesiones con HttpOnly); para datos del cliente usa localStorage (poco) o IndexedDB (mucho).

💡 Consejo: Existe una API moderna y asíncrona, la Cookie Store API (await cookieStore.get('tema'), await cookieStore.set('tema', 'oscuro')), disponible ya en los principales navegadores desde 2025. Si de verdad necesitas manipular cookies desde JavaScript, es mucho más cómoda que document.cookie.


4. IndexedDB: una base de datos en el navegador

📘 Concepto: IndexedDB es una base de datos NoSQL integrada en el navegador. Guarda objetos JavaScript (no solo texto), incluidos Date, Blob o ficheros; admite índices para buscar rápido; es asíncrona (no bloquea la interfaz) y puede almacenar cientos de MB (el límite depende del espacio libre en disco).

Si localStorage es un cajón donde guardas notas sueltas, IndexedDB es un archivador con carpetas, fichas y un índice alfabético. Si conoces las bases de datos relacionales: cada base de datos tiene un número de versión, los object stores son las tablas, cada objeto guardado es una fila, el keyPath es la clave primaria, y todo se hace dentro de transacciones (readonly o readwrite).

Ejemplo básico envuelto en promesas

La API original de IndexedDB es de la época anterior a las promesas y funciona con eventos (onsuccess, onerror). Lo más cómodo es envolverla una sola vez:

// db.js — pequeña capa de promesas sobre IndexedDB
// Convierte una IDBRequest (onsuccess/onerror) en una promesa
function promesa(peticion) {
  return new Promise((resolve, reject) => {
    peticion.onsuccess = () => resolve(peticion.result);
    peticion.onerror = () => reject(peticion.error);
  });
}

export function abrirBD() {
  const peticion = indexedDB.open('agenda', 1); // nombre y versión
  // Solo se ejecuta la primera vez o cuando sube la versión: aquí se crea el "esquema"
  peticion.onupgradeneeded = () => {
    const contactos = peticion.result.createObjectStore('contactos', {
      keyPath: 'id',
      autoIncrement: true,
    });
    contactos.createIndex('porCiudad', 'ciudad');
  };
  return promesa(peticion);
}

export async function añadirContacto(contacto) {
  const bd = await abrirBD();
  const tx = bd.transaction('contactos', 'readwrite');
  return promesa(tx.objectStore('contactos').add(contacto)); // devuelve la clave generada
}

export async function contactosDeCiudad(ciudad) {
  const bd = await abrirBD();
  const indice = bd.transaction('contactos').objectStore('contactos').index('porCiudad');
  return promesa(indice.getAll(ciudad));
}
// main.js
import { añadirContacto, contactosDeCiudad } from './db.js';

await añadirContacto({ nombre: 'Ana', ciudad: 'Sevilla', alta: new Date() });
await añadirContacto({ nombre: 'Luis', ciudad: 'Bilbao', alta: new Date() });
console.log(await contactosDeCiudad('Bilbao'));
// [{ nombre: 'Luis', ciudad: 'Bilbao', alta: Date, id: 2 }]  ← el Date se conserva como Date

⚠️ Importante: Una transacción se cierra sola en cuanto no tiene peticiones pendientes. No hagas await de algo ajeno a IndexedDB (por ejemplo, un fetch) en mitad de una transacción: cuando vuelvas, estará cerrada y obtendrás un error TransactionInactiveError. Primero obtén los datos de red y después abre la transacción.

La librería idb

La librería idb (de Jake Archibald, apenas 1 KB) hace exactamente lo anterior por ti: envuelve IndexedDB en promesas con una API mucho más limpia. Es la opción recomendada en proyectos reales.

import { openDB } from 'idb'; // tras: npm install idb
// Sin npm: import { openDB } from 'https://cdn.jsdelivr.net/npm/idb@8/+esm';

const bd = await openDB('agenda', 1, {
  upgrade(bd) {
    const almacen = bd.createObjectStore('contactos', { keyPath: 'id', autoIncrement: true });
    almacen.createIndex('porCiudad', 'ciudad');
  },
});

await bd.add('contactos', { nombre: 'Ana', ciudad: 'Sevilla' });
const todos = await bd.getAll('contactos');
const deBilbao = await bd.getAllFromIndex('contactos', 'porCiudad', 'Bilbao');
await bd.delete('contactos', 1);
Necesidad Herramienta
Preferencias y pocos datos (< 100 KB) localStorage (o sessionStorage si son de una pestaña)
Muchos registros, búsquedas, ficheros, uso sin conexión IndexedDB (con idb)
Sesión del usuario Cookie HttpOnly puesta por el servidor
Respuestas HTTP para funcionar sin conexión Cache API (Service Workers)

💡 Consejo: Puedes consultar cuánto espacio usas con await navigator.storage.estimate() (devuelve usage y quota en bytes) y pedir al navegador que no borre tus datos con await navigator.storage.persist().


5. Geolocation API

📘 Concepto: La Geolocation API permite obtener la posición del dispositivo (GPS, Wi-Fi, red móvil…). Siempre requiere permiso explícito del usuario y un contexto seguro.

// HTML: <button id="localizar">Mostrar mi ubicación</button> <p id="resultado" aria-live="polite"></p>
const resultado = document.querySelector('#resultado');

// Convierte la API de callbacks en una promesa
function obtenerPosicion(opciones) {
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(resolve, reject, opciones);
  });
}

document.querySelector('#localizar').addEventListener('click', async () => {
  if (!('geolocation' in navigator)) {
    resultado.textContent = 'Tu navegador no soporta geolocalización';
    return;
  }
  resultado.textContent = 'Buscando...';
  try {
    const { coords } = await obtenerPosicion({
      enableHighAccuracy: true, // usa GPS si puede (más lento y gasta más batería)
      timeout: 10_000,          // máximo 10 s de espera
      maximumAge: 60_000,       // acepta una posición de hace menos de 1 minuto
    });
    const { latitude, longitude, accuracy } = coords;
    resultado.textContent = `${latitude.toFixed(5)}, ${longitude.toFixed(5)} (±${Math.round(accuracy)} m)`;
  } catch (error) {
    // error.code: 1 = PERMISSION_DENIED, 2 = POSITION_UNAVAILABLE, 3 = TIMEOUT
    const mensajes = { 1: 'Has denegado el permiso', 2: 'Ubicación no disponible', 3: 'Tiempo agotado' };
    resultado.textContent = mensajes[error.code] ?? error.message;
  }
});

Para seguir la posición en tiempo real (una app de rutas, por ejemplo) usa watchPosition, que llama a tu función cada vez que cambia:

const idSeguimiento = navigator.geolocation.watchPosition(
  ({ coords }) => console.log(coords.latitude, coords.longitude, coords.speed),
  (error) => console.error(error.message),
);
navigator.geolocation.clearWatch(idSeguimiento); // cuando ya no lo necesites (ahorra batería)

⚠️ Importante: Pide la ubicación como respuesta a una acción del usuario (un clic en un botón) y explica para qué la quieres. Si la pides nada más cargar la página, la mayoría de usuarios la deniega y muchos navegadores penalizan esas peticiones.


6. Los observers: reaccionar a cambios sin vigilar a mano

Antiguamente, para saber si un elemento era visible se escuchaba el evento scroll y se calculaban posiciones decenas de veces por segundo, lo que hacía la página lenta. Los observers delegan ese trabajo en el navegador, que te avisa solo cuando ocurre algo. Todos siguen el mismo patrón: new XObserver(callback, opciones), observe(elemento), unobserve(elemento) y disconnect().

Observer Te avisa cuando… Usos típicos
IntersectionObserver Un elemento entra o sale del área visible Lazy loading, animaciones al hacer scroll, scroll infinito, estadísticas de visualización
ResizeObserver Un elemento cambia de tamaño Gráficos que se adaptan, componentes responsive
MutationObserver Cambia el DOM (hijos, atributos, texto) Reaccionar a contenido que inserta otro script

Intersection Observer

const observador = new IntersectionObserver((entradas) => {
  for (const entrada of entradas) {
    console.log(entrada.target.id, entrada.isIntersecting, entrada.intersectionRatio);
  }
}, {
  root: null,                       // null = la ventana del navegador
  rootMargin: '0px 0px 200px 0px',  // amplía el área 200 px por abajo (avisa "antes")
  threshold: 0.5,                   // avisa cuando se vea el 50 % del elemento
});
observador.observe(document.querySelector('#seccion-precios'));

Caso 1: carga diferida de imágenes (lazy loading)

💡 Consejo: Para imágenes normales basta con HTML: <img src="foto.jpg" loading="lazy" alt="...">. Usa JavaScript cuando necesites más control (fondos, vídeos, componentes pesados, un margen de precarga concreto).

// HTML: <img data-src="foto-grande.jpg" alt="Paisaje" class="diferida" width="800" height="600">
const observadorImagenes = new IntersectionObserver((entradas, observador) => {
  for (const entrada of entradas) {
    if (!entrada.isIntersecting) continue;
    const img = entrada.target;
    img.src = img.dataset.src;      // ahora sí descarga la imagen
    img.classList.remove('diferida');
    observador.unobserve(img);      // ya no hace falta vigilarla
  }
}, { rootMargin: '300px' });        // empieza a cargar 300 px antes de verse
document.querySelectorAll('img.diferida').forEach((img) => observadorImagenes.observe(img));

Caso 2: animaciones al hacer scroll

<style>
  .revelar { opacity: 0; translate: 0 2rem; transition: opacity 0.6s, translate 0.6s; }
  .revelar.visible { opacity: 1; translate: 0 0; }
  @media (prefers-reduced-motion: reduce) { .revelar { transition: none; } }
</style>
<section class="revelar">...</section>
<section class="revelar">...</section>
<script type="module">
  const observador = new IntersectionObserver((entradas) => {
    // Aparece al entrar y se oculta al salir (con unobserve, solo animaría una vez)
    entradas.forEach((e) => e.target.classList.toggle('visible', e.isIntersecting));
  }, { threshold: 0.2 });
  document.querySelectorAll('.revelar').forEach((el) => observador.observe(el));
</script>

Caso 3: scroll infinito

La idea es colocar un elemento “centinela” al final de la lista: cuando se ve, cargas la siguiente página.

// HTML: <ul id="lista"></ul> <p id="centinela" aria-live="polite">Cargando...</p>
const lista = document.querySelector('#lista');
const centinela = document.querySelector('#centinela');
let pagina = 1;
let cargando = false;

async function cargarPagina() {
  if (cargando) return; // evita peticiones duplicadas
  cargando = true;
  try {
    const url = `https://jsonplaceholder.typicode.com/posts?_page=${pagina}&_limit=10`;
    const respuesta = await fetch(url);
    if (!respuesta.ok) throw new Error(`HTTP ${respuesta.status}`);
    const posts = await respuesta.json();
    if (posts.length === 0) {
      observador.disconnect(); // ya no hay más páginas
      centinela.textContent = 'No hay más publicaciones';
      return;
    }
    lista.append(...posts.map((post) => {
      const li = document.createElement('li');
      li.textContent = `${post.id}. ${post.title}`;
      return li;
    }));
    pagina++;
  } catch (error) {
    centinela.textContent = `Error: ${error.message}`;
  } finally {
    cargando = false;
  }
}

const observador = new IntersectionObserver((entradas) => {
  if (entradas[0].isIntersecting) cargarPagina();
}, { rootMargin: '400px' });
observador.observe(centinela);

⚠️ Importante: El scroll infinito puede ser un problema de accesibilidad (nunca se llega al pie de página y se pierde la posición al volver). Valora ofrecer también un botón “Cargar más”.

Resize Observer

Te avisa cuando cambia el tamaño de un elemento (no de la ventana), por ejemplo porque se ha abierto una barra lateral:

const observadorTamaño = new ResizeObserver((entradas) => {
  for (const entrada of entradas) {
    const ancho = entrada.contentBoxSize[0].inlineSize;
    entrada.target.dataset.modo = ancho < 400 ? 'compacto' : 'completo';
  }
});
observadorTamaño.observe(document.querySelector('.grafico'));

💡 Consejo: Si solo quieres cambiar estilos según el tamaño del contenedor, las container queries de CSS (@container) lo hacen sin JavaScript. Reserva ResizeObserver para cuando necesites lógica (redibujar un canvas o un gráfico, por ejemplo).

Mutation Observer

Te avisa cuando cambia el DOM. Es útil cuando no controlas el código que modifica la página (un widget de terceros, una extensión…):

const chat = document.querySelector('#mensajes');
const observadorDOM = new MutationObserver((mutaciones) => {
  for (const mutacion of mutaciones) {
    if (mutacion.type === 'childList' && mutacion.addedNodes.length > 0) {
      chat.scrollTop = chat.scrollHeight; // baja al último mensaje
    }
    if (mutacion.type === 'attributes') console.log(`Cambió ${mutacion.attributeName}`);
  }
});
observadorDOM.observe(chat, {
  childList: true,            // hijos añadidos o eliminados
  subtree: true,              // también en los descendientes
  attributeFilter: ['class'], // cambios de atributos, pero solo de class
});
// observadorDOM.disconnect(); cuando ya no lo necesites

⚠️ Importante: Si dentro del callback modificas lo mismo que observas sin una condición de salida, crearás un bucle infinito de mutaciones.


7. Portapapeles, notificaciones y permisos

Clipboard API

// Copiar texto (debe ejecutarse tras una acción del usuario, como un clic)
const boton = document.querySelector('#copiar');
boton.addEventListener('click', async () => {
  try {
    await navigator.clipboard.writeText(document.querySelector('#codigo-descuento').textContent);
    boton.textContent = '¡Copiado!';
  } catch (error) {
    console.error('No se pudo copiar:', error);
  }
});

// Leer texto: el navegador pedirá permiso al usuario
const pegado = await navigator.clipboard.readText();

Para otros formatos (imágenes PNG, HTML) existen navigator.clipboard.write() y read() con objetos ClipboardItem.

⚠️ Importante: No uses document.execCommand('copy'): está obsoleto. La Clipboard API es asíncrona y solo funciona en contextos seguros.

Notifications API

Las notificaciones del sistema operativo requieren permiso. Notification.permission puede valer 'default' (aún no ha decidido), 'granted' o 'denied'.

async function avisar(titulo, opciones) {
  if (!('Notification' in window)) return console.warn('Sin soporte de notificaciones');
  if (Notification.permission === 'default') {
    await Notification.requestPermission(); // abre el diálogo del navegador
  }
  if (Notification.permission === 'granted') {
    const notificacion = new Notification(titulo, opciones);
    notificacion.addEventListener('click', () => window.focus());
  }
}

document.querySelector('#activar-avisos').addEventListener('click', () => {
  avisar('Pedido enviado', {
    body: 'Tu pedido #1234 llegará mañana',
    icon: '/img/logo.png',
    tag: 'pedido-1234', // una notificación con el mismo tag sustituye a la anterior
  });
});

⚠️ Importante: Si el usuario elige “Bloquear”, ya no podrás volver a preguntar: tendrá que cambiarlo él en la configuración del navegador. Pide permiso solo cuando aporte valor y tras un clic. En móviles, las notificaciones se muestran desde un Service Worker (registration.showNotification()), y las que llegan con la app cerrada requieren Push API.

Permissions API

Permite consultar el estado de un permiso sin lanzar el diálogo, para adaptar la interfaz:

const estado = await navigator.permissions.query({ name: 'geolocation' });
console.log(estado.state); // 'granted', 'denied' o 'prompt'

estado.addEventListener('change', () => {
  console.log('El permiso ha cambiado a', estado.state);
});
// Los nombres admitidos varían según el navegador (geolocation y notifications son los más fiables):
// si un nombre no se reconoce, la promesa se rechaza, así que usa try/catch

8. History API: enrutado sencillo en una SPA

📘 Concepto: Una SPA (Single Page Application) carga un único HTML y cambia el contenido con JavaScript sin recargar la página. Para que los botones Atrás/Adelante y los enlaces compartidos funcionen, la URL debe cambiar a la vez que la vista. De eso se encarga un router.

Opción A: History API (pushState + popstate)

Método/evento Qué hace
history.pushState(estado, '', url) Cambia la URL y añade una entrada al historial sin recargar
history.replaceState(estado, '', url) Igual, pero sustituye la entrada actual
history.back() / forward() / go(n) Navega por el historial
Evento popstate Se dispara al pulsar Atrás/Adelante (no al llamar a pushState)
// HTML: <nav><a href="/" data-enlace>Inicio</a> <a href="/productos" data-enlace>Productos</a></nav>
//       <main id="app"></main>
const rutas = {
  '/': () => '<h1>Inicio</h1><p>Bienvenido a la tienda.</p>',
  '/productos': () => '<h1>Productos</h1><p>Listado de productos.</p>',
};

function renderizar() {
  const vista = rutas[location.pathname] ?? (() => '<h1>404</h1><p>Página no encontrada</p>');
  document.querySelector('#app').innerHTML = vista();
}

// Delegación: interceptamos los clics en enlaces internos
document.addEventListener('click', (evento) => {
  const enlace = evento.target.closest('a[data-enlace]');
  if (!enlace) return;
  evento.preventDefault();                          // evita la recarga
  history.pushState({}, '', enlace.getAttribute('href'));
  renderizar();
});
window.addEventListener('popstate', renderizar);    // Atrás / Adelante
renderizar();                                       // primera carga

⚠️ Importante: Con este enfoque, si el usuario recarga en /productos, el navegador pide /productos al servidor. El servidor debe estar configurado para devolver siempre index.html (fallback de SPA). Servicios como GitHub Pages no lo hacen por defecto.

Opción B: enrutado con hash (#/ruta)

Todo lo que va detrás de # nunca se envía al servidor, así que funciona en cualquier alojamiento estático sin configuración. Es la opción que usaremos en el proyecto final.

// URLs del tipo https://misitio.com/#/productos; enlaces normales: <a href="#/productos">
const rutas = { '/': () => '<h1>Inicio</h1>', '/productos': () => '<h1>Productos</h1>' };

function renderizar() {
  const ruta = location.hash.slice(1) || '/'; // '#/productos' → '/productos'
  document.querySelector('#app').innerHTML = (rutas[ruta] ?? (() => '<h1>404</h1>'))();
}
window.addEventListener('hashchange', renderizar);
renderizar();
  History API Hash
URL /productos (limpia) /#/productos
Requiere configurar el servidor Sí (fallback a index.html) No
SEO Mejor Peor
Ideal para Apps con servidor propio Hosting estático (GitHub Pages)

💡 Consejo: La Navigation API (navigation.addEventListener('navigate', ...)) es el sucesor moderno de la History API, pensado específicamente para SPA. Alcanzó soporte en todos los navegadores principales a principios de 2026 (Baseline “recién disponible”), así que conviene conocerla, aunque la History API sigue siendo la más extendida.


9. Page Visibility y matchMedia

Page Visibility: ¿está el usuario mirando?

const iniciarSondeo = () => setInterval(() => console.log('Consultando novedades...'), 5000);
let intervalo = iniciarSondeo();

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    clearInterval(intervalo);           // pestaña oculta: no gastes red ni batería
    document.querySelector('video')?.pause();
  } else {
    intervalo = iniciarSondeo();        // vuelve a estar visible
  }
});

💡 Consejo: visibilitychange con estado hidden es el momento más fiable para guardar datos o enviar estadísticas antes de que el usuario se vaya (en móviles, beforeunload y unload a menudo no se disparan). Para enviar datos en ese momento usa navigator.sendBeacon(url, datos).

matchMedia: media queries desde JavaScript

const consultaOscuro = window.matchMedia('(prefers-color-scheme: dark)');
const consultaMovil = window.matchMedia('(width < 768px)');
const menosMovimiento = window.matchMedia('(prefers-reduced-motion: reduce)');

const aplicarTema = (oscuro) => (document.documentElement.dataset.tema = oscuro ? 'oscuro' : 'claro');
aplicarTema(consultaOscuro.matches); // .matches: true si el sistema usa tema oscuro

// Reaccionar cuando cambie (el usuario cambia el tema del sistema o gira el móvil)
consultaOscuro.addEventListener('change', (evento) => aplicarTema(evento.matches));
consultaMovil.addEventListener('change', (e) => console.log(e.matches ? 'Móvil' : 'Escritorio'));
if (menosMovimiento.matches) console.log('Desactivamos animaciones decorativas');

💡 Consejo: Para estilos, usa media queries en CSS. Usa matchMedia cuando la lógica dependa de ello: cargar un componente u otro, desactivar una animación hecha con JavaScript, etc.


10. Web Workers: tareas pesadas sin bloquear la interfaz

📘 Concepto: JavaScript en la página se ejecuta en un único hilo (el hilo principal), que también pinta la página y atiende los clics. Si haces un cálculo de varios segundos, la página se congela. Un Web Worker es un script que se ejecuta en otro hilo, en paralelo. Se comunica con la página mediante mensajes (postMessage) y no tiene acceso al DOM.

Es como un restaurante: el camarero (hilo principal) atiende a los clientes; si se pusiera a cocinar un guiso de dos horas, nadie le podría pedir nada. Por eso hay un cocinero (worker) en la cocina, y se comunican pasándose comandas.

Ejemplo: calcular números primos

Fichero primos-worker.js:

// primos-worker.js — se ejecuta en otro hilo
function esPrimo(n) {
  if (n < 2) return false;
  for (let divisor = 2; divisor * divisor <= n; divisor++) {
    if (n % divisor === 0) return false;
  }
  return true;
}

self.addEventListener('message', (evento) => {
  const { limite } = evento.data;
  const inicio = performance.now();
  let cantidad = 0;
  let ultimo = 0;
  for (let n = 2; n <= limite; n++) {
    if (esPrimo(n)) {
      cantidad++;
      ultimo = n;
    }
    if (n % 1_000_000 === 0) { // informamos del progreso cada millón de números
      self.postMessage({ tipo: 'progreso', porcentaje: Math.round((n / limite) * 100) });
    }
  }
  const milisegundos = Math.round(performance.now() - inicio);
  self.postMessage({ tipo: 'resultado', cantidad, ultimo, milisegundos });
});

Página index.html:

<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <title>Primos con Web Worker</title>
</head>
<body>
  <label>Límite: <input id="limite" type="number" value="10000000" step="1000000"></label>
  <button id="con-worker">Calcular con worker</button>
  <progress id="progreso" max="100" value="0"></progress>
  <p id="salida" aria-live="polite"></p>
  <input placeholder="Escribe aquí mientras calcula...">

  <script type="module">
    const salida = document.querySelector('#salida');
    const progreso = document.querySelector('#progreso');
    const limite = () => Number(document.querySelector('#limite').value);

    document.querySelector('#con-worker').addEventListener('click', () => {
      const worker = new Worker(new URL('./primos-worker.js', import.meta.url));
      salida.textContent = 'Calculando en segundo plano...';

      worker.addEventListener('message', ({ data }) => {
        if (data.tipo === 'progreso') {
          progreso.value = data.porcentaje;
          return;
        }
        progreso.value = 100;
        salida.textContent = `${data.cantidad} primos (el mayor: ${data.ultimo}) en ${data.milisegundos} ms`;
        worker.terminate(); // liberamos el hilo
      });
      worker.addEventListener('error', (e) => (salida.textContent = `Error: ${e.message}`));
      worker.postMessage({ limite: limite() });
    });
  </script>
</body>
</html>

Con un límite de 10 millones, la salida es 664579 primos (el mayor: 9999991) en ... ms (el tiempo depende de tu equipo), y mientras tanto puedes seguir escribiendo en el campo de texto. Haz la prueba contraria: copia esPrimo y el bucle al script de la página y ejecútalo ahí; la página se quedará congelada varios segundos.

💡 Consejo: Si el worker usa import, créalo como módulo: new Worker(url, { type: 'module' }). Herramientas como Vite entienden el patrón new Worker(new URL('./archivo.js', import.meta.url), { type: 'module' }) y empaquetan el worker automáticamente.

Un worker puede hacer cálculos, usar fetch, IndexedDB, temporizadores o WebSocket e importar módulos; no puede acceder a document, window, el DOM ni localStorage.

⚠️ Importante: Los datos que pasas con postMessage (objetos, arrays, Map, Date, Blob… pero no funciones ni nodos del DOM) se copian (algoritmo structured clone, el mismo de structuredClone). Para datos binarios enormes, puedes transferirlos sin copia: worker.postMessage(buffer, [buffer]).


11. Service Workers y PWA (introducción)

📘 Concepto: Un Service Worker es un tipo especial de worker que actúa como un proxy entre tu web y la red: intercepta las peticiones y puede responder desde una caché. Sigue vivo aunque cierres la pestaña y permite funcionar sin conexión, recibir notificaciones push y sincronizar en segundo plano.

Una PWA (Progressive Web App) es una web que, gracias a un Service Worker y a un fichero Web App Manifest (manifest.webmanifest con nombre, iconos y colores), se puede instalar como una app, abrirse en su propia ventana y funcionar offline.

// main.js — registrar el Service Worker
if ('serviceWorker' in navigator) {
  const registro = await navigator.serviceWorker.register('/sw.js');
  console.log('Service Worker registrado con ámbito', registro.scope);
}

// sw.js — estrategia "primero caché, luego red"
self.addEventListener('install', (evento) => {
  evento.waitUntil(caches.open('mi-app-v1').then((c) => c.addAll(['/', '/estilos.css', '/main.js'])));
});
self.addEventListener('fetch', (evento) => {
  evento.respondWith(caches.match(evento.request).then((r) => r ?? fetch(evento.request)));
});

⚠️ Importante: Los Service Workers tienen un ciclo de vida delicado (instalación, activación, actualización de versiones de caché). Un error puede dejar a los usuarios con una versión antigua de tu web. En proyectos reales se usan herramientas como Workbox o el plugin vite-plugin-pwa, que generan el Service Worker por ti.


12. Canvas 2D: dibujar con JavaScript

📘 Concepto: El elemento <canvas> es un lienzo de píxeles sobre el que dibujas con JavaScript a través de un contexto (getContext('2d')). A diferencia del DOM o de SVG, lo que dibujas no son elementos: son píxeles. Se usa para juegos, gráficos, editores de imagen o visualizaciones.

Método/propiedad Qué hace
fillStyle / strokeStyle / lineWidth Color de relleno, color de borde y grosor
fillRect(x, y, ancho, alto) / clearRect(...) Rectángulo relleno / borrar una zona
beginPath(), moveTo(), lineTo(), arc() Construyen un trazado, que luego pintas con fill() o stroke()
font + fillText(texto, x, y) Texto

Animación con requestAnimationFrame

requestAnimationFrame(funcion) pide al navegador que ejecute tu función justo antes del siguiente repintado (normalmente 60 veces por segundo, o más en pantallas de alta frecuencia). Es mucho mejor que setInterval para animar: se sincroniza con la pantalla y se pausa sola cuando la pestaña está oculta.

<canvas id="lienzo" width="600" height="300" aria-label="Pelota que rebota"></canvas>

<script type="module">
  const lienzo = document.querySelector('#lienzo');
  const ctx = lienzo.getContext('2d');
  const pelota = { x: 50, y: 50, radio: 20, vx: 240, vy: 180 }; // velocidad en px/segundo
  let anterior = performance.now();

  function dibujar() {
    ctx.fillStyle = '#f0f4ff';                         // fondo
    ctx.fillRect(0, 0, lienzo.width, lienzo.height);
    ctx.beginPath();                                   // pelota
    ctx.arc(pelota.x, pelota.y, pelota.radio, 0, Math.PI * 2);
    ctx.fillStyle = '#e63946';
    ctx.fill();
    ctx.fillStyle = '#1d3557';                         // texto
    ctx.font = '16px system-ui';
    ctx.fillText(`x: ${Math.round(pelota.x)}  y: ${Math.round(pelota.y)}`, 10, 20);
  }

  function bucle(ahora) {
    // Tiempo desde el fotograma anterior (máx. 0,05 s por si la pestaña estuvo oculta)
    const segundos = Math.min((ahora - anterior) / 1000, 0.05);
    anterior = ahora;
    pelota.x += pelota.vx * segundos;
    pelota.y += pelota.vy * segundos;
    if (pelota.x < pelota.radio || pelota.x > lienzo.width - pelota.radio) pelota.vx *= -1;
    if (pelota.y < pelota.radio || pelota.y > lienzo.height - pelota.radio) pelota.vy *= -1;
    dibujar();
    requestAnimationFrame(bucle);                      // pide el siguiente fotograma
  }
  requestAnimationFrame(bucle);
</script>

💡 Consejo: Multiplica siempre la velocidad por el tiempo transcurrido (segundos): así la pelota va igual de rápido a 60 Hz que a 144 Hz. Y recuerda que el canvas es invisible para los lectores de pantalla: añade aria-label y no pongas en él información que no esté disponible de otra forma.


13. Web Components: crea tus propias etiquetas

📘 Concepto: Los Web Components son un conjunto de estándares para crear elementos HTML reutilizables, como <valoracion-estrellas> o <tarjeta-usuario>, que funcionan en cualquier página y con cualquier framework (o sin ninguno). Sus piezas principales son:

  • Custom Elements: definir una etiqueta nueva con una clase.
  • Shadow DOM: un DOM encapsulado, cuyos estilos no “se escapan” ni se ven afectados por los de la página.
  • <template> y <slot>: plantillas y huecos para insertar contenido.

Un contador como Custom Element

<style> button { background: hotpink; } /* NO afecta al botón interno: Shadow DOM */ </style>
<contador-clics inicial="5">Me gusta</contador-clics>
<contador-clics>Favorito</contador-clics>

<script type="module">
  class ContadorClics extends HTMLElement {
    static observedAttributes = ['inicial']; // atributos cuyos cambios vigilamos
    #valor = 0;
    #salida;

    constructor() {
      super();
      const sombra = this.attachShadow({ mode: 'open' }); // DOM encapsulado
      sombra.innerHTML = `
        <style>
          :host { display: inline-flex; gap: .5rem; align-items: center; }
          button { border: 1px solid #888; border-radius: 999px; padding: .3rem .8rem; }
        </style>
        <button type="button"><slot>Clic</slot></button>
        <output>0</output>
      `;
      this.#salida = sombra.querySelector('output');
      sombra.querySelector('button').addEventListener('click', () => this.incrementar());
    }

    connectedCallback() { this.#pintar(); }

    attributeChangedCallback(nombre, anterior, nuevo) {
      if (nombre === 'inicial') {
        this.#valor = Number(nuevo) || 0;
        this.#pintar();
      }
    }

    incrementar() {
      this.#valor++;
      this.#pintar();
      // Evento propio; composed: true lo deja salir del Shadow DOM
      this.dispatchEvent(new CustomEvent('cambio', {
        detail: { valor: this.#valor }, bubbles: true, composed: true,
      }));
    }

    #pintar() { this.#salida.textContent = this.#valor; }
  }

  customElements.define('contador-clics', ContadorClics); // el nombre DEBE llevar guion
  document.addEventListener('cambio', (e) => console.log('Nuevo valor:', e.detail.valor));
</script>
Método del ciclo de vida Cuándo se llama
constructor() Al crear el elemento (aquí no leas atributos ni hijos todavía)
connectedCallback() Al insertarlo en el documento
disconnectedCallback() Al quitarlo del documento (limpia temporizadores y listeners globales)
attributeChangedCallback(nombre, anterior, nuevo) Al cambiar un atributo de observedAttributes

💡 Consejo: Los Web Components brillan para crear componentes de interfaz reutilizables entre proyectos (sistemas de diseño). Para aplicaciones enteras, los frameworks como React, Vue o Svelte ofrecen más ayuda (estado, enrutado…). Librerías como Lit simplifican mucho la escritura de Web Components.


14. Comprobar el soporte: feature detection y Baseline

Feature detection

No preguntes “¿qué navegador es?” (browser sniffing, poco fiable); pregunta “¿existe esta funcionalidad?”:

// ¿Existe la propiedad o el constructor?
if ('IntersectionObserver' in window) { /* ... */ }
if ('geolocation' in navigator && typeof Worker !== 'undefined') { /* ... */ }
if (navigator.clipboard?.writeText) { /* ... */ }

// ¿Soporta el navegador una propiedad o un selector CSS?
if (CSS.supports('container-type', 'inline-size') && CSS.supports('selector(:has(*))')) { /* ... */ }

// Carga condicional de un polyfill (solo lo descargan los navegadores que lo necesitan)
if (!('Temporal' in globalThis)) {
  await import('https://cdn.jsdelivr.net/npm/temporal-polyfill@1/global.esm.js/+esm');
}

Este enfoque permite la mejora progresiva: la página funciona con lo básico en todos los navegadores y añade mejoras donde se soportan. Por ejemplo, un botón “Compartir” puede usar navigator.share() (menú nativo de los móviles) si existe y, si no, copiar el enlace con navigator.clipboard.writeText().

Baseline

📘 Concepto: Baseline es una etiqueta común (impulsada por el grupo WebDX del W3C, con Google, Mozilla, Apple y Microsoft) que indica si una funcionalidad web se puede usar con seguridad. Se considera que una funcionalidad está en Baseline cuando funciona en las versiones actuales de Chrome, Edge, Firefox y Safari (escritorio y móvil).

Estado Significado ¿La uso?
Baseline: ampliamente disponible (Widely available) Lleva al menos 30 meses en todos los navegadores principales Sí, sin preocuparte
Baseline: recién disponible (Newly available) Acaba de llegar al último de los navegadores principales Sí, si tus usuarios tienen navegadores actualizados; si no, con alternativa
Disponibilidad limitada (Limited availability) Falta en al menos un navegador principal Solo con feature detection y alternativa o polyfill

Además, Baseline usa años: “Baseline 2024” significa que la funcionalidad llegó a todos los navegadores principales en 2024. Puedes consultarlo en MDN (recuadro al principio de cada página y tabla de compatibilidad al final), en caniuse.com y en webstatus.dev (el panel oficial del proyecto).

API de este módulo Estado aproximado (octubre 2026)
Web Storage, IndexedDB, Geolocation, Canvas, Workers, matchMedia, Page Visibility, History API, observers, Custom Elements, Shadow DOM, clipboard.writeText Ampliamente disponible
Cookie Store API Recién disponible (desde 2025)
Navigation API Recién disponible (desde 2026)

💡 Consejo: Herramientas como eslint-plugin-compat, Browserslist o la información de Baseline que muestra VS Code al pasar el ratón te avisan cuando usas algo que no cumple tu objetivo de compatibilidad.


15. Ejercicios

Ejercicio 1: Bloc de notas persistente

Crea una página con un <textarea> que guarde su contenido en localStorage mientras el usuario escribe (con un debounce de 500 ms) y lo recupere al cargar. Muestra “Guardado a las HH:MM” tras cada guardado y sincroniza el texto entre pestañas con el evento storage.

Ejercicio 2: Lista de tareas con IndexedDB

Crea una lista de tareas (título, fecha de creación como Date, hecha sí/no) guardada en IndexedDB usando la librería idb. Implementa añadir, marcar como hecha, borrar y un filtro “solo pendientes” usando un índice.

Ejercicio 3: Galería con carga diferida y animaciones

Monta una galería de 30 imágenes (por ejemplo, de https://picsum.photos/id/N/600/400) que solo cargue cada imagen cuando esté a 200 px de ser visible, usando IntersectionObserver, con una animación de aparición que respete prefers-reduced-motion.

Ejercicio 4: ¿Qué tiempo hace aquí?

Con un botón, obtén la ubicación del usuario y consulta la API gratuita de Open-Meteo (https://api.open-meteo.com/v1/forecast?latitude=...&longitude=...&current=temperature_2m). Gestiona los tres tipos de error de geolocalización y los errores de red, y guarda la última consulta en sessionStorage.

Ejercicio 5: Mini SPA con router por hash

Crea una SPA con cuatro vistas (#/, #/productos, #/productos/:id y #/carrito), un menú que resalte la ruta activa con aria-current="page" y una vista 404. El carrito se guarda en localStorage.

Ejercicio 6: Componente <valoracion-estrellas>

Crea un Web Component con Shadow DOM que muestre 5 estrellas, permita elegir una valoración con el ratón y con el teclado (flechas), refleje el valor en el atributo valor y emita un evento cambio.

Ejercicio 7: Reloj analógico en Canvas con worker de apoyo

Dibuja un reloj analógico en un <canvas> animado con requestAnimationFrame, que se adapte a su contenedor con ResizeObserver. Añade un botón que ordene un array de 1 000 000 de números en un Web Worker y comprueba que el reloj no se detiene mientras tanto.


Resumen

Concepto Sintaxis / API clave
Web Storage localStorage.setItem(k, JSON.stringify(obj)) / JSON.parse(getItem(k))
Sincronizar pestañas evento storage (e.key, e.oldValue, e.newValue)
Cookies desde JS document.cookie = 'k=v; max-age=3600; path=/; SameSite=Lax'
IndexedDB con idb openDB(nombre, versión, { upgrade }), db.add, db.getAll
Ubicación navigator.geolocation.getCurrentPosition(ok, error, opciones)
Observers new IntersectionObserver(cb, { rootMargin, threshold }), ResizeObserver, MutationObserver
Portapapeles, notificaciones, permisos clipboard.writeText(t), Notification.requestPermission(), permissions.query({ name })
Router history.pushState({}, '', url) + popstate, o location.hash + hashchange
Visibilidad y media queries evento visibilitychange, matchMedia('(...)').matches
Workers new Worker(url, { type: 'module' }) + postMessage; navigator.serviceWorker.register('/sw.js')
Animación requestAnimationFrame(bucle) + getContext('2d')
Web Component class X extends HTMLElement, customElements.define('mi-x', X), attachShadow
Feature detection 'API' in window, CSS.supports(...), Baseline en MDN