En el artículo anterior de esta serie, mostré cómo utilizar Dynamic Module Federation. Esto nos permite cargar Microfrontends - o remotos, que es el término más general en Module Federation - no conocidos en tiempo de compilación. Ni siquiera necesitamos saber el número de Microfrontends por adelantado.
Mientras que el artículo anterior aprovechaba el router para integrar los Microfrontends o remotes disponibles, este artículo muestra cómo cargar componentes individuales. El ejemplo utilizado para esto es un simple diseñador de workflow basado en plugins.
Traducción en español del artículo original de Manfred Steyer "Building A Plugin-based Workflow Designer With Angular and Module Federation" actualizado el 10-06-2021
El diseñador de workflow actúa como un llamado host que carga las tareas de los plugins proporcionados como remotes. Así, pueden ser compilados y desplegados individualmente. Después de iniciar el workflow designer este obtiene una configuración que describe los plugins disponibles:
Tenga en cuenta que estos plugins se proporcionan a través de diferentes orígenes (http://localhost:4201 y http://localhost:4202), y el diseñador de flujo de trabajo se sirve de un origen propio (http://localhost:4200).
Gracias a Zack Jackson y Jack Herrington, que me ayudaron a entender el rater nuevo API para la Federación de Módulos Dinámicos.
Importante: Este artículo está escrito para Angular y Angular CLI 14.x y superior. Asegúrate de que tienes una versión adecuada si pruebas los ejemplos que aquí se describen.
Construyendo los plugins
Los plugins se proporcionan a través de aplicaciones Angular separadas. Para simplificar, todas las aplicaciones son parte del mismo monorepo. Su configuración de webpack utiliza Module Federation para exponer los plugins individuales como se muestra en los artículos anteriores de esta serie:
const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');
module.exports = withModuleFederationPlugin({
name: 'mfe1',
exposes: {
'./Download': './projects/mfe1/src/app/download.component.ts',
'./Upload': './projects/mfe1/src/app/upload.component.ts'
},
shared: {
...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
},
});
Una diferencia con las configuraciones mostradas en los artículos anteriores es que aquí estamos exponiendo directamente componentes independientes. Cada componente representa una tarea que puede ser puesta en el flujo de trabajo.
La combinación de singleton: true
y strictVersion: true
hace que webpack emita un error en tiempo de ejecución cuando el shell y el/los micro frontend(s) necesitan diferentes versiones incompatibles (por ejemplo, dos versiones mayores diferentes). Si omitiéramos strictVersion
o lo pusiéramos en falso, webpack solamente emitiría una advertencia en tiempo de ejecución.
Cargar los Plugins en el Workflow Designer
Para cargar los plugins en el workflow designer, estoy utilizando la función de ayuda loadRemoteModule
proporcionada por el plugin @angular-architects/module-federation
. Para cargar usamos loadRemoteModule
de esta manera
import { loadRemoteModule } from '@angular-architects/module-federation';
[...]
const component = await loadRemoteModule({
type: 'module',
remoteEntry: 'http://localhost:4201/remoteEntry.js',
exposedModule: './Download'
})
Proporcionar metadatos sobre los plugins
En tiempo de ejecución, necesitamos proporcionar al Workflow Designer los datos clave sobre los plugins. El tipo utilizado para esto se llama PluginOptions
y extiende el LoadRemoteModuleOptions
mostrado en la sección anterior por un displayName
y un componentName
:
export type PluginOptions = LoadRemoteModuleOptions & {
displayName: string;
componentName: string;
};
Una alternativa a esto es extender el Manifiesto de la Module Federation como se muestra en el artículo anterior sobre Module Federation Dinamico.
Mientras que el displayName
es el nombre que se presenta al usuario, el componentName
se refiere a la clase TypeScript que representa el componente Angular en cuestión.
Para cargar estos datos clave, el workflow designer aprovecha un LookupService
:
@Injectable({ providedIn: 'root' })
export class LookupService {
lookup(): Promise<PluginOptions[]> {
return Promise.resolve([
{
type: 'module',
remoteEntry: 'http://localhost:4201/remoteEntry.js',
exposedModule: './Download',
displayName: 'Download',
componentName: 'DownloadComponent'
},
[...]
] as PluginOptions[]);
}
}
En aras de la simplicidad, el LookupService
proporciona algunas ya entradas definidas, pero en el mundo real, es muy probable que solicite estos datos desde un punto final HTTP respectivo.
Creando Dinámicamente el Componente del Plugin
El Workflow Designer representa los plugins con un PluginProxyComponent
. Toma un objeto PluginOptions
a través de una entrada, carga el plugin descrito a través de Module Federation Dinámico y muestra el componente del plugin en el placeHolder:
@Component({
standalone: true,
selector: 'plugin-proxy',
template: `
<ng-container #placeHolder></ng-container>
`
})
export class PluginProxyComponent implements OnChanges {
@ViewChild('placeHolder', { read: ViewContainerRef, static: true })
viewContainer: ViewContainerRef;
constructor() { }
@Input() options: PluginOptions;
async ngOnChanges() {
this.viewContainer.clear();
const Component = await loadRemoteModule(this.options)
.then(m => m[this.options.componentName]);
this.viewContainer.createComponent(Component);
}
}
En versiones anteriores a Angular 13, necesitábamos utilizar un ComponentFactoryResolver para obtener la fábrica del componente cargado:
// Before Angular 13, we needed to retrieve a ComponentFactory
//
// export class PluginProxyComponent implements OnChanges {
// @ViewChild('placeHolder', { read: ViewContainerRef, static: true })
// viewContainer: ViewContainerRef;
// constructor(
// private injector: Injector,
// private cfr: ComponentFactoryResolver) { }
// @Input() options: PluginOptions;
// async ngOnChanges() {
// this.viewContainer.clear();
// const component = await loadRemoteModule(this.options)
// .then(m => m[this.options.componentName]);
// const factory = this.cfr.resolveComponentFactory(component);
// this.viewContainer.createComponent(factory, null, this.injector);
// }
// }
Conectando Todo
Ahora, es el momento de conectar las partes mencionadas anteriormente. Para ello, el AppComponent
del workflow designer obtiene un array de plugins y de workflows. El primero representa las PluginOptions
de los plugins disponibles y, por lo tanto, todas las tareas disponibles, mientras que el segundo describe las PluginOptions
de las tareas seleccionadas en la configuracion:
@Component({ [...] })
export class AppComponent implements OnInit {
plugins: PluginOptions[] = [];
workflow: PluginOptions[] = [];
showConfig = false;
constructor(
private lookupService: LookupService) {
}
async ngOnInit(): Promise<void> {
this.plugins = await this.lookupService.lookup();
}
add(plugin: PluginOptions): void {
this.workflow.push(plugin);
}
toggle(): void {
this.showConfig = !this.showConfig;
}
}
El AppComponent
utiliza el LookupService
inyectado para rellenar su array de plugins. Cuando se añade un plugin al workflow, el método add
pone su objeto PluginOptions
en la array del workflow.
Para mostrar el workflow, el diseñador simplemente itera todos los elementos en la array del workflow y crea un plugin-proxy para ellos:
<ng-container *ngFor="let p of workflow; let last = last">
<plugin-proxy [options]="p"></plugin-proxy>
<i *ngIf="!last" class="arrow right" style=""></i>
</ng-container>
Como se ha comentado anteriormente, el proxy carga el plugin (al menos, si no está ya cargado) y lo muestra.
Además, para renderizar el menu de tasks que se muestra a la izquierda, recorre todas las entradas del array de plugins. Para cada uno de ellos muestra un hipervínculo que llama al método add:
<div class="vertical-menu">
<a href="#" class="active">Tasks</a>
<a *ngFor="let p of plugins" (click)="add(p)">Add {{p.displayName}}</a>
</div>
Conclusión
Si bien Module Federation es útil para implementar Micro Frontends, también puede utilizarse para establecer arquitecturas de complementos. Esto nos permite extender una solución existente por parte de terceros. También parece ser un buen ajuste para las aplicaciones SaaS, que necesitan ser adaptadas a las necesidades de diferentes clientes.