Tu primer microfrontend de Angular

Esta es una guía paso a paso sobre cómo crear una simple aplicación angular que consume módulos de otra aplicación en tiempo de ejecución.

· 7 min de lectura
Tu primer microfrontend de Angular

La mayoría de la gente te dirá que los microfrontend son como los microservicios pero en lugar de hacerlo sólo en el backend, el concepto se traslada a hacerlo en el frontend encapsulando ciertos módulos, componentes, páginas en partes independientes y consumiéndolos dinámicamente.

Esto significa que podrías tener una aplicación corriendo en el puerto 5000 y en otra aplicación corriendo en el puerto 3000 puedes usar módulos de la otra aplicación sin tenerlos implementados en la aplicación del puerto 3000.

Todo esto es cierto, pero yo diría que hay más cosas que hacen que los microfrontend sean interesantes.

¿Por qué debería usar microfrontend?


⚠️ No todo el mundo en realidad, la mayoría de la gente debería usar microfrontends porque complican las cosas. La sobreingeniería nunca es buena, pero si ves que una de las siguientes razones se aplica a tu situación, los microfrontend podrían ser interesantes.

  1. Tienes muchos equipos de frontend trabajando en diferentes características. En ese caso, una arquitectura de microfrontend es menos una decisión técnica que una decisión de negocio, porque permitirá a los equipos trabajar realmente de forma independiente. De lo contrario, trabajar con un monolito a veces puede ser difícil porque nunca sabes con certeza si tus cambios pueden afectar a otro equipo de alguna manera.
  2. Tu aplicación es demasiado grande. Si te encuentras sin saber dónde poner los modelos, componentes, módulos, ... porque la estructura de tu proyecto es enorme, entonces los microfrontend te ayudarán a crear una mejor separación de intereses. Al utilizar un monorepositorio Nx y crear librerías compartidas te ves obligado a pensar más cuidadosamente en la arquitectura y hacer cosas descuidadas será más difícil.
  3. Si quieres hacer despliegues independientes. Podrías hacer un pequeño cambio en uno de los microfrontend y entonces no necesitarías volver a desplegar toda la aplicación, sino que podrías desplegar sólo el microfronted que ha sido cambiado.

En conclusión, incluso reduciría las razones que hablan a favor de los microfrontends que he mencionado a esta única línea:

Los microfrontends son interesantes si tienes una organización enorme o una aplicación enorme.

¿Qué son los monorrepositorios?

Mencioné que nuestras aplicaciones vivirán en un monorepositorio eso significa que están todas en el mismo espacio de trabajo o repositorio.

Los monorepositorios tienen muchos atractivos diferentes, pero el mayor de ellos es que resuelven muchos conflictos de versiones porque no necesitamos publicar las bibliotecas en npm, sino que las consumimos directamente desde el proyecto.

Esto también significa que sólo tenemos un angular.json y un package.json para que tengamos las mismas versiones de paquetes en cada proyecto. Por esta regla no sería posible tener diferentes versiones de angular o cualquier otra versión de paquete que varíe.

En este tutorial usaremos Nx para crear un espacio de trabajo que básicamente es un monorepositorio en el que podemos almacenar múltiples aplicaciones y librerías. Incluso podríamos mezclar proyectos de diferentes frameworks en este espacio de trabajo.

Cree un espacio de trabajo Nx.

npx create-nx-workspace@latest

Te pedirá que especifiques el nombre del espacio de trabajo y una primera aplicación. Ya puedes seleccionar Angular y dar un nombre a tu aplicación, pero no sería un problema si empiezas con un espacio de trabajo vacío ya que añadiremos otro proyecto de todos modos.

Instalar Nx

npm install -g nx

Crear una aplicación Angular (remota)

nx generate @nrwl/angular:application remote

Añadir el plugin module federation  a ambas aplicaciones

ng add @angular-architects/module-federation --project=remote
ng add @angular-architects/module-federation --project=shell

Aplicación remota


Así que ahora vamos a crear un simple AbcModule que sólo se dirige al componente Abc en blanco. Luego expondremos este módulo ajustando el webpack.config.js

Crear AbcModule + Routing

ng generate module abc --project=remote --routing

Crear un componente Abc

ng generate component abc --project=remote

Configurar abc-routing.module.ts

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AbcComponent } from './abc.component';

const routes: Routes = [
  {
    path: '',
    component: AbcComponent
  }
];

@NgModule({
  declarations: [AbcComponent],
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class AbcRoutingModule { }

Configurar app.module.ts con rutas

(esto no es necesario para el microfrontend. Simplemente para que el microfrontend funcione también de forma independiente)

import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';
import { NxWelcomeComponent } from './nx-welcome.component';
import { RouterModule, Routes } from '@angular/router';

const routes: Routes = [
  {
    path: '',
    loadChildren: () => import('./abc/abc.module').then(m => m.AbcModule)
  }
];

@NgModule({
  declarations: [AppComponent, NxWelcomeComponent],
  imports: [
    BrowserModule,
    RouterModule.forRoot(routes, { initialNavigation: 'enabledBlocking' }),
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

Exponer AbcModule en webpack.config.js

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const mf = require("@angular-architects/module-federation/webpack");
const path = require("path");
const share = mf.share;

const sharedMappings = new mf.SharedMappings();
sharedMappings.register(
  path.join(__dirname, '../../tsconfig.base.json'),
  [/* mapped paths to share */]);

module.exports = {
  output: {
    uniqueName: "remote",
    publicPath: "auto"
  },
  optimization: {
    runtimeChunk: false
  },
  resolve: {
    alias: {
      ...sharedMappings.getAliases(),
    }
  },
  experiments: {
    outputModule: true
  },
  plugins: [
    new ModuleFederationPlugin({
        library: { type: "module" },

        // here you can expose your components and modules:
        name: "remote",
        filename: "remoteEntry.js",
        exposes: {
            './Module': './apps/remote/src/app/abc/abc.module.ts',
        },

        // this specifies the shared libraries so that these libraries are singelon instances -
        // meaning that every application consumes the same:
        shared: share({
          "@angular/core": { singleton: true, strictVersion: true, requiredVersion: 'auto' },
          "@angular/common": { singleton: true, strictVersion: true, requiredVersion: 'auto' },
          "@angular/common/http": { singleton: true, strictVersion: true, requiredVersion: 'auto' },
          "@angular/router": { singleton: true, strictVersion: true, requiredVersion: 'auto' },

          ...sharedMappings.getDescriptors()
        })

    }),
    sharedMappings.getPlugin()
  ],
};

El archivo remoteEntry.js se autogenera y es necesario para dar instrucciones a las aplicaciones externas que contienen las configuraciones de webpackconfig.

Expone dice qué mapeos deben ser compartidos y qué cosas expone. Si ejecutas la aplicación remota también podrías simplemente mirar este archivo añadiéndolo a la url así: https://localhost:4201/remoteEntry.js

Aplicación Shell


Ahora lo único que queda es el consumo real del microfrontend. Hay básicamente dos formas de hacerlo: Estática y Dinámica.

Haciendo static tendríamos que definir los remotos en el webpack.config.json y también declarar los módulos para decirle a typescript que no debe preocuparse por los módulos remotos que no puede compilar. Esto es un poco tedioso y además dinámico es mucho mejor porque teóricamente podríamos incluso cargar las configuraciones del microfrontend desde un backend.

Ajustar y configurar las remotas en webpack.config.js

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const mf = require("@angular-architects/module-federation/webpack");
const path = require("path");
const share = mf.share;

const sharedMappings = new mf.SharedMappings();
sharedMappings.register(
  path.join(__dirname, '../../tsconfig.base.json'),
  [/* mapped paths to share */]);

module.exports = {
  output: {
    uniqueName: "shell",
    publicPath: "auto"
  },
  optimization: {
    runtimeChunk: false
  },   
  resolve: {
    alias: {
      ...sharedMappings.getAliases(),
    }
  },
  experiments: {
    outputModule: true
  },
  plugins: [
    new ModuleFederationPlugin({
        library: { type: "module" },

        // leave this empty to have dynamic module federation:
        remotes: {},

        shared: share({
          "@angular/core": { singleton: true, strictVersion: true, requiredVersion: 'auto' }, 
          "@angular/common": { singleton: true, strictVersion: true, requiredVersion: 'auto' }, 
          "@angular/common/http": { singleton: true, strictVersion: true, requiredVersion: 'auto' }, 
          "@angular/router": { singleton: true, strictVersion: true, requiredVersion: 'auto' },

          ...sharedMappings.getDescriptors()
        })
        
    }),
    sharedMappings.getPlugin()
  ],
};

Cargar el microfrontend de forma perezosa cuando se activa la ruta

import { loadRemoteModule } from "@angular-architects/module-federation";
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";

const routes: Routes = [
  {
    path: 'microfrontend',
    loadChildren: () => loadRemoteModule({
      remoteEntry: 'http://localhost:4201/remoteEntry.js',
      type: 'module',
      exposedModule: './Module'
    })
    .then(m => m.AbcModule)
  }
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes)
  ],
  exports: [RouterModule]
})
export class AppRoutingModule {}

loadRemoteModule es una alternativa a definir estáticamente el módulo en el webpack.config.json y referenciarlo en las rutas. De esta manera sólo tenemos que especificar la configuración en el router y podríamos cargarlo dinámicamente desde otras fuentes también.

Y no olvides importar también AppRoutingModule en el AppModule ;)

<a routerLink="microfrontend">click me to load remote</a>

<router-outlet></router-outlet>

Let’s go!

Ahora todo está preparado para ejecutarse y debería funcionar. Vamos a iniciar ambas aplicaciones, pero ten en cuenta que debemos iniciar los microfronteds antes que el shell.
nx serve remote
nx serve shell

¿No es una locura? Estamos utilizando un módulo que ni siquiera vive en nuestra aplicación, sino que se ejecuta en su propia aplicación. Y al hacer la carga dinámica podríamos gestionar los microfronteds en el backend y decidir referenciar ciertos módulos un día y otros módulos al día siguiente.

Conclusión


Creo que los microfrontends y la federación de módulos son realmente interesantes aunque también creo que es bueno que no todas las empresas lo utilicen. Me ayudó a tener las cosas más organizadas en mi frontend y me obligó a dedicar más tiempo a pensar en la arquitectura, lo que en última instancia me llevó a un mejor software. Conlleva mucho tiempo pasar de un monolito a un monorrepositorio Nx con bibliotecas y microfrontends, pero al final me mereció la pena.

Hay escenarios aún más interesantes donde la federación de módulos es interesante. Uno de ellos sería si tu estás tratando de migrar una aplicación AngularJs legado a Angular. Usando Microfrontends podrías pasar gradualmente a un nuevo framework.

Fuente