Skip to content

Angular Micro-frontends

Updated: at 03:05 AM

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?

Casos de uso

Consideraciones al implementar micro-frontends

¿Cómo implementar micro-frontends?

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:

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:

Proyectos MFE corriendo juntos

Aquí podemos ver ambos proyectos resaltados:

Proyectos MFE resaltando cada proyecto

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:

Proyectos MFE donde el host consume la información compartida

¡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:

Proyectos MFE donde el host y el mfe consumen la información compartida

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:

Proyectos MFE donde el mfe actualiza la información y el cambio se refleja en ambos proyectos

¡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.