Apuntes DAM
Volver al inicio

El router de una SPA: rutas con parámetros, guardianes e historial

Ejercicio de JavaScriptDifícilUnos 75 minutos

Programa el enrutador de una aplicación de una sola página como el de Vue Router o React Router: rutas con parámetros, restricciones y comodín, redirecciones, rutas privadas que mandan al login y vuelven después, el historial con atrás y adelante, y la defensa frente a las redirecciones abiertas.

  • SPA y enrutado en el cliente
  • History API: pushState, replaceState y popstate
  • Rutas con parámetros
  • Guardianes de navegación
  • URLSearchParams y encodeURIComponent
  • Redirecciones abiertas

Enunciado

En una SPA (Single Page Application) el servidor manda una sola página y es JavaScript quien cambia el contenido al navegar. El router es la pieza que traduce la URL en una vista: busca qué ruta encaja (/alumnos/:id con /alumnos/7 da id = 7), decide si se puede entrar (las rutas privadas exigen sesión) y gestiona el historial con la History API (pushState añade una entrada, replaceState sustituye la actual y popstate avisa de atrás y adelante) para que los botones del navegador sigan funcionando.

Las rutas de la aplicación ya están definidas en el código de partida (tabla de abajo), junto con el historial y una función que separa la ruta de la query. Tú programas el emparejamiento, la resolución con redirecciones y guardián, y las órdenes que simulan lo que hace el usuario.

Qué tiene que hacer el programa

  1. coincidir(patron, ruta): compara segmento a segmento. Un segmento :nombre captura cualquier valor (decodificado con decodeURIComponent), * al final captura uno o más segmentos en resto (decodificados y unidos con /) y el resto deben ser iguales. Devuelve los parámetros o null; un % mal escrito hace que no encaje.
  2. resolver(url) recorre las rutas en orden: si encaja pero no cumple sus restricciones, sigue con la siguiente. Si tiene redirigir, o es privada y no hay sesión, se resuelve el destino (/login?volver= + la URL completa con encodeURIComponent) y el resultado lleva desde con la primera URL pedida. Si no encaja ninguna: Página no encontrada (404).
  3. ir URL (debe empezar por /): escribe url → vista y, si hubo redirección, (redirigido desde URL); añade la URL final al historial borrando lo que hubiera adelante. Si la URL final es la actual: Ya estás en URL. La barra final de la ruta se ignora (/perfil/ es /perfil).
  4. atras y adelante mueven la posición (No hay página anterior / No hay página siguiente) y escriben atrás: o adelante: y el resultado de resolver otra vez esa entrada, que se sustituye si el guardián redirige.
  5. login NOMBRE: Sesión iniciada: NOMBRE (o Ya hay una sesión abierta: X); si se está en /login, se resuelve el parámetro volver (o / si no hay, si no empieza por / o si empieza por //), sustituyendo la entrada actual, y se escribe. logout: Sesión cerrada (o No hay ninguna sesión abierta) y se resuelve otra vez la entrada actual; si cambia, se sustituye y se escribe.
  6. historial: Historial: y cada entrada como N. url, con * en lugar del primer espacio en la actual; o Historial: vacío. Una orden con datos de más o sin los que necesita: Orden no válida: línea; otra orden: Orden desconocida: orden.

Entrada

Una orden por línea: ir URL, atras, adelante, login NOMBRE, logout o historial.

Datos de referencia

Rutas de la aplicación
PatrónVistaNotas
/Inicio
/inicioredirige a /
/loginFormulario de acceso
/alumnosLista de alumnos [ordenada por O]privada; query orden
/alumnos/:idFicha del alumno ID [(pestaña T)]privada; id solo cifras; query tab
/cursos/:curso/modulos/:moduloMódulo M de C
/perfilPerfil de USUARIOprivada
/docs/*Documentación: RESTO

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Navegación con parámetros y login

Entrada

ir /
ir /cursos/1%C2%BA%20DAM/modulos/Programaci%C3%B3n
ir /docs/guia/instalacion
ir /inicio
ir /alumnos/7?tab=notas
login ana
ir /alumnos?orden=apellidos
ir /perfil/
historial
atras
atras
adelante

Salida por consola

/ → Inicio
/cursos/1%C2%BA%20DAM/modulos/Programaci%C3%B3n → Módulo Programación de 1º DAM
/docs/guia/instalacion → Documentación: guia/instalacion
/ → Inicio (redirigido desde /inicio)
/login?volver=%2Falumnos%2F7%3Ftab%3Dnotas → Formulario de acceso (redirigido desde /alumnos/7?tab=notas)
Sesión iniciada: ana
/alumnos/7?tab=notas → Ficha del alumno 7 (pestaña notas)
/alumnos?orden=apellidos → Lista de alumnos ordenada por apellidos
/perfil → Perfil de ana
Historial:
  1. /
  2. /cursos/1%C2%BA%20DAM/modulos/Programaci%C3%B3n
  3. /docs/guia/instalacion
  4. /
  5. /alumnos/7?tab=notas
  6. /alumnos?orden=apellidos
* 7. /perfil
atrás: /alumnos?orden=apellidos → Lista de alumnos ordenada por apellidos
atrás: /alumnos/7?tab=notas → Ficha del alumno 7 (pestaña notas)
adelante: /alumnos?orden=apellidos → Lista de alumnos ordenada por apellidos

Guardianes, 404 y redirecciones abiertas

Entrada

atras
ir /alumnos/abc
ir /alumnos/12/
ir /login?volver=https://malo.example
login eva
login luis
ir /perfil
logout
atras
ir /perfil
ir /perfil
logout
ir /
saltar /
historial

Salida por consola

No hay página anterior
/alumnos/abc → Página no encontrada (404)
/login?volver=%2Falumnos%2F12 → Formulario de acceso (redirigido desde /alumnos/12)
/login?volver=https://malo.example → Formulario de acceso
Sesión iniciada: eva
/ → Inicio
Ya hay una sesión abierta: eva
/perfil → Perfil de eva
Sesión cerrada
/login?volver=%2Fperfil → Formulario de acceso (redirigido desde /perfil)
atrás: / → Inicio
/login?volver=%2Fperfil → Formulario de acceso (redirigido desde /perfil)
Ya estás en /login?volver=%2Fperfil
No hay ninguna sesión abierta
/ → Inicio
Orden desconocida: saltar
Historial:
  1. /alumnos/abc
  2. /login?volver=%2Falumnos%2F12
  3. /
  4. /login?volver=%2Fperfil
* 5. /

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Segmento a segmento

Parte patrón y ruta por / (con filter(Boolean) desaparecen los vacíos) y recórrelos a la vez. Al terminar, deben tener los mismos segmentos, salvo que el patrón acabe en *.

javascript
if (p[i].startsWith(":")) params[p[i].slice(1)] = decodeURIComponent(r[i]);
else if (p[i] !== r[i]) return null;
2. Redirigir es resolver otra vez

resolver se llama a sí misma con el destino. Así una redirección que lleva a una ruta privada pasa también por el guardián.

3. push frente a replace

ir hace push (nueva entrada, se pierde el «adelante»); el login y los guardianes al volver hacen replace (sustituyen la entrada actual). Con splice(actual + 1) borras lo que hay adelante.

4. La vuelta tras el login

URLSearchParams.get("volver") ya devuelve el valor decodificado. Antes de usarlo, comprueba que es una ruta propia.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.

🟨JavaScriptEl router de una SPA: rutas con parámetros, guardianes e historialDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
ir /
ir /cursos/1%C2%BA%20DAM/modulos/Programaci%C3%B3n
ir /docs/guia/instalacion
ir /inicio
ir /alumnos/7?tab=notas
login ana
ir /alumnos?orden=apellidos
ir /perfil/
historial
atras
atras
adelante
Salida esperada
/ → Inicio
/cursos/1%C2%BA%20DAM/modulos/Programaci%C3%B3n → Módulo Programación de 1º DAM
/docs/guia/instalacion → Documentación: guia/instalacion
/ → Inicio (redirigido desde /inicio)
/login?volver=%2Falumnos%2F7%3Ftab%3Dnotas → Formulario de acceso (redirigido desde /alumnos/7?tab=notas)
Sesión iniciada: ana
/alumnos/7?tab=notas → Ficha del alumno 7 (pestaña notas)
/alumnos?orden=apellidos → Lista de alumnos ordenada por apellidos
/perfil → Perfil de ana
Historial:
  1. /
  2. /cursos/1%C2%BA%20DAM/modulos/Programaci%C3%B3n
  3. /docs/guia/instalacion
  4. /
  5. /alumnos/7?tab=notas
  6. /alumnos?orden=apellidos
* 7. /perfil
atrás: /alumnos?orden=apellidos → Lista de alumnos ordenada por apellidos
atrás: /alumnos/7?tab=notas → Ficha del alumno 7 (pestaña notas)
adelante: /alumnos?orden=apellidos → Lista de alumnos ordenada por apellidos
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
javascript
1const lineas = require("fs").readFileSync(0, "utf8").split("\n");
2
3let sesion = null;          // nombre del usuario conectado, o null
4
5// Las rutas de la aplicación, como las de Vue Router o React Router (gana la primera que coincide)
6const RUTAS = [
7  { patron: "/", vista: () => "Inicio" },
8  { patron: "/inicio", redirigir: "/" },
9  { patron: "/login", vista: () => "Formulario de acceso" },
10  { patron: "/alumnos", privada: true, vista: (p, q) => "Lista de alumnos" + (q.get("orden") ? ` ordenada por ${q.get("orden")}` : "") },
11  { patron: "/alumnos/:id", privada: true, restricciones: { id: /^\d+$/ }, vista: (p, q) => `Ficha del alumno ${p.id}` + (q.get("tab") ? ` (pestaña ${q.get("tab")})` : "") },
12  { patron: "/cursos/:curso/modulos/:modulo", vista: (p) => `Módulo ${p.modulo} de ${p.curso}` },
13  { patron: "/perfil", privada: true, vista: () => `Perfil de ${sesion}` },
14  { patron: "/docs/*", vista: (p) => `Documentación: ${p.resto}` },
15];
16
17const historial = [];       // como window.history: las URL visitadas
18let actual = -1;            // posición actual en el historial
19
20/** "/alumnos/7/?tab=notas" → { ruta: "/alumnos/7", query: URLSearchParams, url: "/alumnos/7?tab=notas" } */
21function separar(url) {
22  const i = url.indexOf("?");
23  let ruta = i < 0 ? url : url.slice(0, i);
24  if (ruta.length > 1 && ruta.endsWith("/")) ruta = ruta.slice(0, -1);
25  const resto = i < 0 ? "" : url.slice(i + 1);
26  return { ruta, query: new URLSearchParams(resto), url: ruta + (resto ? "?" + resto : "") };
27}
28
29const texto = (r) => `${r.url} → ${r.vista}` + (r.desde ? ` (redirigido desde ${r.desde})` : "");
30
31/** Los parámetros si la ruta encaja con el patrón (":id" captura un segmento, "*" el resto), o null. */
32function coincidir(patron, ruta) {
33  const p = patron.split("/").filter(Boolean), r = ruta.split("/").filter(Boolean);
34  const params = {};
35  try {
36    for (let i = 0; i < p.length; i++) {
37      if (p[i] === "*") {
38        if (i >= r.length) return null;
39        params.resto = r.slice(i).map(decodeURIComponent).join("/");
40        return params;
41      }
42      if (i >= r.length) return null;
43      if (p[i].startsWith(":")) params[p[i].slice(1)] = decodeURIComponent(r[i]);
44      else if (p[i] !== r[i]) return null;
45    }
46  } catch {
47    return null;            // un %XX mal escrito: la URL no encaja con nada
48  }
49  return p.length === r.length ? params : null;
50}
51
52/** Busca la ruta, aplica las redirecciones y el guardián de las privadas y devuelve { url, vista, desde }. */
53function resolver(url) {
54  const { ruta, query, url: completa } = separar(url);
55  for (const r of RUTAS) {
56    const params = coincidir(r.patron, ruta);
57    if (!params) continue;
58    if (r.restricciones && !Object.entries(r.restricciones).every(([k, re]) => re.test(params[k]))) continue;
59    if (r.redirigir || (r.privada && !sesion)) {
60      const destino = r.redirigir ?? "/login?volver=" + encodeURIComponent(completa);
61      const res = resolver(destino);
62      return { ...res, desde: res.desde ?? completa };
63    }
64    return { url: completa, vista: r.vista(params, query) };
65  }
66  return { url: completa, vista: "Página no encontrada (404)" };
67}
68
69/** Navegar como history.pushState: se pierde lo que hubiera «adelante». */
70function ir(url) {
71  const res = resolver(url);
72  if (historial[actual] === res.url) {
73    console.log(`Ya estás en ${res.url}`);
74    return;
75  }
76  historial.splice(actual + 1);
77  historial.push(res.url);
78  actual = historial.length - 1;
79  console.log(texto(res));
80}
81
82/** Volver a mostrar la entrada actual (popstate): los guardianes se evalúan otra vez y pueden sustituirla. */
83function recargar() {
84  const res = resolver(historial[actual]);
85  historial[actual] = res.url;
86  return res;
87}
88
89function main() {
90  for (const bruta of lineas) {
91    const linea = bruta.trim();
92    if (!linea) continue;
93    const [orden, arg, ...sobra] = linea.split(/\s+/);
94    if (sobra.length) {
95      console.log(`Orden no válida: ${linea}`);
96      continue;
97    }
98    switch (orden) {
99      case "ir":
100        if (!arg || !arg.startsWith("/")) console.log(`Orden no válida: ${linea}`);
101        else ir(arg);
102        break;
103      case "atras":
104      case "adelante": {
105        const nueva = actual + (orden === "atras" ? -1 : 1);
106        if (nueva < 0 || nueva >= historial.length || actual < 0) {
107          console.log(orden === "atras" ? "No hay página anterior" : "No hay página siguiente");
108          break;
109        }
110        actual = nueva;
111        console.log(`${orden === "atras" ? "atrás" : "adelante"}: ${texto(recargar())}`);
112        break;
113      }
114      case "login": {
115        if (!arg) {
116          console.log(`Orden no válida: ${linea}`);
117          break;
118        }
119        if (sesion) {
120          console.log(`Ya hay una sesión abierta: ${sesion}`);
121          break;
122        }
123        sesion = arg;
124        console.log(`Sesión iniciada: ${sesion}`);
125        if (actual < 0) break;
126        const { ruta, query } = separar(historial[actual]);
127        if (ruta === "/login") {
128          let volver = query.get("volver") ?? "/";
129          // solo rutas propias: nada de redirecciones abiertas a otras webs
130          if (!volver.startsWith("/") || volver.startsWith("//")) volver = "/";
131          const res = resolver(volver);
132          historial[actual] = res.url;      // como router.replace: no añade entrada
133          console.log(texto(res));
134        }
135        break;
136      }
137      case "logout": {
138        if (!sesion) {
139          console.log("No hay ninguna sesión abierta");
140          break;
141        }
142        sesion = null;
143        console.log("Sesión cerrada");
144        if (actual >= 0) {
145          const antes = historial[actual];
146          const res = recargar();
147          if (res.url !== antes) console.log(texto(res));
148        }
149        break;
150      }
151      case "historial":
152        if (historial.length === 0) console.log("Historial: vacío");
153        else {
154          console.log("Historial:");
155          historial.forEach((u, i) => console.log(`${i === actual ? "*" : " "} ${i + 1}. ${u}`));
156        }
157        break;
158      default:
159        console.log(`Orden desconocida: ${orden}`);
160    }
161  }
162}
163
164main();

Los routers reales hacen exactamente esto: Vue Router o React Router compilan cada patrón, prueban las rutas en orden y llaman a los guardianes (beforeEach, loaders) antes de mostrar la vista. Lo que este ejercicio imprime es lo que la aplicación pinta.

Que el login use replace evita una trampa clásica: con push, al pulsar atrás después de entrar se volvería al formulario de acceso.

Comprobar el parámetro volver evita una redirección abierta: sin la comprobación, un enlace /login?volver=https://web-falsa.com enviaría al usuario, recién identificado y confiado, a otra web. Por eso //web-falsa.com también se rechaza: el navegador lo entiende como otra web.

Una SPA necesita además que el servidor devuelva el mismo index.html para cualquier ruta (el «fallback»): si no, al recargar en /alumnos/7 el servidor buscaría ese fichero y daría un 404.

Para ir más allá

  • Añade rutas anidadas: /alumnos/:id con hijas notas y faltas que heredan el guardián.
  • Haz que el modo hash (#/alumnos/7) funcione igual, como el de las SPA sin servidor.
  • Añade un guardián de salida que pregunte antes de abandonar un formulario con cambios.

Dónde se explica