Ca în orice domeniu din ziua de astăzi, cu precădere în industria IT, trebuie să privim spre viitor şi să găsim moduri fezabile de a ne alinia nevoilor societății şi tehnologiei care sunt în continuă schimbare. Fie că vorbim despre frameworkuri, tooluri sau procese, deznodământul este același: tehnologiile noi le vor înlocui pe cele vechi. Acest lucru este valabil și în Web Development, unde cele mai mari modificări au fost la frameworkurile pe care le foloseam, la cum să redăm în DOM mai eficient o pagină, cum să facem modelarea datelor mai necostisitoare din punctul de vedere al memoriei și așa mai departe. Se pare că, în sfârșit, a venit momentul să ne concentrăm și asupra modului cum alegem să facem arhitectura unui proiect, în așa fel încât să putem dezvolta produse mai rapid, în echipe mai numeroase într-un mod independent. Aici intră în discuție Microfrontends.
Microfrontendurile sunt un concept relativ nou în lumea dezvoltării web, dar au câștigat rapid popularitate datorită numeroaselor beneficii. Pe măsură ce dezvoltatorii au început să descompună aplicațiile monolitice în servicii mai mici, independente, ei și-au dat seama că aceeași abordare ar putea fi aplicată și pentru frontendul aplicațiilor web. Astăzi, există numeroase instrumente și cadre disponibile pentru construirea de micro frontenduri, făcându-le mai ușor pentru dezvoltatori să adopte această abordare în proiectele lor.
Nevoia de micro frontenduri apare din cauza limitărilor aplicațiilor monolitice. Prin decentralizarea frontendului în bucăți mai mici de sine stătătoare, putem depăși acest lucru, păstrând în continuare structura de monorepo. Pe scurt, aceasta presupune dezvoltarea unor aplicații mai mici (module) care pot fi rulate independent una de cealaltă, care, la final, se vor integra pentru a forma o aplicație completă. Motivele pentru o astfel de abordare sunt numeroase:
Scalabilitatea. Putem avea mai multe echipe care lucrează la diferite părți din aplicație fără să existe conflicte. Fiecare își poate gestiona modul în care își va lansa aplicația. În cazul unei complexități mărite a unei componente, se poate interveni doar asupra scalabilității acelei componente fără a avea impact asupra restului aplicației.
Flexibilitatea tehnologiilor folosite. Pentru că fiecare micro frontend este independent, avem posibilitatea de a folosi tehnologii și frameworkuri diferite pentru fiecare dintre acestea, atâta timp cât vor fi respectate câteva reguli. Aceasta ajuta la formarea mai rapidă a echipelor, deoarece o aplicație nu va mai fi constrânsă doar de un singur framework. Totodată, oricând un nou micro frontend va trebui integrat, se poate alege cea mai nouă tehnologie.
Construirea aplicațiilor în mod independent. Posibilitatea ca fiecare micro frontend să fie construit independent, fără a fi nevoie de a reconstrui întreaga aplicație integrată.
Cel mai probabil, la un moment dat, fiecare dintre noi am interacționat cu acest concept, însă fără să ne dăm seama. Un astfel de exemplu pot fi unele pachete (componente) publicate într-un Artifactory (NPM) pe care le importăm într-unul sau mai multe proiecte, pentru a reduce duplicitatea codului. Pornind de aici, putem distinge trei moduri prin care putem integra componentele în aplicații:
Build-Time Integration. Înainte ca pagina web să se încarce, fișierele aduse din afara aplicației, trebuie deja încărcate și inițializate (pachete NPM);
Server-Side Integration. Un server va decide ce componente trebuie încărcate în aplicație.
Dacă primele două integrări sunt cât de cât cunoscute, noutatea vine la Run-Time Integration. Astfel ne vom concentra asupra acesteia. Aici diferența este că micro frontedurile, care pot fi și pagini de sine stătătoare, sunt integrate în aplicație print-un URL specific (ex: https://my-app.com/MFE/remoteEntry.js), fără a fi nevoie de o reconstruire a aplicației container, atunci când o modificare va fi adusă micro frontendului copil.
Deși avem o arhitectură de micro frontenduri, ideal ar fi să existe, în continuare, o componentă principală (container/părinte/shell), care va trebui să le integreze pe celelalte (copii). Doar prin intermediul acesteia, în cazuri speciale, micro frontendurile să poată comunica între ele.
O dată cu această structurare vine și un set de recomandări, care ajută pe măsură ce aplicațiile vor crește:
Nu ar trebui să existe legături între micro frontendurile copii. După cum observăm, este de evitat împărtășirea componentelor de la un micro frontend copil la altul. Excepție fac librăriile comune conectate prin Module Federation.
Ar trebui să se evite, pe cât posibil, transferul de date între container și aplicațiile copii.
CSS-urile ar trebui încapsulate în micro frontenduri şi nu să fie folosite general. Style inheritance poate fi o problemă în dezvoltarea aplicațiilor deoarece pot exista conflicte în denumirea claselor sau stilizarea elementelor: (header, body etc.)
Cum acest concept de integrare al micro frontendurilor este cel mai nou, la fel sunt și instrumentele care ne ajută în dezvoltarea cât mai ușoară a acestor aplicații, și de aici face parte și ModuleFederation.
Module Federation este un plugin integrat în Webpack 5, care permite micro frontendurilor să interschimbe între ele componente, dependințe sau chiar aplicații întregi, fără a fi nevoie de a copia codul în proiecte separate. Înainte, dacă aveam mai multe micro frontenduri pe care voiam să le utilizam în alte aplicații, acestea trebuiau să fie construite și implementate în interiorul aplicațiilor pe care le foloseau, astfel ajungea ca performanța și dimensiunea aplicațiilor să crească exponențial, datorită duplicității.
Acest plugin vine ca alternativă, deoarece permite micro frontendurilor să distribuie codul într-un mod mult mai eficient prin încărcarea resurselor în mod dinamic, doar atunci când o pagină web este redată în DOM (Run-Time), și nu când este construită pentru a fi implementată. Orice modificare adusă unui micro frontend copil, se va propaga automat la container și la celelalte micro frontenduri fără a fi nevoie de implementări separate pentru fiecare aplicație în parte. Astfel, codul devine mai ușor de menținut și de înțeles, în timp ce echipele pot lucra din ce în ce mai independent.
Configurarea și utilizarea Module Federationului este destul de simplă. Dar, pe măsura ce aplicația crește, la fel va crește și complexitatea, iar pentru un start de bază este destul să avem următoarele elemente:
În primul rând, pentru a putea expune și a avea acces la diferite micro frontenduri, vom avea un fișier numit "webpack.config.js" în ambele aplicații, dar cu configurări diferite:
În Microfrontendul Dashboard (copil):
trebuie să setăm un nume pentru componentele pe care le expunem (name: 'dashboard'), și cum se va numi fișierul generat de webpack (filename: 'remoteEntry.js')
Cheia 'exposes' va expune componentele pe care dorim să le exportăm, dar trebuie respectate câteva reguli:
Trebuie să se aleagă un alias pentru a putea fi accesate: 'DashboardPage ';
Trebuie să setăm calea componentei pe care vrem să o expunem;
În Microfrontendul Container (părinte):
trebuie să setăm un nume pentru componentele pe care le expunem (container);
Prin cheia 'remotes', vom importa în aplicație componentele expuse, dar trebuie respectate câteva reguli:
Să înceapă cu același nume cu al micro frontendului pe care îl expune (dashboard).
În continuare, vom lucra cu următoarea structură de fișiere. Deoarece dorim să avem aplicații independente de un limbaj de programare, vom implementa și o funcție care inserează conținutul returnat din micro frontendurile copii în elementele din containerul părinte. Pentru exemplul ales mai departe, vom folosi ReactJs.
Un lucru important de reținut este că folosim un mod dinamic de a consuma micro frontendurile. Prin urmare, chiar dacă folosim componente din același repository (monorepo), trebuie să încărcăm în mod asycron aceste componente, deoarece ele vor fi interpretate ca module externe. De aceea, trebuie să avem un fișier de 'pornire' (bootsratp.js) unde să copiem conținutul fișierului 'index.js' și apoi să importăm 'bootstrap.js' în 'index.js'.
Ulterior, ne vom concentra pe aplicația Dashboard, unde tot ce trebuie să facem este să modificăm 'bootstrap.js', pentru a crea funcția care să redea în DOM conținutul din Dashboard în aplicația părinte.
/dashboard/src/bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
const renderDashboard = (element) => {
ReactDOM.render( , element);
}
export { renderDashboard };
Acum putem trece la aplicația Container, unde primul constă în a aduce componenta de Dashboard și a o reda în DOM. Vom face asta cu ajutorul unui ref și apelând funcția expusă cu elementul în care vom încărca conținutul din Dashboard.
/container/src/DashboardComponent.js
import { renderDashboard } from
'dashboard/DashboardPage'
import React, { useRef, useEffect } from 'react';
export default () => {
const ref = useRef(null);
useEffect(() => {
renderDashboard(ref.current);
}, []);
return ;
};
Iar de aici setupul va fi la fel ca într-o aplicație de ReactJs normală, având următoarele fișiere:
În acest moment, vom avea o aplicație care va folosi micro frontenduri pentru a randa conținutul. Este tot ce trebuie pentru a ne putea extinde.
Unul dintre cele mai importante lucruri care trebuie menționate când este vorba despre micro frontendurile îl reprezintă state managementul și, mai precis, cum se face schimbul de date între componente. Pentru asta avem câteva soluții:
În cazul aplicației de ReactjJs, putem apela la cea mai ușoară metoda și anume: Prop Drilling, unde variabilele și funcțiile pot fi transmise cu ajutorul propsurilor;
Având un magazin al datelor în aplicația container și distribuind datele la celelalte module. Aici vom expune hookuri pentru a consuma și actualiza magazinul de date, care vor fi folosite în toate micro frontendurile. Pentru a menține independența aplicației, ideal ar fi să se folosească librării precum Redux-Toolkit sau Zustand.
Un alt lucru important este rutarea deoarece, pe lângă rutarea pe care o vom face în container, trebuie să ne ocupăm și de celelalte micro frontenduri. Desigur că putem să tratăm acest aspect și din container adăugând încă o cale pentru fiecare nouă rută din microfrontenduri, dar aceasta se pierde în independenţa aplicațiilor, fiind necesară o nouă integrare a containerului.
Un alt aspect la care trebuie să fim atenți este CSS-ul. De multe ori, unele stiluri se vor suprascrie deoarece se lucrează în echipe separate, fiind ușor ca o clasă sau un element dintr-o aplicație să suprascrie alta. Printre cele mai ușoare soluții se numără:
setarea unui id pentru fiecare aplicație în parte și toate modificările să fie făcute în domeniul de aplicare al acelui id;
folosirea JS-in-CSS care inserează codul CSS direct pe fiecare element în pagină;
O mare îmbunătățire pe care putem să o aducem se axează pe dependințele care sunt împărtășite între module. După cum am observat în fișierul 'webpack.config', există o cheie 'shared' care are ca valoare un vector cu numele tuturor librăriilor folosite în acel micro frontend. Când aplicația Container importă şi folosește acest modul, se va verifica dacă acele dependințe sunt deja încărcate în interiorul aplicației și se vor refolosi.
Pentru că fiecare aplicație creşte în ritmul ei, va fi greu să ținem cont de toate librăriile utilizate și să le actualizăm manual peste tot. Așa că putem să automatizăm dependințele care vor fi aduse, pentru a nu fi duplicate astfel: 'shared:packageJson.dependencies'.
Din păcate, nimic nu poate veni doar cu avantaje. În cazul de față cel mai mare dezavantaj este complexitatea configurării aplicațiilor. Deoarece este un nou tip de integrare al arhitecturii, lucrurile avansează într-un ritm alert, iar resursele și soluțiile pentru a acoperi toate problemele care apar în timpul dezvoltării și cererilor businessului pot fi limitate sau complicat de implementat. Fie că vorbim despre tehnologia aleasă pentru fiecare micro frontend, despre crearea rutelor aplicației sau despre cum putem să stocăm și să transferăm datele dintr-un micro frontend în altul, la început, majoritatea lucrurilor vor fi personalizate pentru nevoile fiecărei aplicații. Însă o dată cu stabilizarea proiectului şi trecerea peste această etapă, beneficiile vor fi multiple. Pe de altă parte, nimic nu asigură că, alegând o altă arhitectură, nu vor apărea alte dificultăți.
O altă problemă se referă la erorile generate de micro frontenduri. Deși am spus că aceste micro frontenduri fac aplicațiile mai ușor de depanat, există și situații când unele erori pot fi descoperite doar la integrarea lor în componente părinte.
Alte contraargumente sunt greu de găsit deoarece, dacă reușim să trecem peste integrare, beneficiile care vor fi aduse, în cele mai multe cazuri, depășesc riscul adus și timpul petrecut pentru înțelegere a arhitecturii de către echipe.
Fie că vrem sau nu, din ce în ce mai multe proiecte se vor structura în acest mod, chiar dacă este vorba despre soluții implementate la nivel de Build sau Run-Time. Faptul că proiectele sunt din ce în ce mai mixte și nevoia separării codului în componente cât mai mici este în creștere ne va determina să privim această opțiune drept cel mai bun mod de a merge înainte. În exemplul nostru am ales conceptul Run-Time pentru a implementa aceste micro frontenduri, dar acest lucru se poate face în multiple moduri și în multiple configurații. Fie că avem un singur monorepo sau repository-uri mai multe în diferite spaţii de lucru, fie că importăm micro frontendurile în timpul creării aplicației sau folosim soluții dinamice cu Module Federation, avantajele aduse vor fi din ce mai evidente pe măsură ce productivitatea şi autonomia echipelor va crește, iar complexitatea va scădea o dată cu apariția unor noi soluții.
de Ovidiu Mățan
de Ovidiu Mățan
de Denisa Lupu
de Ovidiu Mățan