Création d'un Microfrontend avec Module Federation

Microfrontend ?

➡️ Microservices

Définition :

An architectural style where independently deliverable frontend applications are composed into a greater whole.

Martin Fowler

Comment faire ?

  • Avec Module Federation
  • Avec des Web Components
  • Avec des IFrames

Webpack

Module Federation

  • Plugin Webpack (Webpack 5)
  • Chargement asynchrone de modules distants (pas dans le code de l'application).
    • Le code est chargé dynamiquement à l'exécution, avec les dépendances si nécessaire.
  • Plus large que le Microfrontend, peut aussi être utilisé côté backend

Comment faire avec React ?

Tout dépend de la façon dont on souhaite construire notre application !

  • En utilisant Webpack directement
  • Create React App
  • Create React App Rewired
  • Vite
  • Next

La documentation officielle fournit des exemples

Vite

Outil front-end JS pour améliorer la rapidité de développement avec une compilation optimisée pour la production.

Vite Webpack

On commence ?

Toutes les ressources sont disponibles sur le dépôt github ddecrulle/workshop-module-federation.

Vous pouvez commencer par forker le dépôt.

Le starter est très simple et contient essentiellement de la CI et des outils pour faire du monorepo.

Création des projets

Créons 2 projets vite

yarn create vite
  # Project Name : host
  # Framework React/Typescript
yarn create vite
  # Project Name : remote
  # Framework React/Typescript

Lancement des applications

npx lerna bootstrap #Télécharge les dépendances
yarn dev #Lance les 2 applications
yarn build #Build les 2 applications

Fixons les ports de lancement, host : 5000, remote 5001.

"scripts": {
   "dev": "vite --port 5000 --strictPort",
    "build": "tsc && vite build",
    "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview --port 5000 --strictPort"
}

Modifications

Pour commencer et éviter de mélanger host et remote, modifions le titre en ajoutant Host ou Remote dans App.tsx.

Je vous propose ensuite de customiser le bouton du remote.

Pour ce faire créons un composant Button dans components/Button.tsx et importons le dans notre App.tsx.

Button.tsx

import "./Button.css";

import { useState } from "react";

export const Button = () => {
  const [state, setState] = useState(0);
  return (
    <div>
      <button
        id="click-btn"
        className="shared-btn"
        onClick={() => setState((s) => s + 1)}
      >
        Click me: {state}
      </button>
    </div>
  );
};

Button.css

.shared-btn {
  background-color: skyblue;
  border: 1px solid white;
  color: white;
  padding: 15px 30px;
  text-align: center;
  text-decoration: none;
  font-size: 18px;
}

Dans App.tsx remplaçons le bouton par celui que nous venons de créer

import { Button } from "./components/Button"

...
{/* remplacer */}

<button onClick={() => setCount((count) => count + 1)}>
          count is {count}
</button>
{/* par */}

<Button />

Le code jusqu'à cette étape.

Configuration de Module Federation

Le plugin Vite : @originjs/vite-plugin-federation

On ajoute la dépendance dans le projet racine car elle est commune à toutes les apps et que c'est une devDependencies.

yarn add -D @originjs/vite-plugin-federation -W

Configuration du build

Dans le vite.config.ts (des deux app)

export default defineConfig({
  plugins: [react()],
  build: {
    modulePreload: false,
    target: "esnext",
    minify: false,
    cssCodeSplit: false,
  },
});

(Optionnel) TsconfigPath

yarn add -D vite-tsconfig-paths -W
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [
    react(),
    ...,
    tsconfigPaths()
    ],
});

Pour le remote

Cela se passe dans le fichier vite.config.ts

import federation from "@originjs/vite-plugin-federation";
export default defineConfig({
  plugins: [
    react(),
    federation({
      name: "remote",
      filename: "remoteEntry.js",
      exposes: { "./Button": "./src/components/Button.tsx" },
      shared: ["react", "react-dom"],
    }),
  ],
});

Pour l'Host

import federation from "@originjs/vite-plugin-federation";
export default defineConfig({
  plugins: [
    react(),
    federation({
      name: "host",
      remotes: {
        remoteApp: "http://localhost:5001/assets/remoteEntry.js",
      },
      shared: ["react", "react-dom"],
    }),
  ],
});

Import du bouton dans l'host

// Static import
import Button from "remoteApp/Button";
// Lazy import
const App = React.lazy(() => import("remoteApp/Button"));

Déclaration du type (pas optimal...)
fichier custom.d.ts

declare module "remoteApp/*";
// Dans l'App.tsx
<Button />

Lancement en local

yarn build
yarn serve

L'application host devrait embarquer le bouton du remote !

http://localhost:5000

Le remote doit absolument utiliser le build lancé via vite preview. L'host peut être en mode développement vite dev

Le code jusqu'à cette étape.

Des améliorations

Variabiliser le passage de l'url du remote.

Créer un fichier .env

VITE_REMOTE_URL=http://localhost:5001

Documentation officielle

Dans le vite.config.ts

federation({
  name: "app",
  remotes: {
    remoteApp: {
      external: `Promise.resolve(import.meta.env["VITE_REMOTE_URL"] + "/assets/remoteEntry.js")`,
      externalType: "promise",
    },
  },
  shared: ["react", "react-dom"],
});

Ajouter "baseUrl": "./src" pour avoir des imports absolus (nécessite tsConfigPath).

IntelliSense pour TypeScript

Pour activer l'IntelliSense sur les variables d'environnements, dans le fichier vite-env.d.ts ajouter :

interface ImportMetaEnv {
  readonly VITE_REMOTE_URL: string;
  // more env variables...
}
interface ImportMeta {
  readonly env: ImportMetaEnv;
}

Le code jusqu'à cette étape.

C'est cool mais partager un bouton ...

Autant faire une librairie (ou un système de design ) !

Et si on ajoutait l'application remote sur la route /remote ?

Créer le router dans l'host

npx lerna add react-router-dom --scope=host

routes/root.tsx

import { createBrowserRouter } from "react-router-dom";
import App from "App";
import RemoteApp from "remoteApp/RemoteApp";
export const router = createBrowserRouter([
  { path: "/remote", element: <RemoteApp /> },
  { path: "/", element: <App /> },
]);

Utiliser le router

Dans main.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import "./index.css";
import { router } from "routes/root";

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

Exposer l'app du remote

Dans vite.config.ts

exposes:
  {
    "./Button": "./src/components/Button.tsx",
    "./RemoteApp": "./src/App.tsx",
  }

On teste

Comme tout à l'heure

yarn build
yarn serve

https://localhost:5000

Le code jusqu'à cette étape.

Quelques points de vigilances

  • L'host importe dynamiquement les modules du remote. Il n'y a pas de contrôle sur ce qui est importé au build time. Par conséquent on peut découvrir des erreurs au runtime !
    • Bien définir des contrats d'interface !
  • L'architecture monorepo est, je pense, à conseiller dans le cas d'un microfrontend. Il faut cependant un gitflow solide.

Et si le remote a lui aussi un router ?

Essayons de voir ce qu'il se passe !

Création du router dans le remote

npx lerna add react-router-dom --scope=remote

routes/root.tsx

import { createBrowserRouter } from "react-router-dom";
import App from "App";
export const router = createBrowserRouter([
  { path: "/remote-routes", element: <div>Test router in remote </div> },
  { path: "/", element: <App /> },
]);

Utiliser le router

Dans main.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import "./index.css";
import { router } from "routes/root";

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

On teste ?

Quelqu'un a une idée du comportement ?

yarn build
yarn serve

Le Router

On ne peut pas avoir 2 Browser Router sur une "même" application.

Solution

  • Un Browser Router dans l'application host
  • Un Memory Router dans l'application remote (uniquement en microFrontend)

Un exemple, utilisant webpack, est disponible ici.

On essaie de faire pareil dans notre contexte ?

Pour aller plus loin

  • Partager des états entre applications
  • PWA
    • Offline
  • Paramétrage du serveur applicatif pour gérer les CORS
  • Déploiement

Ressources

2011 Arrivée de l'architecture Microservices Grand succès, sauf que cette architecture traite principalement d'aspect back-end. 2016 Première apparition du mot Microfrontend Définition

Avec le microfrontend il est possible d'avoir un écran découpé en plusieurs applications (de techno différentes ou non) qui discutent avec différents microfrontend (ou non)

On peut aussi imaginer un microfrontend ou c'est pas "l’écran" qui est découpé mais les différentes routes. Web4g ?

La difference est essentiellement pour le dev. Il y a donc un changement de paradigme sur les serveurs de développements. Le temps de démarrage est très très rapide. Pour le build en production, Vite utilise rollup (un module bundler comme webpack). Webpack est basé sur CommonJS quand rollup réfère à ES Module.

Créer un fichier **bootstrap.tsx** ```tsx import { createRoot } from "react-dom/client"; import { RouterProvider } from "react-router-dom"; import { createRouter } from "./routing/router-factory"; import type { RoutingStrategy } from "./routing/types"; const mount = ({ mountPoint, initialPathname, routingStrategy, }: { mountPoint: HTMLElement; initialPathname?: string; routingStrategy?: RoutingStrategy; }) => { const router = createRouter({ strategy: routingStrategy, initialPathname }); const root = createRoot(mountPoint); root.render(<RouterProvider router={router} />); return () => queueMicrotask(() => root.unmount()); }; export { mount }; ``` --- **routing/router-factory.ts** ```ts import { createBrowserRouter, createMemoryRouter } from "react-router-dom"; import { routes } from "./routes"; import type { RoutingStrategy } from "./types"; interface CreateRouterProps { strategy?: RoutingStrategy; initialPathname?: string; } export function createRouter({ strategy, initialPathname }: CreateRouterProps) { if (strategy === "browser") { return createBrowserRouter(routes); } const initialEntries = [initialPathname || "/"]; return createMemoryRouter(routes, { initialEntries: initialEntries }); } ``` --- **routing/routes.tsx** Le fichier root.tsx sans le browserRouter et renommage _*router*_ en routes. ```tsx import App from "App"; export const routes = [ { path: "/remote-routes", element: <div>Test router in remote </div> }, { path: "/", element: <App /> }, ]; ``` --- Dans le **main.tsx** ```tsx import "./index.css"; import("./bootstrap").then(({ mount }) => { const localRoot = document.getElementById("root") as HTMLElement; mount({ mountPoint: localRoot!, routingStrategy: "browser", }); }); export {}; ``` --- # On test que le remote n'est pas cassé ```bash yarn dev:remote yarn build:remote yarn serve:remote ``` http://localhost:5001/remote-routes Tout à l'air ok ! --- # On modifie l'export ```ts "./remoteApp": "./src/bootstrap.tsx" ``` ---