El router de una SPA: rutas con parámetros, guardianes e historial
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
coincidir(patron, ruta): compara segmento a segmento. Un segmento:nombrecaptura cualquier valor (decodificado condecodeURIComponent),*al final captura uno o más segmentos enresto(decodificados y unidos con/) y el resto deben ser iguales. Devuelve los parámetros onull; un%mal escrito hace que no encaje.resolver(url)recorre las rutas en orden: si encaja pero no cumple sus restricciones, sigue con la siguiente. Si tieneredirigir, o es privada y no hay sesión, se resuelve el destino (/login?volver=+ la URL completa conencodeURIComponent) y el resultado llevadesdecon la primera URL pedida. Si no encaja ninguna:Página no encontrada (404).ir URL(debe empezar por/): escribeurl → vistay, 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).atrasyadelantemueven la posición (No hay página anterior/No hay página siguiente) y escribenatrás:oadelante:y el resultado de resolver otra vez esa entrada, que se sustituye si el guardián redirige.login NOMBRE:Sesión iniciada: NOMBRE(oYa hay una sesión abierta: X); si se está en/login, se resuelve el parámetrovolver(o/si no hay, si no empieza por/o si empieza por//), sustituyendo la entrada actual, y se escribe.logout:Sesión cerrada(oNo hay ninguna sesión abierta) y se resuelve otra vez la entrada actual; si cambia, se sustituye y se escribe.historial:Historial:y cada entrada comoN. url, con*en lugar del primer espacio en la actual; oHistorial: 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
| Patrón | Vista | Notas |
|---|---|---|
| / | Inicio | |
| /inicio | redirige a / | |
| /login | Formulario de acceso | |
| /alumnos | Lista de alumnos [ordenada por O] | privada; query orden |
| /alumnos/:id | Ficha del alumno ID [(pestaña T)] | privada; id solo cifras; query tab |
| /cursos/:curso/modulos/:modulo | Módulo M de C | |
| /perfil | Perfil de USUARIO | privada |
| /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 *.
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.
Ejemplo
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
/ → 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
Solución explicada
Ver la solución completa
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/:idcon hijasnotasyfaltasque 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.