La verdad es que no sabía como titular este artículo en una sola frase corta. Realmente se podría llamar algo así como «Cómo crear un routing dinámico en Angular cuando las rutas te las proporciona un sistema externo mediante llamadas asíncronas a un endpoint de api Rest», pero me parecía demasiado largo. Pero realmente este era nuestro problema y no sabíamos muy bien qué hacer para resolverlo.
¿Cuál es el problema?
Vamos a entender primero el problema. Típicamente en Angular se define un routing en una constante en el módulo de routing, de forma que cuando arranca la aplicación ya tiene todas las posibles rutas y el usuario puede navegar sin problemas o incluso acceder directamente a una url concreta.
Ejemplo de constante de rutas:
const routes: Routes = [
{ path: '', component: HomeComponent, data: { title: '', description: 'Un nuevo lenguaje para encontrar empleo'} },
{ path: 'blog/post', component: PostComponent, data: { title: '', description: 'Un nuevo lenguaje para encontrar empleo'} },
{ path: '**', component: EmptyComponent, data: { title: '', description: 'Un nuevo lenguaje para encontrar empleo'} },
];
El problema radica en que necesitábamos algo completamente distinto. Estamos montando una web en Angular que sirve de front end de un Headless CMS, por lo tanto el routing de la aplicación no lo marca Angular, si no que depende de lo que se defina en el CMS, ya que, se pueden crear o eliminar páginas o incluso modificar urls de páginas existentes.
Carga asíncrona de rutas
El primer paso para crear un routing dinámico en Angular, es ver como cargar las rutas de forma asíncrona. Para ello, lo primero es tener un endpoint que nos devuelva en JSON todas las rutas disponibles. Esto lo hemos hecho en el CMS y el resultado es algo parecido a este JSON:
{
"last_date": "2022-02-14 16:12:40",
"sitemapItems": [
{
"title": "Squad Front End",
"url": "squad-front-end",
"template": "page",
"post_modified": "2022-02-14 16:12:40"
},
{
"title": "Squad Full Stack",
"url": "squad-fullstack",
"template": "page",
"post_modified": "2022-02-10 16:05:24",
"children": [
{
"title": "Interna full stack",
"url": "interna-full-stack",
"template": "page",
"data": {},
"post_modified": "2021-12-14 14:02:14"
}
]
},
{
"title": "Squad back end",
"url": "squad-back-end",
"template": "page"
}
]
}
Una vez que ya tenemos el endpoint, tenemos que conseguir que en la carga de la página se actualice el routing. Para conseguir esta actualización hemos desarrollado varias piezas de código.
Lo primero que necesitamos es un servicio que se encargue de recuperar del api la navegación. Ahora estamos hablando de todas las rutas, pero este mismo servicio lo usaremos para recuperar los menús de la aplicación. Este servicio se llamará NavigationService y recupera todas las rutas del api.
@Injectable({
providedIn: 'root'
})
export class NavigationService {
constructor(private http:HttpClient) { }
public getSiteMapUrls(): Observable<any> {
let endpointUrl = `${environment.apiUrl}/menu/sitemap`
return this.http.get<any>(endpointUrl);
}
}
Una vez que tenemos el servicio que recupera las rutas, necesitamos un servicio que actualice nuestro routing. A este servicio lo hemos llamado SettingsService y tiene un método loadSettings que se encarga de llamar al NavigationService y establecer las rutas en el router.
Injectable({
providedIn: 'root'
})
export class SettingsService {
private siteMapsUrlsSubscription! : Subscription;
constructor(
private injector: Injector,
private navigationService: NavigationService,
) { }
public loadSettings() {
return new Promise<void>((resolve, reject) => {
const router = this.injector.get(Router);
this.siteMapsUrlsSubscription = this.navigationService.getSiteMapUrls()
.subscribe({
next: (sitemap) => {
this.setSettingsRouter(router, sitemap.sitemapItems);
resolve();
}
});
});
}
Podemos ver que este método recupera el router, posteriormente recupera las urls y una vez recibidas, llama a un método que establece los settings del router.
Vamos a ver este método a continuación que es el que realmente actualiza el router.
private setSettingsRouter(router: Router, items: any){
const basicRoutes: Routes = [
{ path: '', component: HomeComponent, data: { title: '', description: 'Un nuevo lenguaje para encontrar empleo'} },
{ path: '**', component: EmptyComponent, data: { title: '', description: 'Un nuevo lenguaje para encontrar empleo'} },
];
if(items){
let routerItems: Routes;
routerItems = items.map(
(item: any) => {
return this.convertSitemapItem2Route(item);
}
);
router.config = routerItems.concat(basicRoutes);
}
}
Lo que hacemos es crearnos dos rutas básicas, una es la página Home y otra es la página vacía, que ya explicaremos más adelante para que la usamos.
Nos recorremos todas las rutas devueltas por el endpoint y las convertimos una a una a la clase Route que necesitamos con el método this.convertSitemapItem2Route. Generamos un único array con todas las rutas y actualizamos la configuración del routing.
Como vemos por el momento es bastante sencillo y hasta diría que lógico 😉
Asignación de componente y routing jerárquico
Para continuar hacia nuestro objetivo de crear un routing dinámico en Angular, hay dos de cosas que tenemos que tener en cuenta al definir nuestro routing, por un lado que una ruta la tenemos que asociar a un componente Angular, por otro lado, que nuestras rutas deben tener un formato de árbol, ya que, necesitaremos esta estructura cuando queramos generar nuestro sitemap o nuestras migas de pan.
Respecto a la asignación de componente, generalmente en el routing que tenemos en una aplicación Angular al definir una entrada de nuestro Routes ya le asignamos el componente que tenemos que utilizar. Por ejemplo:
Routes = [
{ path: '', component: HomeComponent …
Sin embargo, en esta aplicación esto no es tan sencillo, ya que el routing es dinámico y viene determinado por un endpoint en WordPress. Lo que hemos hecho en este caso es que el propio WS de sitemap nos dé la plantilla de página asociada a cada ruta, y con eso determinamos el componente a utilizar, como podemos ver en el método convertSitemapItem2Route.
private convertSitemapItem2Route(item: any): Route{
let componentToRoute: Type<any>;
let title = '';
switch(item.template){
case "home":
componentToRoute = HomeComponent;
break;
case "page":
componentToRoute = SimplePageComponent;
title = item.title;
break;
case "simple-page":
componentToRoute = SimplePageComponent;
title = item.title;
break;
[…]
return route;
}
Como hemos explicado en el punto anterior, este es el método que se usa para componer las rutas de la aplicación.
Respecto a la jerarquía, el WS devuelve la estructura de forma jerárquica y componemos las rutas de la misma forma usando el sistema de jerarquía que proporciona Angular en su routing,
Actualización de rutas en carga inicial
Un punto vital es entender que en el momento de inicialización de la aplicación no tenemos las rutas cargadas, ya que todavía no hemos hecho la llamada al WS de WordPress que nos las proporcione. Por tanto, tenemos que ser capaces de realizar esta carga tan pronto como sea posible. Esto lo conseguimos con mediante un servicio usado como un provider.
Lo que desarrollamos es un servicio con un método que realiza la carga, y nos aseguramos que se llama este método en la inicialización de la aplicación.
Por tanto, tenemos un servicio que llamamos settingsService con un método loadSettings. Este método se encarga de recuperar las rutas del endpoint de Angular y actualizar la configuración de nuestro routing.
Para asegurarnos de que este método se llama en la inicialización de la aplicación lo metemos como provider del app-module.
export function initSettings(settings: SettingsService) {
return (): Promise<any> => {
return settings.loadSettings();
}
}
providers: [
{
'provide': APP_INITIALIZER,
'useFactory': initSettings,
'deps': [SettingsService],
'multi': true,
},
Mejora de rendimiento para visitantes reincidentes
Con el objetivo de mejorar el rendimiento de los visitantes reincidentes y partiendo de la base de que nuestro mapa de rutas es relativamente estable (asumimos un pequeño porcentaje de error temporal que se pueda ocasionar en algún visitante), lo que hacemos es que al recuperar las rutas de nuestro WS de WordPress las guardamos en local storage, y en la carga inicial, en caso de que tengamos unas rutas cargadas en local storage, son estas las que usamos para generar el árbol de routing de navegación.
public loadSettings() {
return new Promise<void>((resolve, reject) => {
const router = this.injector.get(Router);
let sitemapLs = this.localStorage.getItem('menu.sitemap');
if(sitemapLs){
let sitemap = JSON.parse(sitemapLs);
this.setSettingsRouter(router, sitemap.sitemapItems);
resolve();
} else {
this.siteMapsUrlsSubscription = this.navigationService.getSiteMapUrls()
.subscribe({
next: (sitemap) => {
this.localStorage.setItem('menu.sitemap', JSON.stringify(sitemap));
this.setSettingsRouter(router, sitemap.sitemapItems);
resolve();
}
});
}
});
}
Sabemos que esto puede causar algún error, pero sería mínimo, primero porque sabemos que nuestras rutas son bastante estables y segundo por el siguiente punto que vamos a explicar.
¿Qué pasa si cambian las rutas mientras navegamos en la web?
Bien, aunque es algo improbable porque nuestras rutas son bastante estáticas, es algo que puede llegar a pasar. Para evitar este problema lo que hacemos es consultar cada 30 segundos al WS por el sitemap completo. En este endpoint uno de los campos que devolvemos es la fecha/hora del último cambio de sitemap. Como este mismo campo lo guardamos en localStorage, lo que hacemos es comprobar si son iguales. En caso de ser iguales no hacemos nada más y en caso de que haya algún cambio actualizamos todas las rutas y el local storage.
De esta forma si hay algún cambio en las rutas se actualiza todo el sistema de navegación, pero si no hay cambios no consumimos más recursos de los necesarios.
Para conseguir esta lógica tenemos el método updateSiteMap en el app.component.
private updateSitemap(){
AppComponent.isBrowser.subscribe(isBrowser => {
if (isBrowser) {
this.sitemapSubscription = timer(0, 30000).pipe(
switchMap( () => this.navigationService.getSiteMapUrls() )
).subscribe({
next: (sitemap) => {
let fechaUltModifGuardada = null;
let sitemapLs = this.localStorage.getItem('menu.sitemap');
if(sitemapLs){
let sitemapSaved = JSON.parse(sitemapLs);
fechaUltModifGuardada = sitemapSaved.last_date;
}
if(fechaUltModifGuardada != sitemap.last_date){
this.localStorage.setItem('menu.sitemap', JSON.stringify(sitemap));
this.settingsService.loadSettings();
}
}
});
}
});
}
Este método lo llamamos desde el ngOnInit del componente
ngOnInit(): void {
this.updateSitemap();
}
Componente vacío
Toda la lógica que hemos explicado hasta el momento funciona correctamente si lo que hacemos es iniciar, como cliente, la aplicación en la home (ya carga el routing) y a partir de ahí navegar. Sin embargo, si un usuario accede directamente a cualquier otra página, no funciona porque no es capaz de cargar el componente. Esto que sería raro en una aplicación estándar de gestión en Angular, es muy típico en una aplicación web pública, ya que, si por ejemplo, un usuario localiza una página en un buscador irá directamente a esa página.
Para solventar este problema hemos utilizado un truquillo: el componente vacío.
Este componente, como su propio nombre indica, está vacío. Lo único que hace es navegar hacia la misma url que ha solicitado el usuario, lo que pasa, es que al pasar por todo el ciclo de vida de Angular, cuando navega ya tiene la navegación completa cargada y por tanto carga el componente correcto.
export class EmptyComponent {
constructor(
private router: Router,
private seoService: SeoService
) {
this.router.navigate([this.router.url]);
}
}
Esperamos que os haya gustado el enfoque del proceso y os sirva para que podáis aplicar el routing dinámico en Angular en vuestros proyectos de forma satisfactoria.