Router de Angular
Como dijimos anteriormente, con Angular estamos construyendo una SPA (Aplicación de Página Única). Esto significa que index.html es el único documento HTML cargado desde cero en el navegador y toda nuestra aplicación se maneja desde este documento. Esto no impide el poder hacer una aplicación donde tengamos varias secciones o páginas, ya que el router de Angular se encargará de ir sustituyendo el contenido principal de la aplicación en base a la url actual.
En nuestro ejemplo de productos, vamos a agregar 2 páginas más para mostrar, además de la página de productos. Estas páginas son el formulario de añadir un producto y la página de detalle de un producto (cargada cuando hacemos clic en la descripción de un producto en la lista).
El componente del formulario para insertar un producto, product-form ya lo hemos creado en ejemplos anteriores, por lo que crearemos el componente que representará la página de detalle de un producto (product-detail).
ng g c product-detail
Posteriormente, vamos a crear las rutas de la aplicación. Como todas las rutas están relacionadas con productos, van a compartir el prefijo /products. Las rutas a crear son las siguientes:
- /products → Cargará el componente products-page
- /products/add → Cargará el componente product-form
- /products/:id → Cargará el componente product-detail
- Ruta vacía '' o incorrecta '**' → Redirigiremos a /products.
Importante: La ruta products/add debe ir delante de products/:id. Esto se debe a que el componente 'add' de la ruta es un valor fijo mientras que ':id' es una variable (se respresenta con dos puntos delante). Si ponemos 'products/:id' primero, al navegar a products/add, esa ruta será compatible y navegará a product-detail, tomando 'add' como el valor de la id del producto a cargar. En definitiva, las rutas con componentes cuyo valor es fijo, siempre van delante.
Así quedaría el archivo app.routes.ts:
Por defecto (en las últimas versiones de Angular), estas rutas están registradas en el archivo app.config.ts usando la función provideRouter:
Aún hay una cosa más por hacer, o la aplicación no funcionará. Debemos incluir un elemento router-outlet en la plantilla de nuestro AppComponent. Dentro de este elemento, el router de Angular cargará las diferentes vistas/componentes. También incluiremos una barra de navegación que nos permita ir a las rutas de productos y añadir producto.
Para usar router-outlet en un componente, debes importar primero RouterModule en dicho componente. Alternativamente, puedes importar solo el componente RouterOutlet y las directivas que vamos a usar (RouterLink, RouterLinkActive). Esto implica un tamaño menor de la aplicación generada al compilar.
Así quedaría AppComponent (quitamos ProductsPageComponent del array de imports):
Los enlaces utilizan la directiva [routerLink] en lugar del atributo href. Esta directiva puede tomar como valor un string → routerLink="products/13", o pasarle un array con los componentes de la ruta, lo que permite vincular valores a variables/propiedades → [routerLink]="['products', product.id]".
La directiva [routerLinkActive] se utiliza para asignar una o más clases CSS a un elemento cuando esa ruta está activa (de esta manera, podemos ver mejor en qué sección nos encontramos).
El parámetro de entrada [routerLinkActiveOptions] (incluído en la directiva routerLinkActive) con el atributo exact establecido en true se utiliza en combinación con la directiva routerLinkActive. Por defecto, un enlace está activo cuando la ruta definida es un prefijo de la ruta actual → (/products es un prefijo de /products/23 o /products/34/edit). Con esta opción configurada, el enlace estará activo solo cuando coincida exactamente con la ruta actual.
Ya podemos eliminar el componente product-form de la plantilla de products-page, ya que lo hemos movido a su propia ruta. Ahora se cargará justo debajo de router-outlet, el componente cuya ruta esté activa.
Cambiando el título de la página
El título de la página se encuentra en el archivo index.html. En este archivo es donde se cargan todas las bibliotecas y estilos, así como también el AppComponent. Angular solo tiene control directo sobre lo que sucede dentro de la etiqueta <app-root> (puede tener otro nombre). Por lo tanto, no se puede establecer el valor del elemento <title> con algo como <title>{{title}}</title>.
Para cambiar el título de la página, al navegar a una ruta, se puede añadir una propiedad a la ruta llamada title. Angular establecerá el nuevo título de la página una vez navegue a la ruta.
Si lo queremos cambiar dinámicamente una vez estemos dentro de la ruta. Por ejemplo, poner como título el nombre del producto que se está visualizando, se puede usar el servicio Title de Angular para ello.
Parámetros de entrada en una ruta
Cuando queremos ir a la página de detalle de un producto, debemos indicar qué producto vamos a ver. La ruta definida para esta vista es 'products/:id'. Los componentes de una ruta que comienzan con ':' son parámetros de la ruta (en el navegador verás algo como product/4, donde 4 es el id del producto).
Veamos cómo llamar a esta ruta definiendo la descripción del producto como un enlace para ir allí, pasando el id del producto actual:
Ahora, vamos a recibir ese parámetro (el id del producto) en el componente de destino: ProductDetailComponent. En las versiones recientes de Angular tenemos 2 opciones.
Pero antes de verlas, vamos a crear el método en el servicio ProductsService para obtener un producto del servidor a partir de la id. También tenemos que crear la interfaz correspondiente que representa la respuesta del servidor.
Inyectar parámetros de ruta en @Input
En las últimas versiones de Angular podemos crear un parámetro de entrada @Input() con el mismo nombre que el parámetro de la ruta, donde recibiremos el valor de la id. En el decorador podemos pasarle la opción transform que recibe una función que transforma el dato antes de devolverlo. En este caso, la función numberAttribute (@angular/core), nos lo devuelve como number en lugar de string.
Para usar esto debemos añadir la llamada a la función withComponentInputBinding como parámetro a la función provideRouter en el archivo app.config.ts.
Usando el servicio ActivatedRoute
Otra opción es inyectar el servicio ActivatedRoute y acceder a los parámetros de la ruta desde ahí. Esta opción está disponible desde Angular 2.0.
Mostrando datos asíncronos
Vamos a crear una plantilla para mostrar los datos del producto. La cuestión es que el producto, hasta que el servidor nos devuelve los datos, está con valor undefined. Si no comprobamos eso, Angular nos dará un error cuando trate de acceder a las propiedades del producto en la plantilla la primera vez. Después, una vez recibe el producto y lo asigna, lo renderiza correctamente.
Para evitar eso podríamos utilizar product? en la plantilla, y mientras no haya cargado el producto, los campos estarán vacíos. Sin embargo, vamos a englobar todo dentro de una estructura @if, para poder mostrar un mensaje de carga alternativo mientras esperamos a los datos.
Al incorporar en la plantilla el componente star-rating, deberíamos poner el método para cambiar la puntuación en el producto, el mismo que en product-item. Si compartieran más lógica, podríamos considerar reutilizar el componente product-item pasándole un booleano por ejemplo, que indicara si queremos el HTML con la estructura de card o de fila.
Navegación entre rutas que cargan el mismo componente
A veces se navega a una ruta que comparte el mismo componente que la actual. Por ejemplo, estamos en la página de detalle de un producto, y vamos a la página de detalle de otro producto. Por optimizar rendimiento, Angular no destruye el componente y lo vuelve a crear en este caso, sino que simplemente cambia los parámetros de la ruta. Esto implica que el método ngOnInit no se volvería a ejecutar y no se cargaría el nuevo producto (prueba a poner un enlace con routerLink en la página de detalle que vaya al detalle de otro producto).
Si utilizamos @Input, basta con crear un setter asociado a dicho parámetro de entrada. De esta manera, cada vez que cambia el valor, se ejecuta el setter, y ahí cargamos el producto. De esta manera, podemos prescindir del método ngOnInit.
Si usamos ActivatedRoute, sin embargo, debemos omitir el atributo snapshot. En ese caso, al acceder a los parámetros, nos devuelve un observable al que debemos suscribirnos. Cada vez que cambie, se ejecutará la función asociada al método subscribe, y ahí cargaremos el nuevo producto.
Navegación desde código
Para navegar a otra página desde código, debemos inyectar en el componente el servicio Router de Angular. Con el método navigate, le podemos pasar un array con los componentes de la ruta donde queremos ir, igual que se los pasamos a la directiva routerLink. También está el método navigateByUrl que recibe un string con la ruta completa.
Vamos a crear un método para volver a la página del listado de productos desde el detalle.
Guardianes de rutas: CanActivate
Angular puede controlar si podemos ir a una ruta o abandonarla utilizando guardianes (guards). Los guards son clases especiales (servicios) utilizadas en combinación con el enrutador de Angular, y veremos pronto cómo utilizarlos. En esta sección, vamos a ver cómo evitar que el usuario vaya a una ruta específica si, por ejemplo, el usuario no ha iniciado sesión. Para lograr esto, utilizaremos el guard CanActivate.
Vamos a crear un guard que compruebe si la ruta tiene un identificador numérico válido asociado al parámetro id de la ruta. A partir de Angular 15, podemos implementar guardianes como funciones en lugar de clases.
La función del guard recibe 2 parámetros (el segundo parámetro generalmente no se utiliza y se puede omitir). En este método, comprobaremos que el identificador sea un número. Los guards pueden devolver un objeto URLTree para forzar la navegación a otra página cuando la ruta no se puede activar. Si la función devuelve true, Angular nos deja navegar a la ruta que tenga asociado ese guardián.
El comando para generar un guardián es: ng g guard nombre-guardian
El método canActivate, además de devolver un valor booleano, también puede devolver Promise<boolean> u Observable<boolean>. El router de Angular se suscribirá automáticamente a ese observable o promesa.
Por ahora no funcionará ya que falta un paso importante. Si pruebas la ruta /products/asdf, por ejemplo, te llevará al detalle del producto y la id tomará el valor 'asdf', lo que al convertir a número genera NaN (dará un error al intentar cargarlo)
Para utilizar este guard, debemos registrarlo en las rutas correspondientes (en este caso, la ruta a ProductDetailComponent). Para proteger una ruta, utilizamos la propiedad canActivate. Es un array porque podemos usar varios guards CanActivate en una sola ruta (si algún guard falla, no se activará la ruta):
Si ahora probamos la ruta /products/asdf, nos devolverá a la raíz (página de productos) ya que la id no es numérica. ESta comprobación la podríamos haber hecho en el componente product-detail, sin embargo, es más eficiente comprobarlo con antelación y directamente no cargar la página de destino.
Guardianes de rutas: CanDeactivate
Un guard de tipo CanDeactivate permite que el usuario abandone la página actual solo si se cumplen ciertas condiciones. Por ejemplo, si el usuario está editando la información de algún producto y no ha guardado los cambios, podemos usar este guard para mostrarle un mensaje indicándole que guarde (o cancele) los cambios antes de abandonar la ruta. O simplemente preguntarle si desea salir sin guardar cambios.
Vamos a crear un guard de tipo CanDeactivate que nos pregunte si estamos seguros de abandonar la página sin guardar los cambios. Si respondemos que sí, nos permitirá abandonar la página (devolviendo true). En caso contrario nos quedaremos donde estamos.
Al igual que antes, se lo tenemos que añadir a la ruta correspondiente, esta vez en el array canDeactivate. En este caso, debe ser una ruta asociada con el componente ProductFormComponent, ya que es lo que hemos especificado como tipo genérico en la función del guard.
Como puedes ver, si tenemos que especificar el componente para este guard, solo podemos usarlo en rutas asociadas a este componente. Pero hay una manera de crear un guard CanDeactivate reutilizable. Crearemos una interfaz llamada CanComponentDeactivate con un método llamado canDeactivate, que todos los componentes que necesitemos pueden implementar. En lugar de usar un componente como tipo genérico, usaremos esta interfaz, y devolveremos lo que el método canDeactivate de ese componente devuelva.
Para nuestro ejemplo además, vamos a crear un booleano, para cuando ser acabe de insertar un producto en el servidor, no nos pregunte a la hora de redirigir a la página de productos, y en caso contrario sí nos preguntará.
De esta manera solo tenemos que implementar la interfaz en aquellos componentes para cuyas rutas queramos utilizar este guard. En el futuro veremos cómo comprobar si el formulario ha sido modificado para no preguntar al usuario en caso contrario.
Precarga de datos: Resolver
¿Y si queremos obtener los datos (por ejemplo, un producto) antes de cargar el componente de una ruta? (como la página de detalles del producto). En lugar de obtener el producto a partir del id en ProductDetailComponent, lo obtendremos antes de cargar la ruta utilizando un guard de tipo Resolver. De esta manera, sabremos que cuando se cargue el componente, el producto estará disponible de inmediato.
La función de tipo Resolver (también se puede crear como una clase) puede devolver los datos directamente, en una promesa o en un observable (el router de Angular se suscribirá a él automáticamente). También usaremos la función catchError del observable para detectar errores. En caso de error al obtener el producto, la función catchError devolverá un observable vacío (EMPTY) y redireccionaremos a la página de productos.
ng g resolver resolvers/product
Cambia el tipo de dato que devuelve el resolver de boolean a Product en este caso:
Así incluimos el resolver en la ruta a la página de detalle un producto. La propiedad resolve de la ruta será un objeto cuyas propiedades son los datos a precargar antes de ir a la ruta y el valor, será la función de tipo resolver que los obtiene. En el ccomponente recibimos este dato en un @Input con el mismo nombre (ya no necesitaremos la id de la ruta para nada).
Ya podemos borrar la comprobación de si existe el producto en la plantilla (@if). La parte negativa de usar resolver es que hasta que los datos no se hayan cargado, no cambia la ruta y por lo tanto el componente. Esto con una conexión lenta puede generar la impresión al usuario de que el enlace a la ruta no está respondiendo. Es la responsabilidad del desarrollador poner alguna animación o mensaje que indique al usuario que la aplicación está obteniendo los datos.
Carga en diferido de rutas: Lazy loading
Para mejorar el rendimiento en la carga inicial de la aplicación, podríamos agrupar rutas que tengan un prefijo común (en nuestro caso /products) y cargarlas en diferido. Es decir, el script inicial que se cargaría en el navegador no incluiría el código asociado a los componentes de esas rutas, ni guards, resolvers, etc. Esto implicaría un tiempo de carga menor y una mejor experiencia de usuario.
Para tener una página inicial que no tenga que ver con las rutas de productos y dejar esa fuera de la división de código, vamos a crear un componente que represente la página inicial de la aplicación (y su correspondiente ruta). Lo llamaremos welcome.
ng g c welcome
El contenido del componente es lo de menos. Pondremos un contenido de bienvenida a en la plantilla y asignaremos la ruta por defecto a este componente.
Después, creamos un directorio llamado products dentro de src/app. Y metemos ahí todo lo estrictamente relacionado con las rutas de productos (cuyo prefijo es /products). Esto no es obligatorio, pero ayuda a organizar un poco el código. Lo que pensemos que podríamos reutilizar para otras rutas en el futuro (por ejemplo los guards que hemos creado, o el componente star-rating), o sea global (interceptores), se quedará en src/app.
Tendremos que comprobar los imports en varios archivos, ya que las rutas habrán cambiado, eso sí.
Posteriormente, creamos un archivo llamado products.routes.ts dentro de la carpeta products. Este archivo contendrá las rutas relacionadas con productos. Copiamos todas las rutas que empiezan por products aquí (Importante: omitiendo el prefijo products/).
Posteriormente, una vez quitadas esas rutas del archivo principal (app.routes.ts), añadimos una ruta que representará el prefijo 'products' y con la propiedad loadChildren importamos de forma dinámica las rutas que hemos separado en el otro archivo.
Vamos a ver como quedaría el tamaño del código generado (para producción) como lo teníamos antes y con la carga en diferido de las rutas. Antes:
Después:
Antes el navegador tenía que cargar unos 284KB de código (75KB comprimidos) para lanzar la aplicación. Este código incluía todo, nuestro código, librerías y Angular. Ahora ha pasado lo siguiente:
- El código común (librerías, Angular) se ha llevado a un archivo aparte de unos 247KB (que se tiene que cargar al inicio).
- Nuestro código inicial que se carga en el inicio de la app (componente welcome y app, y poco más), ocupa solo 2,4KB
- Todo lo que tenga que ver con las rutas de productos, ocupa unos 35KB y no se carga al inicio.
Si sumamos todo lo anterior, nos da los 284KB de código que generaba antes. Un buen porcentaje de ese código es Angular y otras librerías, por lo que no parece que estemos ahorrando mucho, pero en una aplicación de verdad, nuestro código va a representar un porcentaje mucho mayor del total que en este ejemplo. Por lo que separar gran parte del mismo para que no se cargue al principio, se va a notar mucho en los tiempos de carga iniciales.
Carga en diferido de componentes en las rutas
Se puede optar a un nivel más fino de optimización si además, cargamos en diferido cada componente asociado a una ruta usando loadComponent en lugar de component. De esta manera, cada ruta, tendría su código separado del resto, y al visitar una página por primera vez solo se cargaría el código asociado a dicha página. Una vez se visita una ruta, durante la ejecución del programa, las siguientes veces no tiene que cargar nada otra vez.
Estrategia de precarga
El comportamiento predeterminado de carga en diferido consiste en cargar las rutas o componentes solo cuando accedemos a una ruta que los necesita. Existe otra estrategia llamada pre-carga. Esta cargará primero la aplicación principal sin los componentes de carga diferida (tiempo de carga rápido), pero tan pronto como termine de cargar la aplicación, descargará e importará el resto de los componentes en segundo plano. Así, cuando vayamos a la ruta 'products', por ejemplo, el componente ProductsPageComponent ya estará cargado en memoria.
Para usar esta estrategia, en el archivo app.config.ts, incluya la función withPreloading con la opción preferida.
Quizás esta estrategia no sea la mejor para aplicaciones grandes con muchos componentes de carga diferida, ya que puede saturar la red. Podríamos crear nuestra propia estrategia personalizada donde decidimos qué rutas o componentes precargar y cuáles se cargarán tan pronto como el usuario visite una ruta relacionada con ellos (por defecto). Ejemplo.