En el artículo anterior de esta serie, he mostrado cómo utilizar Webpack Module Federation para cargar Micro frontends compilados por separado en un shell. Como la configuración de webpack del shell describe los Micro Frontends ya definidos.
En este artículo, estoy asumiendo una situación más dinámica donde el shell no conoce el Micro Frontend por adelantado. En su lugar, esta información se proporciona en tiempo de ejecución a través de un archivo de configuración. Mientras que este archivo es un archivo JSON estático en los ejemplos mostrados aquí, su contenido también podría venir de una API Web.
Importante: Este artículo está escrito para Angular y Angular CLI 14 o superior.
La siguiente imagen muestra la idea descrita en este artículo:
Este es el ejemplo de configuración de los Micro Frontends de los que el shell necesita encontrar en tiempo de ejecución, estos se estan mostrado en el menú y al hacer clic en él, este es cargado y mostrado por el router del la shell.
📂 Código fuente (versión simple, branch: simple)
📂 Código fuente (versión completa)
Dinamico y simple
Vamos a empezar con un enfoque simple. Para esto, asumimos que conocemos los Micro Frontends por adelantado y solamente queremos cambiar sus URLs en tiempo de ejecución, por ejemplo, con respecto al entorno actual. Un enfoque más avanzado, en el que ni siquiera necesitamos conocer el número de Micro Frontends por adelantado, se presenta a continuación.
Agregando Modulo Federation
El proyecto de demostración que utilizamos contiene un shell y dos Micro Frontends llamados mfe1 y mfe2. Como en el artículo anterior, añadimos e inicializamos el plugin Module Federation para los Micro Frontends:
npm i -g @angular-architects/module-federation -D
ng g @angular-architects/module-federation --project mfe1 --port 4201 --type remote
ng g @angular-architects/module-federation --project mfe2 --port 4202 --type remote
Generación de un Manifiesto
A partir de la versión 14.3 del plugin, podemos generar un host dinámico que toma los datos escenciales sobre los Micro Frontend de un archivo json.
ng g @angular-architects/module-federation --project shell --port 4200 --type dynamic-host
Esto genera una configuración de webpack, el manifiesto y agrega código en el main.ts para cargar el manifiesto que se encuentra projects/shell/src/assets/mf.manifest.json
.
El manifiesto contiene la siguiente definicion:
{
"mfe1": "http://localhost:4201/remoteEntry.js",
"mfe2": "http://localhost:4202/remoteEntry.js"
}
Es importante, después de generar el manifiesto, asegúrarnos que los puertos coinciden.
Cargando el manifiesto
El archivo main.ts
generado carga el manifiesto:
import { loadManifest } from '@angular-architects/module-federation';
loadManifest("/assets/mf.manifest.json")
.catch(err => console.error(err))
.then(_ => import('./bootstrap'))
.catch(err => console.error(err));
Por defecto, loadManifest
no sólo carga el manifiesto sino también las entradas remotas a las que apunta el manifiesto. Por lo tanto, Module Federation obtiene todos los metadatos necesarios para obtener los Micro Frontends bajo demanda.
Carga de los Micro Frontends
Para cargar los Micro Frontends descritos por el manifiesto, utilizamos las siguientes rutas:
export const APP_ROUTES: Routes = [
{
path: '',
component: HomeComponent,
pathMatch: 'full'
},
{
path: 'flights',
loadChildren: () => loadRemoteModule({
type: 'manifest',
remoteName: 'mfe1',
exposedModule: './Module'
})
.then(m => m.FlightsModule)
},
{
path: 'bookings',
loadChildren: () => loadRemoteModule({
type: 'manifest',
remoteName: 'mfe2',
exposedModule: './Module'
})
.then(m => m.BookingsModule)
},
];
La opción type: 'manifest'
hace que loadRemoteModule
busque los datos clave necesarios en el manifiesto cargado y la propiedad remoteName
apunta a la clave que se utilizó en el manifiesto.
Configuración de los Micro Frontends
Esperamos que ambos Micro Frontends proporcionen un NgModule
con sub-rutas a través de './Module'.
Los NgModules se exponen a través del webpack.config.js
en los Micro Frontends:
// projects/mfe1/webpack.config.js
const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');
module.exports = withModuleFederationPlugin({
name: 'mfe1',
exposes: {
// Adjusted line:
'./Module': './projects/mfe1/src/app/flights/flights.module.ts'
},
shared: {
...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
},
});
// projects/mfe2/webpack.config.js
const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');
module.exports = withModuleFederationPlugin({
name: 'mfe2',
exposes: {
// Adjusted line:
'./Module': './projects/mfe2/src/app/bookings/bookings.module.ts'
},
shared: {
...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
},
});
Creando la navegacion
Para cada ruta que carga un Micro Frontend, el AppComponent del shell contiene un routerLink:
<!-- projects/shell/src/app/app.component.html -->
<ul>
<li><img src="../assets/angular.png" width="50"></li>
<li><a routerLink="/">Home</a></li>
<li><a routerLink="/flights">Flights</a></li>
<li><a routerLink="/bookings">Bookings</a></li>
</ul>
<router-outlet></router-outlet>
Eso es todo. Simplemente inicie los tres proyectos (por ejemplo, utilizando npm run run:all). La principal diferencia con el resultado del artículo anterior es que ahora el shell se informa a sí mismo sobre los Micro Frontends en tiempo de ejecución. Si quieres apuntar el shell a diferentes Micro Frontends, sólo tienes que ajustar el manifiesto.
Configurando las rutas Dinamicas
La solución que tenemos hasta ahora es adecuada en muchas situaciones: El uso del manifiesto permite ajustarlo a diferentes entornos sin reconstruir la aplicación. Además, si cambiamos el manifiesto por un servicio REST dinámico, podríamos implementar estrategias como el A/B testing.
Sin embargo, en algunas situaciones es posible que ni siquiera se conozca el número de Micro Frontends por adelantado. Esto es lo que discutimos aquí.
Añadiendo Metadatos Personalizados al Manifiesto
Para configurar dinámicamente las rutas, necesitamos algunos metadatos adicionales. Para ello, es posible que desee ampliar el manifiesto:
{
"mfe1": {
"remoteEntry": "http://localhost:4201/remoteEntry.js",
"exposedModule": "./Module",
"displayName": "Flights",
"routePath": "flights",
"ngModuleName": "FlightsModule"
},
"mfe2": {
"remoteEntry": "http://localhost:4202/remoteEntry.js",
"exposedModule": "./Module",
"displayName": "Bookings",
"routePath": "bookings",
"ngModuleName": "BookingsModule"
}
}
Además de remoteEntry
, todas las demás propiedades son personalizadas.
Tipos para la Configuración Extendida
Para representar nuestra configuración extendida, necesitamos algunos tipos que usaremos en la shell:
// projects/shell/src/app/utils/config.ts
import { Manifest, RemoteConfig } from "@angular-architects/module-federation";
export type CustomRemoteConfig = RemoteConfig & {
exposedModule: string;
displayName: string;
routePath: string;
ngModuleName: string;
};
export type CustomManifest = Manifest<CustomRemoteConfig>;
El tipo CustomRemoteConfig
representa las entradas del manifiesto y el tipo CustomManifest el manifiesto completo.
Creación dinámica de rutas
Ahora, necesitamos una función que itere a través de todo el manifiesto y cree una ruta para cada Micro Frontend descrito allí:
// projects/shell/src/app/utils/routes.ts
import { loadRemoteModule } from '@angular-architects/module-federation';
import { Routes } from '@angular/router';
import { APP_ROUTES } from '../app.routes';
import { CustomManifest } from './config';
export function buildRoutes(options: CustomManifest): Routes {
const lazyRoutes: Routes = Object.keys(options).map(key => {
const entry = options[key];
return {
path: entry.routePath,
loadChildren: () =>
loadRemoteModule({
type: 'manifest',
remoteName: key,
exposedModule: entry.exposedModule
})
.then(m => m[entry.ngModuleName])
}
});
return [...APP_ROUTES, ...lazyRoutes];
}
Esto nos da la misma estructura, que configuramos directamente arriba.
La shell AppComponent
se encarga de unir todo:
@Component({
selector: 'app-root',
templateUrl: './app.component.html'
})
export class AppComponent implements OnInit {
remotes: CustomRemoteConfig[] = [];
constructor(
private router: Router) {
}
async ngOnInit(): Promise<void> {
const manifest = getManifest<CustomManifest>();
// Hint: Move this to an APP_INITIALIZER
// to avoid issues with deep linking
const routes = buildRoutes(manifest);
this.router.resetConfig(routes);
this.remotes = Object.values(manifest);
}
}
El método ngOnInit
accede al manifiesto cargado (todavía está cargado en el main.ts como se muestra arriba) y lo pasa a la funcion buildRoutes
. Las rutas dinámicas recuperadas se pasan al router y los valores de los pares clave/valor en el manifiesto, se ponen en el campo remotesm, estos se utilizan en el template para crear dinámicamente los elementos del menú:
<!-- projects/shell/src/app/app.component.html -->
<ul>
<li><img src="../assets/angular.png" width="50"></li>
<li><a routerLink="/">Home</a></li>
<!-- Dynamically create menu items for all Micro Frontends -->
<li *ngFor="let remote of remotes"><a [routerLink]="remote.routePath">{{remote.displayName}}</a></li>
<li><a routerLink="/config">Config</a></li>
</ul>
<router-outlet></router-outlet>
Ahora, probemos esta solución "dinámica" iniciando el shell y los Micro Frontends (por ejemplo, con npm run run:all).
Algunos detalles más
Hasta ahora, hemos utilizado las funciones de alto nivel proporcionadas por el plugin. Sin embargo, para los casos en los que necesites más control, también hay algunas alternativas de bajo nivel:
loadManifest(...)
: La función loadManifest utilizada anteriormente proporciona un segundo parámetro llamado skipRemoteEntries
. Asignarlo a true evita la carga de los puntos de entrada. En este caso, sólo se carga el manifiesto:
loadManifest("/assets/mf.manifest.json", true)
.catch(...)
.then(...)
.catch(...)
setManifest(...)
: Esta función permite establecer directamente el manifiesto. Es muy útil si se cargan los datos desde otro lugar.
loadRemoteEntry(...)
: Esta función permite cargar directamente el punto de entrada remoto. Es útil si no se utiliza el manifiesto:
Promise.all([
loadRemoteEntry({ type: 'module', remoteEntry: 'http://localhost:4201/remoteEntry.js' }),
loadRemoteEntry({ type: 'module', remoteEntry: 'http://localhost:4202/remoteEntry.js' })
])
.catch(err => console.error(err))
.then(_ => import('./bootstrap'))
.catch(err => console.error(err));
LoadRemoteModule(...)
: Si no quieres usar el manifiesto, puedes cargar directamente un Micro Frontend con loadRemoteModule:
{
path: 'flights',
loadChildren: () =>
loadRemoteModule({
type: 'module',
remoteEntry: 'http://localhost:4201/remoteEntry.js',
exposedModule: './Module',
}).then((m) => m.FlightsModule),
},
En general, creo que la mayoría de la gente utilizará el manifiesto en el futuro. Incluso si uno no quiere cargarlo desde un archivo JSON con loadManifest
, puede definirlo mediante setManifest
.
La propiedad type:'module'
define que se quiere cargar un módulo EcmaScript "real" en lugar de "sólo" un archivo JavaScript. Esto es necesario desde Angular CLI 13. Si cargas cosas no construidas es muy probable que tengas que establecer esta propiedad como script. Esto también puede ocurrir a través del manifiesto:
{
"non-cli-13-stuff": {
"type": "script",
"remoteEntry": "http://localhost:4201/remoteEntry.js"
}
}
Si una entrada del manifiesto no contiene una propiedad de type
, el plugin asume el valor module
.
Conclusión
Usar Module Federation dinamicos proporciona más flexibilidad ya que permite cargar Micro Frontends que no tenemos que conocer en tiempo de compilación. Ni siquiera tenemos que conocer su número por adelantado. Esto es posible gracias a la API en tiempo de ejecución proporcionada por webpack. Para hacer su uso un poco más fácil, el plugin @angular-architects/module-federation lo envuelve muy bien para simplificanos el trabajo.
Traducción en español del artículo original de Manfred Steyer "Dynamic Module Federation with Angular" actualizado el 09-06-2022