Micro-frontends es la introducción de un concepto como el de micro-servicios pero aplicados al desarrollo del lado del cliente. Nos permite tener varios proyectos del lado del frontend conectados entre sí para crear una sola aplicación más grande.
¿Por qué usar Micro-frontends?
- Puedes desacoplar el proyecto en distintas bases de código.
- Es más sencillo entender, extender y mantener el código al manejar proyectos más pequeños.
- Las dependencias pueden ser actualizadas de forma independiente (siempre y cuando no sean dependencias compartidas).
- Los builds son desacoplados y escalables.
- Los artifacts pueden ser publicados de forma independiente con un impacto mínimo a otros proyectos.
- El riesgo de cada publicación se reduce.
- Se pueden tener equipos independientes y especializados en cada proyecto.
- Cada equipo puede trabajar con una tecnología distinta (no es lo recomendado, pero de poder se puede).
Casos de uso
- Migrar/actualizar bases de código gradualmente. Por ejemplo, para un proyecto hecho en AngularJS puedes empezar a trabajar en nuevos features usando la última versión de Angular, a medida que se actualiza el código legacy a Angular también.
- Compartir un feature en diferentes aplicaciones. Un solo micro-frontend puede ser utilizado en múltiples proyectos.
- Equipos autónomos trabajan en diferentes features del proyecto, cada uno en un micro-frontend distinto.
- Deployments (publicaciones) independientes. Puedes trabajar en un cambio que afecta un micro-frontend específico y publicarlo sin necesidad de hacer deploy de todo el proyecto.
Consideraciones al implementar micro-frontends
- Evita en lo posible mezclar frameworks/librerías en diferentes micro-frontends al menos que sea estrictamente necesario. El mezclar tecnologías genera bundles más grandes al no permitir el compartir dependencias, además de mezclar conceptos y guidelines al momento del desarrollo.
- Procura manejar dependencias compartidas y crear librerías para el código común, así el mismo puede ser reutilizado. Sin embargo, ten cuidado de no acoplar demasiado el código, esto disminuye la autonomía de los equipos trabajando en cada proyecto.
¿Cómo implementar micro-frontends?
- iframe
- Web Components (custom elements)
- Single-spa
- Module Federation
En este artículo nos concentraremos en esta última opción, Module Federation.
Module Federation
Module Federation es un feature de Webpack, un module bundler muy popular para JavaScript, además de ser la herramienta que usa Angular para este propósito. Con Module Federation podemos habilitar la carga dinámica de módulos de múltiples sistemas independientes, los cual nos habilita para la creación de aplicaciones que siguen una estructura basada en micro-frontends.
Usando Module Federation en Angular
Para poder utilizar Module Federation en Angular, tenemos disponible la siguiente herramienta: @angular-architects/module-federation.
Para ejemplificar su uso, implementaremos tres proyectos, uno que servirá como host o contenedor de la aplicación, un micro-frontend que vendría a llenar el contenido de la aplicación, y una librería con la cual podremos compartir información entre ambas aplicaciones.
Creando el host
Usando el CLI de Angular, ejecutamos el siguiente comando para crear nuestro host:
ng new host --routing
Hay que notar que le pasamos la opción --routing
para que se nos cree un módulo separado que se encargará de definir la configuración de rutas, mediante la cual luego podremos cargar el módulo remoto que crearemos en el otro proyecto.
Lo siguiente que haremos será ingresar al proyecto y en la raíz correr el schematic necesario para agregar el plugin de Module Federation:
ng add @angular-architects/module-federation --type host
Cuando se nos pregunte por el nombre y puerto del proyecto, le daremos el nombre host
y el puerto 4200
. El valor del nombre es el de nuestro proyecto Angular, para el puerto podemos definir el que queramos, mantendremos 4200
para este ejemplo.
Debes notar que al correr el schematic le pasamos una opción --type host
. Existen 3 posibles valores para esta opción type
:
host
dynamic-host
remote
El usar la adecuada nos proporcionará una configuración más acorde al tipo de proyecto, siendo host
y dynamic-host
las opciones usadas para crear un proyecto contenedor, el que se encarga de cargar los proyectos remotos (remote
), la única diferencia entre ambas es que al usar dynamic-host
también se creará un archivo donde podremos definir los paths de los diferentes micro-frontends que vayamos a usar.
Al correr el schematic notaremos cambios en algunos archivos, como ser configuración adicional en el angular.json
file para usar un builder distinto, además de que se agregará una propiedad extraWebpackConfig
que apuntará a la ruta de un archivo recién creado, el webpack.config.js
. En este último archivo tendremos la configuración base necesaria con la ruta de los micro-frontends que vayamos a consumir (remotes
) y las dependencias compartidas (shared
).
Creando el micro-frontend
Igual haremos uso del CLI para crear el proyecto, tomando en cuenta el hacerlo en un path distinto al del host:
ng new mfe
Esta vez no es necesario definir un módulo de rutas, ya que este proyecto solo tendrá feature modules que serán cargados desde el proyecto host
.
Ingresamos al proyecto y corremos el schematic para agregar Module Federation, teniendo cuidado de usar el type remote
:
ng add @angular-architects/module-federation --type remote
El nombre del proyecto es mfe
y para el puerto debemos usar uno distinto al del proyecto host
, en mi caso usaré el 3000
para que haga match con la configuración por defecto que ya se encuentra en el archivo webpack.config.js
en la sección de remotes
del proyecto host
.
Al correr el schematic notaremos cambios muy similares a los que ocurrieron en el proyecto host
, pero veremos algunas diferencias en el webpack.config.js
generado, ya que en vez de tener una propiedad remotes
tendrá una propiedad llamada exposes
. Modificaremos esta propiedad más adelante para que concuerde con el módulo que crearemos y de esta forma poder exponerlo para que sea consumido por el host.
Lo siguiente será crear nuestro feature module, para ello creamos un par de nuevos módulos, el del feature como tal y el de rutas para el mismo:
ng g m feature-one --routing
Y también creamos un componente principal para este módulo:
ng g c feature-one
Nos aseguraremos de tener bien configurado nuestro módulo de rutas recién creado (feature-one-routing.module.ts
). Para ello, definimos que para el path vacío se cargue el componente recién creado:
import { FeatureOneComponent } from './feature-one.component';
const routes: Routes = [
{
path: '',
component: FeatureOneComponent,
},
];
Ahora, a diferencia de un feature module normal cargado por lazy loading, este lo debemos importar en el módulo raíz (app.module.ts
) para que sea parte de la compilación de TypeScript:
import { FeatureOneModule } from './feature-one/feature-one.module';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, FeatureOneModule],
bootstrap: [AppComponent],
})
export class AppModule {}
Ahora sí, volvemos al webpack.config.js
y realizamos la modificación que teníamos pendiente en la propiedad exposes
:
exposes: {
'./FeatureOneModule': './src/app/feature-one/feature-one.module.ts',
},
Debemos prestar atención al nombre de la propiedad que estamos agregando, en este caso ./FeatureOneModule
, ya que será el valor que utilizaremos en el proyecto host
al cargar este módulo.
Ya estamos casi listos para conectar nuestros dos proyectos.
Conectando ambos proyectos
Ve al módulo de rutas del módulo raíz del proyecto host
(app-routing.module.ts
) y define que para la ruta vacía se haga una redirección a la ruta feature-one
. Para esta última ruta usaremos lazy loading para cargar el feature module que creamos en nuestro proyecto mfe
, pero notarás que en vez de importarlo localmente, como se haría usualmente para un módulo dentro del mismo proyecto, lo cargaremos de forma remota:
import { loadRemoteModule } from '@angular-architects/module-federation';
const routes: Routes = [
{
path: '',
redirectTo: 'feature-one',
pathMatch: 'full',
},
{
path: 'feature-one',
loadChildren: () =>
loadRemoteModule({
type: 'module',
remoteEntry: 'http://localhost:3000/remoteEntry.js',
exposedModule: './FeatureOneModule',
}).then((mod) => mod.FeatureOneModule),
},
];
En type
viene el tipo de elemento que estamos cargando, en este caso es un module
.
En remoteEntry
definimos la ruta del archivo remoto, es decir, del proyecto mfe
. El nombre del archivo remoto por defecto lleva el nombre remoteEntry.js
al momento de correr el schematic, sin embargo este es un nombre que podemos modificar si lo quisiéramos en la configuración del proyecto mfe
más adelante. Para este tutorial lo dejaremos tal cual está.
En exposedModule
nos debemos asegurar de poner el nombre que usamos en exposes
de la configuración webpack del mfe
.
Lo que resta por hacer es modificar nuestro template para que luzca como queremos. En este caso agregaremos una barra de navegación sin estilizarla demasiado para este ejemplo, además de tener en posición el elemento router-outlet
, que será donde cargará el contenido de nuestras rutas. Reemplazamos el contenido de app.component.html
por el siguiente:
<nav>
<a routerLink="/feature-one"> Feature One </a>
<!-- Aquí podemos seguir agregando más enlaces a otros feature modules, remotos o locales -->
</nav>
<main>
<router-outlet></router-outlet>
</main>
¡Ya debería funcionar! Si corremos ambos proyectos (ng serve
) y luego accedemos al path del host
(http://localhost:4200
), nuestra aplicación ya debería estar corriendo:
Aquí podemos ver ambos proyectos resaltados:
Muy bien, ahora solo falta tener un mecanismo para poder compartir datos a través de ambos proyectos.
Usando una librería para compartir información
Crearemos una librería para permitir el paso de datos e información entre ambos proyectos. Para ello, primero crearemos un workspace de angular vacío en un path distinto:
ng new libs --no-create-application
Accedemos al workspace y creamos la librería:
ng g library shared-library
En el path: projects/shared-library/src/lib/shared-library.service.ts
encontraremos un servicio donde agregaremos un BehaviorSubject
para ejemplificar cómo podemos compartir información:
import { BehaviorSubject } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class SharedLibraryService {
sharedData$ = new BehaviorSubject<string>('Hello World!');
}
Creamos un distribuible de nuestra librería corriendo el siguiente comando en la raíz de nuestro workspace:
npm run build
Notarás que se creó el distribuible en dist/shared-library
. Accede a esa ruta y crea un symlink global de la librería usando este comando:
npm link
En el archivo package.json
dentro de projects/shared-library
podrás ver el nombre y la versión con la que se creó el symlink de la librería. Si no realizaste ningún cambio, los valores deberían ser shared-library
y 0.0.1
respectivamente.
En cada uno de los proyectos anteriores debes instalar la librería que acabamos de crear creando un symbolic link del paquete global. Ve a la raíz de cada proyecto, detén la ejecución y corre el siguiente comando:
npm link shared-library
Una vez hecho esto, ve al webpack.config.js
file de cada proyecto y modifica la propiedad shared para asegurarte de que se comparta una sola instancia de la librería en ambos proyectos:
shared: {
...shareAll({
singleton: true,
strictVersion: true,
requiredVersion: "auto",
}),
"shared-library": {
singleton: true,
strictVersion: true,
requiredVersion: "0.0.1",
},
},
Debes definir que haya una sola instancia usando la propiedad singleton
, además de asegurarte que ambos proyectos usen la misma versión de la librería con las propiedades strictVersion
y requiredVersion
.
Ahora nos iremos al proyecto host
e inyectaremos el servicio de la librería compartida en el app.component.ts
file para poder consumir la sharedData$
:
import { SharedLibraryService } from 'shared-library';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
data$ = this.sharedService.sharedData$;
constructor(private sharedService: SharedLibraryService) {}
}
Lo siguiente será hacer binding de data$
en el template (app.component.html
):
<nav>
<a routerLink="/feature-one"> Feature One </a>
<!-- Aquí podemos seguir agregando más enlaces a otros feature modules, remotos o locales -->
</nav>
<main>
<p>
{{ data$ | async }}
</p>
<router-outlet></router-outlet>
</main>
Además, agregaremos un pequeño estilo para poder diferenciar este párrafo con la información compartida del que agregaremos en el proyecto mfe
más adelante. En el archivo app.component.css
colocamos lo siguiente:
p {
color: blue;
}
Si corremos ambos proyectos, host
y mfe
, veremos lo siguiente:
¡Genial! El host
ya consume la información compartida de la librería. Nos queda hacer algo muy parecido en el mfe
.
Nos vamos al feature-one.component.ts
del mfe
y hacemos lo mismo, inyectamos el servicio compartido y nos creamos una propiedad pública para guardar la sharedData$
del servicio y mostrarla en el template. De forma adicional agregaremos una función que se encargará de actualizar la información compartida.
import { SharedLibraryService } from 'shared-library';
@Component({
selector: 'app-feature-one',
templateUrl: './feature-one.component.html',
styleUrls: ['./feature-one.component.css'],
})
export class FeatureOneComponent {
data$ = this.sharedService.sharedData$;
constructor(private sharedService: SharedLibraryService) {}
updateSharedData(): void {
this.sharedService.sharedData$.next('Hello from the MFE');
}
}
Nos queda actualizar el template (feature-one.component.html
):
<p>
{{ data$ | async }}
</p>
<button (click)="updateSharedData()">
Update shared data
</button>
Y agregar un estilo (en feature-one.component.css
) para diferenciar este párrafo del que tiene el host
:
p {
color: red;
}
Este será el resultado:
Ahora, solo nos queda asegurarnos de que efectivamente la información se está compartiendo. Para ello fue que agregamos la función que actualiza sharedData$
del servicio (updateSharedData
). Si todo está correcto, al llamar a la función, la información compartida se debería actualizar tanto en el mfe
como en el host
:
¡Lo hicimos!
Apuntes finales
Esta es sola una introducción a cómo podemos empezar a implementar micro-frontends usando Angular. Espero de corazón te sea de mucha ayuda.
Puedes encontrar el código de todo lo que hicimos en este tutorial en el siguiente repositorio: https://github.com/combimauri/my-first-mfes
Mucha más información relacionada a Module Federation y la integración con Angular la verás aquí: https://www.angulararchitects.io/en/aktuelles/the-microfrontend-revolution-module-federation-in-webpack-5/
Si tienes consultas puedes hacerlas por aquí y/o dejármelas en mi GitHub, LinkedIn, Twitter o Threads.
Quiero hacer un agradecimiento especial a mis compañeros de trabajo: Allan Leon Mendieta
, Sergio Lopez
y Diego Garcia
, quienes directa o indirectamente aportaron un montón a este artículo y en base a muchas partes de su investigación es que el mismo pudo ser completado.