Microfrontends III: Enfoques y Aproximaciones


Venimos del anterior artículo de la serie Microfrontends II: Beneficios y Retos


Enfoques

Como resumen de lo visto hasta ahora, queda claro que para poner en marcha una arquitectura de microfrontends tenemos que romper en piezas pequeñas la funcionalidad de nuestro proyecto. Esto no es muy diferente a la filosofía que ya venimos aplicando desde hace tiempo en front-end gracias a la componentización. De hecho, podríamos considerarlo una componentización a un nivel más alto.

Por tanto, tendremos que descomponer nuestra aplicación en componentes ricos (formados por diversos subcomponentes) o módulos (si prefiere verse de esta manera) que aíslen lo máximo posible una funcionalidad concreta con una misión clara y concisa (por ejemplo: mostrar la información de usuario, un módulo de login/autenticación, una galería de productos disponibles o el detalle de un producto con su descripción e imágenes).

En Lemoncode, por ejemplo, venimos apostando desde hace tiempo en organizar nuestros proyectos mediante pods, entendiendo un pod precisamente como una isla de funcionalidad acotada, que a nivel de código englobaría todos los componentes presentacionales, api y lógica de negocio necesaria para cubrir dicha funcionalidad. En muchas ocasiones un pod acaba resultando ser una vista o una página, o bien un widget reusable en diversas vistas. En cualquier caso, esta organización es un muy buen punto de partida para extraer microfrontends.

Pongámonos en situación y supongamos que tenemos nuestro proyecto dividido en módulos, ¿cómo integramos las distintas piezas del puzle? En función de cómo lo hagamos, vamos a distinguir entre dos enfoques diferenciados: integración build-time versus integración run-time.

 

Integración build-time

Este primer enfoque es muy sencillo, consiste en realizar la integración los distintos microfrontends generados en el paso de construcción de la aplicación final (la aplicación principal que integra los microfrontends se la conoce de muchas formas: aplicación host, anfitriona, contenedora o simplemente principal). Dicho de forma sencilla, haremos que nuestra aplicación contenedora consuma los microfrontends como si de librerías se tratase.

Es más, tendremos que publicar nuestros microfrontends como paquetes (lo habitual es alojarlos en un registry privado de nuestra organización), dichos paquetes aparecerán como dependencias en la aplicación contenedora y será, al lanzar el proceso de build, cuando se integre todo el código en una solución definitiva (Figura 1).

Figura 1. Integración build-time

Figura 1. Integración build-time

¿Nos suena familiar, verdad? Muchos de nosotros hemos estado haciendo microfrontends sin saberlo, no es muy distinto a cuando hacemos librerías de componentes comunes para su reuso. Lo cierto es que, si bien este enfoque cumple con algunos de los objetivos vistos para microfrontends, hay quien no lo considera como una aproximación completa a esta arquitectura.

Y el motivo es que entraña numerosas desventajas y dificultades:

  • Añadimos un paso final en el que acoplamos todo a un monolito nuevamente. Necesitaremos lanzar esta build para integrar y desplegar cada cambio en los microfrontends.

  • Dependency hell primera parte: la gestión del versionado se hace complicada entre la aplicación anfitriona y las versiones específicas de los microfrontends de los que dependen.

  • Dependency hell segunda parte: cada microfrontend tiene su propio árbol de dependencias, con sus versiones específicas. Pero muchas de estas librerías exigen ser ejecutadas en modo singleton, es decir, no están pensadas para ejecutarse con múltiples instancias simultáneas en distintas versiones (con la llegada de los microfrontends se está empezando a tomar conciencia de que esto es un problema). Se ha de recurrir a tratarlas como peer dependencies, para que sea la aplicación host quien suministre las dependencias críticas, con lo que acabamos atándonos a versiones específicas que debemos arrastrar entre todos los microfrontends. Por ejemplo, esto sucede con react-dom (encargado de ‘pintar’ los componentes y hacer la gestión del DOM) que debe resolver siempre a la misma instancia de react.

En nuestra opinión, con este enfoque perdemos parte de la ansiada libertad que nos proporcionan los microfrontends a la hora de desarrollarlos como proyectos 100% independientes y por ello creemos que es mucho más interesante, flexible y potente el enfoque siguiente.

 

Integración run-time

En este enfoque, tal y como su nombre indica, la integración la llevaremos a cabo durante la ejecución de nuestra aplicación host. Los microfrontends dejan de ser dependencias de la aplicación principal, no hará falta publicarlos en ningún registry y tampoco tendremos que recurrir a una compilación global de toda la solución cada vez que queramos hacer algún cambio. Simplemente serán consumidos por la aplicación contenedora en tiempo de ejecución (Figura 2).

Cada microfrontend será un proyecto y aplicación independiente. Tendremos libertad para desplegarlo de forma standalone en algún servidor y consumirlo desde allí, o bien empaquetarlo y alojarlo en un servidor de bundles que provea a la aplicación principal de todas sus ‘piezas’. Esto implica independencia completa en el flujo de desarrollo y despliegue. Cada microfrontend podrá escoger sus dependencias, tanto en versión como en tecnología**.

Figura 2. Integración run-time

Figura 2. Integración run-time

La característica clave de este enfoque está en entender que ahora cada microfrontend debe ser responsable de renderizarse a si mismo. Este hecho es, a su vez, la diferencia principal respecto al enfoque anterior. Para conseguirlo, nos encontramos con distintas aproximaciones a su vez, en función del formato en que se exporte y consuma cada microfrontend en tiempo real:

 

Integración mediante iFrames

Es la solución más sencilla posible. Basta con levantar nuestro microfrontend en un servidor de nuestra elección y consumirlo desde la aplicación principal empotrándolo mediante un iframe que apunte a su dirección. Supongamos que desarrollamos un simple reloj como widget microfrontend y lo alojamos en microfrontend.clock.com. Podríamos consumirlo así:

// Host app

export const App: React.FC = () => (
  <>
    <h1>Hello World!</h1>
    <p>I am the host app</p>
    <iframe src="http://microfrontend.clock.com" />
  </>
);

Los iframes son una tecnología madura para compartir y embeber otros documentos web en el documento actual. Se han usado extensamente durante mucho tiempo, y todavía se sigue haciendo, por ejemplo para integrar contenido de YouTube, mapas de Google Maps o comentarios de Disqus. Pero no significa que sea la herramienta más adecuada para microfrontends.

Los iframes son muy indicados como sandbox para incorporar contenido third party a nuestra web app sobre el que NO tenemos un control directo. Sin embargo, para incorporar funcionalidad de los microfrontends, con los que queremos mantener una continuidad en el documento, integrarlos de forma limpia, transparente al usuario, interactuando con ellos, compartiendo información, etc nos limitaría demasiado y no parece ser lo más adecuado.

 

Integración mediante Web Components.

El estándar web components permite la creación de elementos HTML personalizados, llamados custom elements, para encapsular la funcionalidad que deseemos bajo una etiqueta HTML (componente rico).

Por ejemplo, podríamos abstraer nuestro widget de reloj y exportarlo como un <microfrontend-clock/> para así consumirlo de forma fácil y directa en nuestra aplicación contenedora. Una forma muy sencilla como punto de entrada para este microfrontend podría ser:

// Microfrontend Clock app

import React from "react";
import ReactDOM from "react-dom";
import { Clock } from "./components";

// Exportamos como Web component, wrappeamos nuestro <Clock />
export class Microfrontend extends HTMLElement {
  mountPoint: HTMLDivElement;

  connectedCallback() {
    this.mountPoint = document.createElement("div");

    // Versión sin shadow DOM.
    this.appendChild(this.mountPoint);
    ReactDOM.render(<Clock />, this.mountPoint);
  }

  disconnectedCallback() {
    ReactDOM.unmountComponentAtNode(this.mountPoint);
  }
}
window.customElements.define("microfrontend-clock", Microfrontend);


// Host app

export const App: React.FC = () => (
  <>
    <h1>Hello World!</h1>
    <p>I am the host app</p>
    <microfrontend-clock />
  </>
);

En este código se resume esquemáticamente como crear un custom element de la forma más sencilla posible y como consumirlo desde la aplicación host. Nos reservamos para futuros artículos publicar un ejemplo más completo y funcional de esta aproximación donde se muestre también, entre otras claves, la carga y descarga de cada microfrontend en tiempo de ejecución.

A pesar de que los web components han generado mucho ruido en los últimos años y se ha impulsado su adopción desde diferentes sectores de la comunidad front-end, muchos lo consideran una promesa fallida y hay quien directamente los etiqueta como tecnología muerta. Sin entrar en esta polémica, su nivel de uso actual no es el esperado y está por ver si en el futuro toman tracción y su adopción se extiende o quedan relegados a tareas más específicas y minoritarias.

Uno de los posibles motivos por los que no han terminado de despegar es la concepción errónea que mucha gente tiene de ellos. El estándar para web components surge como ‘pegamento’ entre distintas tecnologías, como una carcasa o wrapper que puede ser consumido desde cualquier proyecto, pero no son ningún sustituto de los frameworks actuales que ya existen.

Es decir, frameworks como React o Vue existen para resolver un problema distinto al de los custom elements: abstraernos del DOM. Mientras que los web components, al ser un estándar de bajo nivel, nos devolverá a la ardua tarea de lidiar con el DOM si pretendemos usarlos de forma vanilla, sin ningún framework. Dicho de otro modo, si decidimos apostar por los web components, es altamente deseable trabajar con algún framework, pero una vez elegido nuestro framework, los web components son supérfluos (para compartir componentes de React entre aplicaciones React no necesito transformarlos a nada). Sólo cuando hagamos mezcla de tecnologías** podrían llegar a ser útiles como forma de compatibilizarlas. Pero si nos centramos en romper aplicaciones en micro-aplicaciones y que nos permita mantener, por ejemplo, versiones diferentes de librerías sin conflictos, teniendo comunicación entre módulos … ¿Podríamos plantear una solución nativa agnóstica de tecnología para usar como ‘carcasa’? Veamos la siguiente propuesta.


Integración mediante Javascript.

Los web components se han empleado como carcasa o contenedor para ofrecer y consumir componentes de forma agnóstica a la tecnología con la que han sido creados. Pero tienen algunas limitaciones en compatibilidad y también ciertos inconvenientes. El ejemplo anterior era sencillo, pero la complejidad comienza al añadir propiedades y atributos a los custom elements.

Sin embargo, podemos reemplazar estas carcasas por algo todavía más estándar, más sencillo y 100% compatible en cualquier escenario: ¡vanilla javascript! ¿Por qué no usamos una función como punto de entrada para nuestros microfrontends? Estaremos creando una interfaz común, hecha a medida. La aplicación contenedora tan sólo tendrá que llamar a esta función e indicarle dónde debe renderizarse cada microfrontend. Así de sencillo.

Nos daremos cuenta que no es muy diferente a la solución de web components, pero implementado con vanilla javascript y con la posibilidad de hacer a medida el interfaz que mejor nos cuadre en nuestra arquitectura. Exploraremos esta solución en el siguiente artículo de la serie.


Continuará en Microfrontends IV:


**Nota Importante: Mezclar mútliples tecnologías (como podría ser React, Vue o Angular) en una única aplicación es técnicamente posible en el contexto de microfrontends. Esto podría suceder durante la migración de un framework a otro. Sin embargo, fuera de este escenario, podría considerarse una estrategia peligrosa, incluso en el futuro, mala práctica. Recientemente se ha acuñado un nuevo término para referirse a esto: microfrontends anarchy. Por lo general, es aconsejable que haya consenso de tecnologías y herramientas entre los equipos responsables de cada microfrontend.