Micro Frontends

Extendiendo la idea de microservicio al desarrollo frontend

View project on GitHub
EN JP ES PT KR RU CN IT PL

Técnicas, estrategias y recetas para crear una aplicación web moderna con múltiples equipos que pueden entregar funcionalidades independientemente.

¿Qué son los micro frontend?

El término Micro Frontends apareció por primera vez en ThoughtWorks Technology Radar a finales de 2016. Extiende los conceptos de los micro servicios al mundo del frontend. La tendencia actual es crear una aplicación de navegador potente y rica en características, también conocida como “single page app”, que se asiente sobre una arquitectura de microservicio. Con el tiempo, la capa de frontend, a menudo desarrollada por un equipo independiente, crece y se vuelve más difícil de mantener. Eso es lo que llamamos una Interfaz Monolítica.

La idea detrás de Micro Frontends es pensar en un sitio web o aplicación web como una composición de características que son propiedad de equipos independientes. Cada equipo tiene un área de negocio definida o misión de la que se preocupa y se especializa. Un equipo es cross functional y desarrolla sus características end-to-end, desde la base de datos hasta la interfaz de usuario.

Sin embargo, esta idea no es nueva. Tiene mucho en comun con el concepto de Sistemas autocontenidos. En el pasado se llamaba Integración de Frontend para Sistemas Verticales. Pero Micro Frontends es claramente un término más amigable y menos voluminoso.

Frontends monolíticos Frontends monolíticos

Organización vertical Equipos End-To-End con Micro Frontends

¿Qué es una aplicación web moderna?

En la introducción he usado la frase “crear una aplicación web moderna”. Vamos a definir los supuestos que están relacionados con este término.

Para poner esto en una perspectiva más amplia, Aral Balkan ha escrito una publicación en su blog sobre lo que él llama el Documents‐to‐Applications Continuum. Sugiere la idea de una escala móvil en la que un sitio, construido a partir de documentos estáticos, conectado a través de enlaces, se encuentra a la izquierda y uno dirigido completamente por comportamiento, una aplicación sin contenido, como un editor de fotos en online, está a la derecha.

Si tu proyecto se encuentra en el lado izquierdo de este espectro, una integración en servidor web es una buena opción. Con este modelo, un servidor recopila y concatena cadenas de HTML de todos los componentes que conforman la página solicitada por el usuario. Las actualizaciones se realizan recargando la página desde el servidor o reemplazando partes de ella a través de ajax. Gustaf Nilsson Kotte ha escrito un amplio artículo sobre este tema.

Cuando la interfaz de usuario tiene que proporcionar información instantánea, incluso en conexiones no estables, un sitio de servidor puro no es suficiente. Para implementar técnicas como UI optimista o Skeleton Screens debe poder también actualizar la UI en el dispositivo en sí. El término de Google Progressive Web Apps describe adecuadamente el balanceo entre ser un buen ciudadano de la web (mejora progresiva) y al mismo tiempo proporcionar rendimiento como en una app. Este tipo de aplicación se encuentra en algún lugar sobre la mitad. Aquí una solución basada únicamente en el servidor ya no es suficiente. Tenemos que movernos a la integración en el navegador, y ese es el enfoque de este artículo.

Ideas centrales detrás de las micro frontend

  • Sé Agnóstico a la Tecnología
    Cada equipo debe poder elegir y actualizar su stack sin tener que coordinar con otros equipos. Los Custom Elements son una excelente manera de ocultar los detalles de la implementación mientras se proporciona una interfaz neutral a otros.
  • Aislar el código del equipo
    No compartir tiempo de ejecución, incluso si todos los equipos usan el mismo framework. Crea aplicaciones independientes que sean autónomas. No hay que confiar en estado compartido o variables globales.
  • Establecer prefijos de equipo
    Acordar los espacios de nombres no aislados. Espacio de nombres CSS, eventos, almacenamiento local y cookies para evitar colisiones y dejar clara la propiedad.
  • Favorece las funciones nativas del navegador sobre las API personalizadas
    Utilizar Eventos de navegador para la comunicación en lugar de crear un sistema global PubSub. Si realmente tiene que crear una API de varios equipos, intente que sea lo más simple posible.
  • Construir un sitio resiliente
    Su función debería ser útil, incluso si JavaScript falla o no se ha ejecutado todavía. Utilizar Universal Rendering y Progressive Enhancement para mejorar el rendimiento percibido.

El DOM es la API

Custom Elements, el aspecto de interoperabilidad de las especificaciones de Web Components, son una buena primitiva para la integración en el navegador. Cada equipo construye su componente usando la tecnología web de su elección y lo envuelve dentro de un Custom Element (por ejemplo, <order-minicart></order-minicart>). La especificación DOM de este elemento en particular (nombre de etiqueta, atributos y eventos) actúa como el contrato o API pública para otros equipos. La ventaja es que pueden usar el componente y su funcionalidad sin tener que conocer la implementación. Solo tienen que ser capaces de interactuar con el DOM.

Pero los custom elements por sí solos no son la solución a todas nuestras necesidades. Para abordar la mejora progresiva, renderizado universal o el routing, necesitamos piezas de software adicionales.

Esta página está dividida en dos áreas principales. Primero, analizaremos Composición de la página: cómo ensamblar una página con componentes que pertenecen a diferentes equipos. Después mostraremos ejemplos para implementar el lado de cliente Transición de página.

Composición de la página

Además de la integración cliente-servidor del código escrito con diferentes frameworks, hay muchos temas secundarios que deben ser discutidos: mecanismos para aislar js, evitar conflictos css, cargar recursos según sea necesario, compartir recursos comunes entre equipos, manejar la obtención de datos y pensar sobre estados de carga buenos para el usuario. Vamos a entrar en estos temas paso a paso.

El prototipo base

La página de productos de este modelo de tienda de tractores servirá de base para los siguientes ejemplos.

Cuenta con un selector para cambiar entre los tres modelos diferentes de tractores. Al cambiar la imagen del producto, se actualizan el nombre, el precio y las recomendaciones. También hay un botón comprar, que añade la variedad seleccionada a la cesta y una minicesta en la parte superior que se actualiza en consecuencia.

Examplo 0 - Página del producto - Plain JS

probar en navegador & inspeccionar código

Todo el HTML se genera en el lado del cliente utilizando JavaScript y Template Strings ES6 sin dependencias. El código separa estado de maquetacion y vuelve a renderizar todo el lado del cliente HTML en cada cambio, sin DOM extraño ni renderizado universal por ahora. Tampoco separación por equipo - [código] https://github.com/neuland/micro-frontends/tree/master/0-model-store) está escrito en un archivo js/css.

Integración del lado del cliente

En este ejemplo, la página se divide en componentes/fragmentos separados que pertenecen a tres equipos. Team Checkout (azul) ahora es responsable de todo lo relacionado con el proceso de compra, es decir, botón de compra y minicesta. Team Inspire (verde) administra las recomendaciones de producto en esta página. La página en sí es propiedad de Team Product (rojo).

Ejemplo 1 - Página del producto - Composición

probar en navegador & inspeccionar código

El equipo de producto(rojo) decide qué funcionalidad se incluye y dónde se coloca en el diseño. La página contiene información que puede ser proporcionada por el propio equipo, como el nombre del producto, la imagen y las variedades disponibles. Pero también incluye fragmentos (custom elements) de los otros equipos.

¿Cómo crear un Custom Element?

Tomemos el botón de compra como ejemplo. El equipo de producto incluye el botón simplemente agregando <blue-buy sku="t_porsche"></blue-buy> en la posición deseada en la maquetación. Para que esto funcione, Team Checkout debe registrar el elemento blue-buy en la página.

class BlueBuy extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<button type="button">buy for 66,00 €</button>`;
  }

  disconnectedCallback() { ... }
}
window.customElements.define('blue-buy', BlueBuy);

Ahora, cada vez que el navegador encuentra una nueva etiqueta blue-buy, el método connectedCallback es llamado. this es la referencia al nodo DOM raíz del Custom Element. Se pueden usar todas las propiedades y métodos de un elemento DOM estándar como innerHTML o getAttribute().

Custom Elements en acción

Al nombrar tu elemento, el único requisito que define la especificación es que el nombre debe incluir un guión (-) para mantener la compatibilidad con las nuevas etiquetas HTML. En los siguientes ejemplos, se utiliza la convención de nombres [color]-[característica]. El espacio de nombres del equipo protege contra las colisiones y, de esta manera, el propietario de una característica se vuelve obvio, simplemente mirando el DOM.

Comunicación padre-hijo / Modificación de DOM

Cuando el usuario selecciona otro tractor en el selector, el botón comprar debe actualizarse en consecuencia. Para lograr el equipo de producto simplemente puede borrar el elemento existente del DOM e insertar uno nuevo.

container.innerHTML;
// => <blue-buy sku="t_porsche">...</blue-buy>
container.innerHTML = '<blue-buy sku="t_fendt"></blue-buy>';

El disconnectedCallback del antiguo elemento se invoca de forma sincrónica para proporcionar al elemento la posibilidad de limpiar cosas como los event listeners. Después de eso, se llama a connectedCallback del elemento t_fendt recién creado.

Otra opción más eficaz es simplemente actualizar el atributo sku en el elemento existente.

document.querySelector('blue-buy').setAttribute('sku', 't_fendt');

Si el equipo de producto usara un motor de plantillas que detecta diferencias de DOM, como React, el algoritmo lo haría automáticamente.

Cambio de atributo de Custom Element

Para respaldar esto, el Custom Element puede implementar attributeChangedCallback y especificar una lista de atributos observados en observedAttributes para los cuales se debe ejecutar este callback.

const prices = {
  t_porsche: '66,00 €',
  t_fendt: '54,00 €',
  t_eicher: '58,00 €',
};

class BlueBuy extends HTMLElement {
  static get observedAttributes() {
    return ['sku'];
  }
  connectedCallback() {
    this.render();
  }
  render() {
    const sku = this.getAttribute('sku');
    const price = prices[sku];
    this.innerHTML = `<button type="button">buy for ${price}</button>`;
  }
  attributeChangedCallback(attr, oldValue, newValue) {
    this.render();
  }
  disconnectedCallback() {...}
}
window.customElements.define('blue-buy', BlueBuy);

Para evitar la duplicidad, se introduce un método render() que se llama desde connectedCallback y attributeChangedCallback. Este método recopila los datos necesarios y el nuevo html que se asigna a innerHTML. Si se decide ir con un motor de plantillas o un framework más sofisticado dentro del custom element, aquí es donde va la inialización de este.

Soporte en navegador

El ejemplo anterior utiliza la especificación Custom Element V1 que actualmente está soportada en todos los navegadores. No son necesarios pollyfils or hacks de ningún tipo.

Framework de Compatibilidad

Debido a que los custom elements son un estándar web, todos los frameworks principales de JavaScript como React, Vue Angular, Svelte o Preact los soportan. Permiten embeber un Custom Element en tu aplicación de la misma manera que una etiqueta HTML nativa, y también ofrecen formas de publicar tu aplicación como un Custom Element.

Evitar la Anarquia de Frameworks

Usar Custom Elements es una manera genial de lograr un gran desacoplamiento entre los fragmentos de los equipos individuales. De esta manera, cada equipo es libre de elegir el framework que prefiera. Pero sólo porque puedas hacerlo no significa que sea una buena idea mezclar diferentes tecnologías. Intenta evitar la Anarquía de microfrontends y crea un nivel razonable de alineamiento entre los distintos equipos. De esta manera, los equipos pueden compartir conocimiento y buenas prácticas. También te hará la vida más fácil cuando quieras establecer una biblioteca de patrones central. Dicho esto, la capacidad de combinar tecnologías puede resultar útil cuando se trabaja con una aplicación heredada (legacy) y se desea migrar a una nueva stack tecnológica.

Comunicación padre-hijo (o hermanos) / eventos de DOM

Pero pasar atributos no es suficiente para todas las interacciones. En nuestro ejemplo, la minicesta debe actualizarse cuando el usuario hace click en el botón comprar.

Ambos fragmentos son propiedad de Team Checkout (azul), por lo que podrían crear algún tipo de API interna de JavaScript que le permita a la mini cesta saber cuándo se presionó el botón. Pero esto requeriría que las instancias de los componentes se conozcan entre sí y también sería una violación de aislamiento.

Una forma más limpia es utilizar un mecanismo PubSub, donde un componente puede publicar un mensaje y otros componentes pueden suscribirse a temas específicos. Por suerte los navegadores tienen esta característica incorporada. Así es exactamente cómo funcionan los eventos del navegador como click, select o mouseover. Además de los eventos nativos, también existe la posibilidad de crear eventos de nivel superior con new CustomEvent(...). Los eventos siempre están vinculados al nodo DOM en el que se crearon/enviaron. La mayoría de los eventos nativos también hacen bubbling. Esto hace posible escuchar todos los eventos en un subárbol específico del DOM. Si desea escuchar todos los eventos de la página, se puede añadir un listener al elemento window. Aquí es cómo se ve la creación del evento blue:basket:changed en el ejemplo:

class BlueBuy extends HTMLElement {
  [...]
  connectedCallback() {
    [...]
    this.render();
    this.firstChild.addEventListener('click', this.addToCart);
  }
  addToCart() {
    // maybe talk to an api
    this.dispatchEvent(new CustomEvent('blue:basket:changed', {
      bubbles: true,
    }));
  }
  render() {
    this.innerHTML = `<button type="button">buy</button>`;
  }
  disconnectedCallback() {
    this.firstChild.removeEventListener('click', this.addToCart);
  }
}

La mini cesta ahora puede suscribirse a este evento en window y recibir una notificación cuando deba actualizar sus datos.

class BlueBasket extends HTMLElement {
  connectedCallback() {
    [...]
    window.addEventListener('blue:basket:changed', this.refresh);
  }
  refresh() {
    // fetch new data and render it
  }
  disconnectedCallback() {
    window.removeEventListener('blue:basket:changed', this.refresh);
  }
}

Con este enfoque el fragmento de la mini cesta agrega un oyente a un elemento DOM que está fuera de su alcance (window). Esto debería estar bien para muchas aplicaciones, pero si no estas cómodo con esto, también se puede implementar un enfoque en el que la propia página (Team Product) escuche el evento y notifique a la mini cesta llamando a refresh() en el elemento DOM.

// page.js
const $ = document.getElementsByTagName;

$('blue-buy')[0].addEventListener('blue:basket:changed', function() {
  $('blue-basket')[0].refresh();
});

Llamada imperativa a los métodos DOM es bastante poco común, pero se puede encontrar en video element api por ejemplo. Si es posible se debería hacer uso de un enfoque declarativo (cambio de atributo).

Renderizado en servidor / Renderizado Universal (SSR)

Los Custom Elements son excelentes para integrar componentes dentro del navegador. Pero cuando se construye un site, es probable que la velocidad de carga inicial sea importante y que los usuarios vean una pantalla en blanco hasta que se descarguen y ejecuten todos los frameworks JS. Además, es bueno pensar qué pasa con el sitio si el JavaScript falla o está bloqueado. Jeremy Keith explica la importancia de su libro/podcast Resilient Web Design. Por lo tanto, la capacidad de renderizar el contenido en el servidor es clave. Lamentablemente, la especificación de componentes web no habla en absoluto renderizado en servidor. Sin JavaScript no hay Custom Elements :(

Custom Elements + Server Side Includes = ❤️

Para hacer que el renderizado del servidor funcione hay que refactorizar el ejemplo anterior. Cada equipo tiene su propio servidor Express y el método render() del elemento personalizado también es accesible a través de url.

$ curl http://127.0.0.1:3000/blue-buy?sku=t_porsche
<button type="button">buy for 66,00 €</button>

El nombre de la etiqueta del Custom Element se utiliza como nombre de la ruta: los atributos se convierten en query params. Ahora hay una manera de procesar en servidor el contenido de cada componente. Combinado con custom element <blue-buy> se consigue algo que está bastante cerca de un Universal Web Component:

<blue-buy sku="t_porsche">
  <!--#include virtual="/blue-buy?sku=t_porsche" -->
</blue-buy>

El comentario #include es parte de Server Side Includes, que es una característica que está disponible en la mayoría de los servidores web. Sí, es la misma técnica usada hace tiempo para insertar la fecha actual en nuestros sitios web. También hay algunas técnicas alternativas como ESI, nodesi, compoxure y tailor, pero en general Server Side Iincludes (SSI) ha demostrado ser una solución simple e increíblemente estable.

El comentario #include se reemplaza con la respuesta de /blue-buy?sku=t_porsche antes de que el servidor web envíe la página completa al navegador. La configuración en nginx sería así:

upstream team_blue {
  server team_blue:3001;
}
upstream team_green {
  server team_green:3002;
}
upstream team_red {
  server team_red:3003;
}

server {
  listen 3000;
  ssi on;

  location /blue {
    proxy_pass  http://team_blue;
  }
  location /green {
    proxy_pass  http://team_green;
  }
  location /red {
    proxy_pass  http://team_red;
  }
  location / {
    proxy_pass  http://team_red;
  }
}

La directiva ssi: on; habilita la función SSI y añadimos un bloque upstream y location para cada equipo para garantizar que todas las direcciones URL que comienzan con /blue se dirijan a la aplicación correcta (team_blue: 3001). Además, la ruta / se asigna al equipo rojo, que controla la página de inicio / página de producto.

Esta animación muestra la tienda de tractores en un navegador que tiene JavaScript desactivado.

Serverside Rendering - Disabled JavaScript

ver el código

Los botones de selección ahora son enlaces reales y cada click produce una recarga de la página. El terminal a la derecha ilustra el proceso de cómo una solicitud de una página se enruta al equipo rojo, que controla la página de producto y luego el marcado se complementa con los fragmentos del equipo azul y verde.

Al volver a activar JavaScript, solo estarán visibles los mensajes llamadas al servidor para la primera solicitud. Todos los cambios posteriores se manejan del lado del cliente, como en el primer ejemplo. En un ejemplo posterior, los datos del producto se extraerán del JavaScript y se cargarán a través de una API REST según sea necesario.

Puedes jugar con este código de muestra en tu máquina local. Solo se debe instalar Docker Compose.

git clone https://github.com/neuland/micro-frontends.git
cd micro-frontends/2-composition-universal
docker-compose up --build

Docker luego inicia el nginx en el puerto 3000 y construye la imagen node.js para cada equipo. Cuando se abra http://127.0.0.1:3000/ en el navegador se debe de ver un tractor rojo. El log combinado de docker-compose hace que sea fácil ver lo que está sucediendo en la red. Lamentablemente, no hay forma de controlar el color de salida, por lo que el equipo azul se resaltará en verde :)

Los archivos src se mapean a contenedores individuales y la aplicación node se reinicia cuando realiza un cambio de código. Cambiar el nginx.conf requiere un reinicio de docker-compose para que tenga efecto. Así que no dudes en juguetear y dar tu opinión.

Carga de datos y Estados carga

Una desventaja del enfoque SSI/ESI es que el fragmento más lento determina el tiempo de respuesta de toda la página. Así que es bueno almacenar los framentos en caché. Para los fragmentos que son costosos de producir y difíciles de almacenar en caché, a menudo es buena idea excluirlos del procesamiento inicial. Se pueden cargar de forma asíncrona en el navegador. En nuestro ejemplo, el fragmento green-recos, que muestra recomendaciones personalizadas, es un candidato para esto.

Una posible solución sería que el equipo rojo solo omita el SSI Include.

Antes

<green-recos sku="t_porsche">
  <!--#include virtual="/green-recos?sku=t_porsche" -->
</green-recos>

Después

<green-recos sku="t_porsche"></green-recos>

Nota importante: Custom Elements no puede cerrarse en un solo tag, por lo que <green-recos sku="t_porsche" /> no funciona correctamente.

Reflow

El renderizado solo tiene lugar en el navegador. Pero, como se puede ver en la animación, este cambio ahora ha introducido un reflow importante de la página. El área de recomendación está inicialmente en blanco. El JavaScript del equipo verde está cargado y ejecutado. Se hace la llamada al API para obtener la recomendación personalizada. El HTML de la recomendación se renderiza y se solicitan las imágenes asociadas. El fragmento ahora necesita más espacio y empuja el diseño de la página.

Hay diferentes opciones para evitar un reflow molesto como éste. El equipo rojo, que controla la página, podría fijar la altura de los contenedores de recomendación. En un sitio web responsive a menudo es difícil determinar la altura, ya que podría diferir para diferentes tamaños de pantalla. Pero el problema más importante es que este tipo de acuerdo entre equipos crea un fuerte acoplamiento entre el equipo rojo y verde. Si el equipo verde quiere introducir un subtítulo adicional en el elemento reco, tendría que coordinar con el equipo rojo en la nueva altura. Ambos equipos tendrían que implementar sus cambios simultáneamente para evitar romper diseño.

Una mejor manera es usar una técnica llamada Skeleton Screens. El equipo rojo deja el green-recos SSI Include en la maquetación. Además, el equipo verde cambia el método de render en el servidor de su fragmento para que produzca una versión esquemática del contenido. El skeleton markup puede reutilizar partes de los estilos de diseño del contenido real. De esta manera, reserva el espacio necesario y el relleno del contenido real no produce salto.

Skeleton Screen

Los skeleton también son muy útiles para la representación del cliente. Cuando un custom element se inserta en el DOM por una acción del usuario, puede instantáneamente representar skeleton hasta que lleguen los datos que necesita del servidor.

Incluso en un cambio de atributo como variant select se puede decidir mostrar el skeleton hasta que lleguen los nuevos datos. De esta manera, el usuario percibe que algo está sucediendo en el fragmento. Pero cuando el endpoint responde rápidamente, un breve skeleton flicker entre los datos antiguos y nuevos también podría ser molesto. Preservar los datos antiguos o usar timeouts inteligentes puede ayudar. Utiliza esta técnica con cuidado y recoger feedback de los usuarios.

Continuará …

Puede ver el Repo en Github para más información.

Recursos adicionales

Técnicas relacionadas


Cosas por venir …

  • Casos de uso
    • Navegación entre páginas
      • Navegación suave vs navegación dura
      • Router universal
  • Temas secundarios
    • CSS aislado / Interfaz de usuario coherente / Guías de estilo y bibliotecas de patrones
    • Rendimiento en carga inicial
    • Rendimiento durante el uso del sitio
    • Carga de CSS
    • Carga de JS
    • Tests de integración

Autor

Michael Geers (@naltatis) es ingeniero de software en neuland Büro für Informatik y trabaja en la construcción de frontends agradables para e-commerce.

Colaboradores

Este sitio es generado por Github Pages. Su fuente se puede encontrar en español en scipion/micro-frontends, o en el sitio original en neuland/micro-frontends.