Microfrontends IV: Caso Práctico - Integración run-time con Javascript


Venimos del anterior artículo de la serie Microfrontends III: Enfoques y Aproximaciones


Caso Práctico

En este artículo trataremos de resumir los puntos clave para poner en marcha un ejemplo de microfrontends con la aproximación que nos parece más interesante: integración run-time mediante vanilla JS. La idea es dar las pinceladas necesarias para poder arrancar un proyecto de este tipo, sin detenernos en cada apartado del código. Es por eso que ponemos a vuestra disposición el código fuente para que podáis explorarlo y curiosearlo a vuestro ritmo. Podéis encontrarlo aqui.

El ejemplo consta de:

  • Aplicación host: se trata de una aplicación extremadamente sencilla que emula un simple dashboard encargado de mostrar dos widgets que implementaremos usando microfrontends. La responsabilidad principal de esta aplicación contenedora será la de cargar y lanzar el renderizado de dichos microfrontends.

  • Microfrontend - Widget de Reloj: aplicación que muestra fecha y hora actuales en función del locale del navegador.

  • Microfrontend - Widget de Citas: aplicación que muestra, gracias a APIs gratuitas, una cita famosa ilustrada con una imágen también aleatoria de fondo.

El resultado será tal y como se muestra en la Figura 1. Se puede acceder al ejemplo en vivo en la siguiente dirección: mfe.runtime-js.surge.sh.

Figura 1. Aspecto del ejemplo en vivo

Figura 1. Aspecto del ejemplo en vivo

 

Microfrontends

Configuración Dual de webpack

Hemos establecido una doble configuración de webpack en paralelo. La idea es poder cubrir las dos fases principales en el ciclo de vida de un microfrontend:

  • Durante la fase de desarrollo, nos interesa poder levantar nuestro microfrontend de forma standalone, totalmente independiente del resto, sin necesidad de acceder a el a través de la aplicación host. Y eso es posible, ya que un microfrontend no deja de ser una aplicación web (de funcionalidad más reducida eso si).

  • En el momento del despliegue, empaquetaremos toda la aplicación con sus assets y recursos necesarios en un único bundle**, como si de una librería se tratara, con la diferencia de que será consumido en tiempo real por la aplicación host.

**Nota: Esta estrategia de generar un paquete único (1 microfrontend = 1 bundle), tampoco es estrictamente necesaria, pero conceptualmente nos ayuda a mantener cierto orden entre todos los microfrontends, a su gestión (sobre todo cuando se tienen muchos) y a facilitar su descarga por la aplicación host. Si se quiere optimizar al máximo el consumo de los microfrontends, también pueden subdividirse e incluso compartir dependencias. Esto es algo que se está explorando en la actualidad con la feature de webpack 5 llamada module federation.

Nuestra estructura de ficheros quedaría de este modo (Figura 2):

Figura 2. Ficheros de la configuración dual de webpack

Figura 2. Ficheros de la configuración dual de webpack

Aunque esta separación no es estrictamente necesaria, a nosotros nos permite poder hacer ajustes finos a cada setup y nos ayuda a separar responsabilidades. De hecho:

  • El setup standalone se encargará de configurar el servidor de desarrollo de webpack, añadir los source mapping para posibles depuraciones y, muy importante, generar el index.html necesario para poder levantar nuestro microfrontend de forma autónoma. Este setup tendrá un punto de entrada específico en código que llamaremos standalone.entrypoint.tsx.

  • El setup microfrontend, por su lado, se centra en generar un bundle único, embebiendo recursos si fuese necesario. No hay necesidad de generar ningún index.html puesto que será la aplicación host quien lo provea. El punto de entrada para esta configuración también será específico: microfrontend.entrypoint.tsx.

Dispondremos además de sus correspondientes scripts en el package.json para lanzar una build en modo microfrontend (con la intención de hacer un despliegue) o arrancar nuestro servidor de desarrollo (para el día a día del equipo que trabaje con el).

Entrypoint dual

Tal y como se puede intuir del apartado anterior, necesitamos tener un entrypoint dual en nuestra aplicación acorde a las dos configuraciones anteriores de webpack:

Entrypoint microfrontend

Recordemos que el objetivo de construir en modo microfrontend, no es levantar (renderizar) el propio microfrontend sino empaquetarlo para ser consumido por una aplicación host. Por lo tanto, tendremos que ofrecer, el interfaz (en vanilla JS) que permita renderizarlo, tal y como habíamos comentado en artículos anteriores de esta serie:

¿Por qué no usamos una función como punto de entrada para nuestros microfrontends?

¿Recordáis? Pues bien, esto es lo que haremos en microfrontend.entrypoint.tsx, con la pequeña salvedad de que no tendremos una única función sino dos: una para renderizar o montar el microfrontend, y otra para desmontarlo:

// microfrontend-clock/src/microfrontend.entrypoint.tsx

import "./microfrontend.styles";
import React from "react";
import ReactDOM from "react-dom";
import    from "./components";

/**
 * Microfrontend component
 */
const Microfrontend: React.FC = () => <Clock />;

/**
 * Microfrontend public interface
 */
export type MicrofrontendRenderFunction = (container: Element) => void;
export type MicrofrontendUnmountFunction = (container: Element) => boolean;

export interface MicrofrontendInterface {
  render: MicrofrontendRenderFunction;
  unmount: MicrofrontendUnmountFunction;
}

export const MicrofrontendInterface: MicrofrontendInterface = {
  render: (container) => ReactDOM.render(<Microfrontend />, container),
  unmount: (container) => ReactDOM.unmountComponentAtNode(container),
};

Cuando construimos nuestro bundle en modo microfrontend, lo que exportamos hacia afuera es la entidad MicrofrontendInterface. Esta interfaz debe ser idéntico, o al menos compatible, entre todos los microfrontends para que la aplicación o aplicaciones hosts que lo consuman sepan en todo momento como hacerlo.

MicrofrontendInterface es una simple API con dos métodos, render y unmount. Estos métodos serán llamados por la aplicación host cuando lo precise, para ‘pintar’ el microfrontend o para desmontarlo. Será la aplicación host quien provea el container (nodo del DOM de su propiedad) donde debe renderizarse el microfrontend. Fíjate que cada microfrontend hace uso de su propio ReactDOM sin interferir con el de la aplicación host.

Entrypoint standalone

¿Y si queremos levantar nuestro microfrontend, de forma independiente, sin integrarlo con la aplicación host? ¿Qué necesitamos? Pues lo más sencillo será consumir y llamar al interfaz que se expone en microfrontend.entrypoint.tsx. Es decir, emularemos lo que haría la aplicación host real mediante una aplicación host lite cuya misión es proporcionar un index.html básico en donde poder renderizar el microfrontend.

De este modo, nuestro standalone.entrypoint.tsx quedaría algo tan sencillo como:

// microfrontend-clock/src/standalone.entrypoint.tsx

import "./standalone.styles";
import    from "./microfrontend.entrypoint";

MicrofrontendInterface.render(document.getElementById("root"));

Importamos y ejecutamos el método render de nuestro MicrofrontendInterface pasándole un nodo container improvisado (con id = root) en un index.html hecho a tal fin:

<!-- microfrontend-clock/src/index.html -->

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
  <title>Microfrontend</title>
</head>

<body>
  <div id="root"></div>
</body>

</html>

Recordemos, será el setup standalone de webpack quien ponga en marcha el servidor de desarrollo y lo alimentará con un index.html que generará usando como plantilla el index.html que encontramos en el código fuente.

 

Aplicación Host

Descarga de los microfrontends

La aplicación host debe integrar en tiempo real los microfrontends que la conforman. En el apartado anterior vimos que cada microfrontend será desplegado como un único bundle, es decir, un script JS que exporta una interfaz sencilla para montar y desmontar el microfrontend en cuestión.

Las preguntas que cabe hacerse son:

¿Dónde están esos bundles?

Esto dependerá del proyecto en cuestión. Es posible tener un server dedicado a alojar los bundles de los microfrontends, independiente del servidor que aloja la aplicación host. O podríamos tener un único server que aloje ambas cosas. Lo habitual en proyectos reales, por sus ventajas, es implementar el primer escenario. En nuestro ejemplo, por sencillez y rapidez, haremos el segundo.

Para esto, aprovecharemos el servidor de desarrollo que nos ofrece webpack y le indicaremos que incorpore como recursos públicos los bundles de los dos microfrontends que estamos desarrollando, alojándolos en una subcarpeta llamada microfrontends:

// app/config/webpack.dev.js
...
devServer: {
      contentBase: [
        helpers.resolveFromRootPath("../microfrontend-clock/build/microfrontend/"),
        helpers.resolveFromRootPath("../microfrontend-quote/build/microfrontend/"),
      ],
      contentBasePublicPath: "/microfrontends",
      inline: true,
      host: "localhost",
      port: 3000,
      stats: "minimal",
      historyApiFallback: true,
      hot: true,
    },
 ...

¿Cómo los descargamos?

De nuevo, como se trata de un ejemplo sencillo de demostración, plantearemos la estrategia de descarga más fácil posible. Añadiremos estos bundles en nuestro index.html de la aplicación host:

<!-- app/src/index.html -->

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
  <title>App</title>

  <!-- Microfrontend scripts -->
  <script src="microfrontends/clock.js" type="text/javascript"></script>
  <script src="microfrontends/quote.js" type="text/javascript"></script>

</head>

<body>
  <div id="root"></div>
</body>

</html>

Es importante reseñar que, al añadir estos scripts, estamos indicando que son recursos que deben descargarse y ejecutarse de forma síncrona, antes incluso de poner en marcha la aplicación host. Esta descarga síncrona encaja en nuestro proyecto de ejemplo: la aplicación host solo muestra una página inicial renderizando ambos microfrontends en ella. Es decir, los necesita desde el principio.

Sin embargo, habrá escenarios donde la descarga de los microfrontends deba ser optimizada, puesto que no necesitarán ser consumidos desde el principio, sino bajo demanda, cuando llegue el momento de ser renderizados. Pensemos en una aplicación host con distintas páginas y diferentes microfrontends en ellas. Estos ejemplos, con routing y lazy loading, son más avanzados y los dejaremos para futuros artículos.

Render y Unmount de los microfrontends

LLega el momento clave, tenemos todas las piezas del puzle dispuestas: microfrontends empaquetados, con interfaz de consumo estandarizada, en bundles que ya han sido descargados por la aplicación host en funcionamiento … tan solo tenemos que consumir esa interfaz e indicar el nodo en el que deben renderizarse.

Pues bien, a tal efecto, vamos a implementar un componente en React que se encargue precisamente de esto, consumir la interfaz de un microfrontend (cuyo bundle ha sido previamente descargado y ejecutado) y ofrecer un nodo en el DOM en el que renderizarse:

// app/src/microfrontend-render.component.tsx

import React from "react";

// Tipado común de la interfaz de Microfrontends.
type MicrofrontendRenderFunction = (container: Element) => void;
type MicrofrontendUnmountFunction = (container: Element) => boolean;

interface MicrofrontendInterface {
  render: MicrofrontendRenderFunction;
  unmount: MicrofrontendUnmountFunction;
}

type RegisteredMicrofrontends = "MicrofrontendClock" | "MicrofrontendQuote";

// Componente Microfrontend Render
export interface MicrofrontendRenderProps {
  microfrontend: RegisteredMicrofrontends;
}

export const MicrofrontendRender: React.FC<MicrofrontendRenderProps> = ({ microfrontend }) => {
  const containerRef = React.useRef();

  React.useEffect(() => {
    // Línea clave, ¿dónde puedo encontrar el interfaz de mi microfrontend cargada por <script>?
    const microfrontendInterface: MicrofrontendInterface =
      window[microfrontend]?.MicrofrontendInterface;
    microfrontendInterface?.render(containerRef.current);

    return () => microfrontendInterface?.unmount(containerRef.current);
  }, [microfrontend]);

  return <div ref={containerRef} />;
};

En primer lugar, fíjate que el componente acepta como propiedad única el nombre del microfrontend que deseamos renderizar. Este nombre, que puedes ver definido en RegisteredMicrofrontends para cada uno de los microfrontends que tenemos disponibles, es de gran importancia ya que debe ser el mismo nombre con el que los hemos exportado en su configuración de webpack (para el setup de microfrontend).

Además, observa que el componente MicrofrontendRender renderiza un elemento div al que le extrae su referencia en el DOM que servirá como nodo container para el microfrontend que vamos a renderizar. Es decir, allá donde se ‘pinte’ este componente MicrofrontendRender, estaremos colocando el nodo donde se enganchará el microfrontend.

Finalmente, para consumir el interfaz del microfrontend utilizaremos un effect de React encargado de localizarlo y llamar al método render. ¿Pero … donde encontramos dicho interfaz? Por facilidad de consumo, hemos exportado cada microfrontend como una variable global. El nombre de esta variable es el que vemos en RegisteredMicrofrontends, así pues accederemos por ejemplo a:

window[MicrofrontendClock]

para consumir el microfrontend con el widget de reloj. Allí encontraremos la entidad MicrofrontendInterface que exportan todos los bundles que siguen nuestro estándar, y esta entidad a su vez contendrá los métodos render y unmount. Es importante seguir un criterio limpio a la hora de registrar estos microfrontends con variables globales para evitar colisiones.

Nótese también como el mismo effect devuelve una función de limpieza encargada de llamar al método unmount del microfrontend cuando el componente MicrofrontendRender muera.

Con este componente MicrofrontendRender conseguimos abstraer la tarea de levantar un microfrontend en React. Tan sólo tendremos que crear una instancia pasándole el nombre del microfrontend en cuestión allá donde queramos ‘pintarlo’. Así de sencillo y limpio quedaría nuestro árbol de componentes para el dashboard de la aplicación host:

// app/src/dashboard.component.tsx

export const Dashboard: React.FC = () => {
  const [name] = React.useState("Dashboard");

  return (
    <main className={styles.container}>
      <div className={styles.header}>
        <img src={WorldImage} className={styles.image} />
        <h1 className={styles.title}>{`Welcome to my ${name}!`}</h1>
      </div>
      <MicrofrontendRender microfrontend="MicrofrontendClock" />
      <MicrofrontendRender microfrontend="MicrofrontendQuote" />
    </main>
  );
};
 

Conclusiones

El objetivo de esta serie de artículos es descubrir al lector que se esconde tras la palabra microfrontends y proporcionarle un punto de partida para construir aplicaciones web de forma modular.

La arquitectura de microfrontends es aquella con la cual rompemos monolitos en piezas más pequeñas, inspirada en la arquitectura distribuida de microservicios, con el objetivo de generar un sistema modular de sub-aplicaciones, con una funcionalidad acotada, más sencillas, ligeras e independientes.

Hemos repasado sus numerosos y deseables beneficios: reducir complejidad y acoplamiento, mejorar mantenibilidad y testeo, permitir funcionalidad escalable y upgrades incrementales, así como flujos de trabajo totalmente independientes, incluyendo equipos, repositorios y despliegues. El precio a pagar es el reto de orquestar adecuadamente todos los microfrontends, planificando con cuidado los interfaces, tecnologías y elementos comunes a compartir entre todos. Esta arquitectura encajará en grandes proyectos con funcionalidad fácilmente aislable, no así en productos sencillos o altamente acoplados.

No existe un camino único para poner en marcha un sistema de microfrontends, sino multitud de aproximaciones e implementaciones válidas. Aunque, según nuestra experiencia, lo más flexible es integrar estos microfrontends en tiempo de ejecución: cada microfrontend es responsable de renderizarse a si mismo, siendo una aplicación host quien los integre en tiempo real, decidiendo dónde y cuándo. A tal efecto, pueden utilizarse iframes, web components o funciones en vanilla JS, siendo esta última nuestra elección.

En el caso práctico propuesto hemos desarrollado un ejemplo funcional básico con este enfoque. Hemos aprendido a establecer una configuración dual para los microfrontends, de modo que puedan levantarse como una aplicación independiente, o empaquetarse exportando una interfaz conocida. Dicha interfaz será consumida por la aplicación host durante su ejecución, y para conseguirlo hemos implementado un sencillo componente en React encargado de renderizar y desmontar microfrontends.

Desafíos

¿Y ahora qué? Tan sólo hemos visto la punta del iceberg. En un proyecto real aparecerán, con toda probabilidad, un buen puñado de desafíos, por ejemplo:

  • Descarga asíncrona o lazy de los microfrontends. Es mejor descargarlos únicamente cuando sean necesarios y no todos de golpe al principio. Para resolver este problema podríamos utilizar 2 features de webpack como el code-splitting más la sintáxis dinámica de importación. O bien podemos construirnos una utilidad vanilla que haga lo mismo. En el primer caso, cada microfrontend es responsable de descargarse asíncronamente a si mismo, mientras que en el segundo caso es la aplicación contendora quien lanza estas descargas a demanda.

  • Colisión de nombres de clases. Con independencia de la solución/tecnología de estilado que escojamos, es crítico evitar una posible colisión de nombres de clases, problemática que sucede con bastante frecuencia entre microfrontends. La forma sencilla de resolverlo es aplicando un prefijo ,a modo de namespace, a las clases generadas por cada microfrontend. Su implementación difiere en cada tecnología. Para CSS nativo habrá que usar CSS Modules en webpack y añadir este prefijo. En JSS compartiremos entre todos los microfrontends una misma instancia de la función que construye los classnames. En otras tecnologías como emotion habrá que crear instancias custom de la librería para tener un control a bajo nivel y poder pasarle el namespace deseado.

  • Enrutado en la aplicación host + enrutado en microfrontend. Es habitual tener enrutado en la aplicación host, pero ¿y si además también queremos microfrontends multi vista con sus rutas particulares? En estos casos tendremos un router principal en la aplicación host y múltiples routers secundarios en cada microfrontend. Tendrán que compartir su history y además, para evitar colisiones en las rutas, no queda otra que sincronizarse: la aplicación principal establecerá la raíz de las URLs y cada microfrontend añadirá su porción de ruta a continuación.

  • Un caso particularmente complejo del escenario anterior se daría cuando además puedan visualizarse simultáneamente varios microfrontends multi página en una misma vista (o página) de la aplicación host. En estos casos hay que recurrir a soluciones creativas para poder codificar o expresar múltiples rutas en una única URL, utilizando, por ejemplo, query params.

  • Estado global compartido entre microfrontends. En muchas ocasiones vamos a necesitar que los microfrontends puedan comunicarse entre si. Una posible solución consiste en disponer de un estado global que la aplicación host compartirá hacia los microfrontends. Podemos recurrir a alguna librería de gestión de estado, pero también conviene explorar soluciones taylor-made o in-house, que nos abstraigan de tecnologías y nos den todo el control que podamos necesitar en un futuro, como por ejemplo, implementar mecanismos de suscripción y notificación de cambios de estado.

No es un camino sencillo y cada paso está plagado de retos. Pero si se hace correctamente, el resultado es un sistema modular potente, flexible y duradero.