Introduction to Micro frontend

Introduction to Micro frontend

What is Micro frontend?

The term “micro frontends” debuted in the 2016 ThoughtWorks Technology Radar guide.

At its core, micro frontends extend the philosophy of micro services to the front end of web applications. Micro services, a well-known architectural pattern that divides the backend into separate services, each responsible for a specific function. This approach ensures scalability, agility, and fault tolerance.

Micro frontends apply the same principles to the front end, breaking down a web application into smaller, autonomous modules or functions. Each module can be developed independently, providing frontend teams with a level of flexibility and speed similar to what micro services offer to their backend counterparts.

Micro frontends can be managed in code base leveraging three different strategies.

Mono Repo (Monolithic Repository)

In a mono repo strategy, all micro frontends and their associated code (e.g., backend services, shared libraries) are stored in a single repository.

Multi Repo (Multiple Repositories)

In a multi-repo strategy, each micro frontend and its associated code (e.g., backend services, shared libraries) are stored in separate repositories.

Meta Repo (Meta Repository)

In a meta repo strategy, we have a primary repository (meta repo) that contains the configuration and references to multiple micro frontend repositories. The meta repo doesn’t contain the actual micro frontend code but orchestrates and integrates them.


Key Concepts of Micro frontend

  1. Decomposition(Separation of Concerns): Just as micro services break down backend services into smaller, manageable pieces, micro frontends decompose the frontend into smaller, loosely coupled components or applications. Each team can own the development, deployment, and maintenance of these components independently.
  2. Tech stack Flexibility: Each micro frontend can be built using different technologies or frameworks allowing for experimentation and gradual adoption of new technologies without overhauling the entire frontend, as long as they adhere to the agreed-upon integration standards.
  3. Autonomy: Each micro frontend can have its own build pipeline and deployment process. This autonomy ensures that teams can release features or updates without impacting other parts of the application, promoting faster development cycles.
  4. Scalability: Micro frontend architecture supports the scalability of both development and deployment. Teams can scale their individual micro frontends independently, and new micro frontends can be added without significant changes to the existing application.
  5. Resilience (Failure isolation): By isolating different parts of the application, micro frontends can improve resilience. If one micro frontend fails or experiences issues, it doesn’t necessarily bring down the entire application.

Micro frontends though all developed, deployed, owned and maintained separately and independently, with different framework choices but these need to be integrated together and user should be able to get consistent user experience. Thus integration of micro frontends becomes important.

Integration of Micro frontends

Micro frontends need to be integrated into a cohesive user experience. This can be done using techniques such as:

  1. Build-time integration: Combining micro frontends into a single application bundle during the build process.
  2. Runtime integration: Loading micro frontends dynamically at runtime, often using iframes, Web Components, or JavaScript frameworks.
  3. Server Side Integration(SSI): Combines micro frontends on the server before sending them to the client.

The picture above represents build time integration.

The picture above is a representation of run time integration.

This picture above represents server side integration(SSI).

Lets take a look at side by side comparison between the three strategies.

Thus all these methods have their advantages and trade-offs, and the choice between them often depends on the specific requirements of the project, including performance, flexibility, and deployment needs.

In summary, build-time integration is suitable for scenarios where performance and consistency are critical, and where micro frontends do not change frequently.

Run-time integration is better suited for projects that require high flexibility, independent deployments, and frequent updates, despite the potential complexity and performance trade-offs.

SSI works well in scenarios where we need consistent, pre-rendered content and can handle the increased complexity on the server side.

In this blog's hands on section we would be focusing on Runtime integration of various micro frontends.

Different Ways of Runtime integration

Here’s a breakdown of various methods for runtime integration of micro frontends:

JavaScript Bundles

Description: Each micro frontend is built as a separate JavaScript bundle (e.g., a standalone script file).

Integration: The main application dynamically loads these bundles at runtime using script tags or dynamic imports.

Advantages: Simple and leverages standard web technologies.

Challenges: Handling dependencies and ensuring proper isolation between micro frontends.

Web Components

Description: Micro frontends are built as custom elements (Web Components), which are encapsulated, reusable components that follow web standards.

Integration: The main application uses these custom elements like any other HTML tag.

Advantages: Encapsulation, easy integration with existing applications, and native browser support.

Challenges: Performance implications and polyfills required for older browsers.

iFrames

Description: Each microfrontend is loaded within an <iframe>.

Integration: The main application embeds these iframes within its own layout.

Advantages: Strong isolation and independent deployment.

Challenges: Performance overhead, lack of direct communication between iframes, and potential issues with responsiveness. IFrames suffer from a challenge that it becomes difficult to pass over things like user sessions, cookies, local storage to different parts of the application seamlessly as these things are local to the frame which is rendered.

Single-SPA (Single Page Application)

Description: A framework specifically designed for micro frontend architecture, allowing multiple micro frontends to coexist and interact within a single-page application.

Integration: Micro frontends are registered and loaded dynamically based on the application's routing and configuration (activity function).

Advantages: Mature ecosystem, extensive documentation, and community support.

Challenges: Can be a learning curve to start with.

Module Federation (Webpack 5)

Description: Module Federation is a feature in Webpack that lets JavaScript applications dynamically share and load code from other applications. It's a powerful tool for building micro-frontends, which are smaller, self-contained modules that make up a web application's front end.

Integration: Micro frontends expose and consume modules dynamically at runtime.

Advantages: Fine-grained control over shared dependencies and runtime integration.

Challenges: Requires careful management of module versions and dependencies. When using module federation with Angular, React, or Vue JS frameworks, it’s important to note that each module must be built using the same framework. This can be a limitation for teams that are using different frameworks within the same application.

Key Considerations for Runtime Integration:

Performance: Ensure that the loading and interaction of micro frontends do not degrade performance. Consider lazy loading and code splitting.

Isolation: Maintain separation between micro frontends to avoid conflicts and unintended side effects.

Communication: Establish clear patterns for communication between micro frontends if they need to interact or share state.

Versioning: Manage different versions of micro frontends and their tech stacks to avoid compatibility issues.

Here in this blog we will explore single-spa SPA to build our micro frontends and runtime integrate them together. We will also explore how we are able to solve key challenges of communication between micro frontends and maintaining different versions.

Single-SPA

Single-spa framework is a JavaScript framework for building, testing, and deploying a number of JavaScript Micro frontends independently within a single frontend app. Here we can merge different tech stack micro frontends into a single page.

Architecture Components

Root Config

The single-spa root config consists of the following:

  1. The root HTML file that is shared by all single-spa applications.
  2. The JavaScript that calls singleSpa.registerApplication().
  3. A function that determines when the different applications are active/inactive

The root config exists only to start up the single-spa applications.

Application

A single-spa registered application is everything that a normal SPA(single page application) is, except that it doesn't have an HTML page. In a single-spa world, our SPA contains many registered applications, where each has its own framework. Registered applications have their own client-side routing and their own frameworks/libraries.For example, our React or Angular SPAs are applications. When active, they can listen to URL routing events and put content on the DOM. When inactive, they do not listen to URL routing events and are totally removed from the DOM.

Parcels

A single-spa parcel is a framework agnostic component. It is a chunk of functionality meant to be mounted manually by an application, without having to worry about which framework was used to implement the parcel or application. Parcels use similar methodology as registered applications but are mounted by a manual function call rather than the activity function. Parcels can be as large as an application and as small as components.

Utility

A utility is an in-browser module that (generally) has it's own repository and CI process. It exports a public interface of functions and variables that any other micro frontend can import and use. A utility micro frontend is just like any other micro frontend, except it doesn't serve as a single-spa application or parcel.


Hands on!

Now since we know about Micro frontends, importance of building those, how we can manage code and dependencies, we also know about the different strategies in place to integrate the diverse techstack and autonomous frontends. Its time to get out hands dirty with some code!!

Here in this hands on section we will create multi repository micro frontends. We will leverage runtime integration of these using the Single-SPA framework.

In Single-SPA we would be developing the root config and three parcel applications (leveraging single-spa-react library). To brush up on react, please refer this.

We run the same command after creating empty directories for root config as well as parcels.

npx create-single-spa        

Lets get started by creating the three parcels and root config. For creation all three parcels we are leveraging react with npm(node package manager). Our org name is demo.

The three parcel names are

  1. single-spa-demo-nav
  2. single-spa-demo-page1
  3. single-spa-demo-page2

For the root config we are leveraging npm and org name is same demo. The org name is used to group sources as we see them in browser.

Name of the container directory or root config or shell layer is single-spa-demo-root-config

Thus here since we have different repository for each parcel and shell layer we are leveraging multi repository mode of code management for micro frontends.

Lets take a look at our single-spa-demo-root-config container or shell layer. As it is the shell layer it has two files

  1. activity-functions.js - This file is responsible to define the activity functions for when to load which parcel or application.
  2. demo-root-config.js - This is the root configuration file (prefixed by our org name).

Lets take a look at how are activity-functions.js file looks like.

export function prefix(location, ...prefixes) {
    return prefixes.some(
        prefix => location.href.indexOf(`${location.origin}/${prefix}`) !== -1
    );
}

export function nav() {
    // The nav is always active
    return true;
}

export function page1(location) {
    return prefix(location, "page1");
}

export function page2(location) {
    return prefix(location, "page1");
}        

This simply checks if we need to load a particular application (or parcel in our case) basis on the first param i.e location. Here basically if we are having "page1" in our windows browser uri, we will load all the three micro frontend parcels. Else if say we are not having page1 then only the nav function returns true so only that would be loaded.

Lets now see the root configuration where we actually configure and registerApplications.

import { registerApplication, start } from "single-spa";
import * as isActive from "./activity-functions";
import MessageBus from "./MessageBus";

// registerApplication({
//   name: "@single-spa/welcome",
//   app: () =>
//     System.import(
//       "https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js"
//     ),
//   activeWhen: ["/"],
// });
//
// // registerApplication({
// //   name: "@demo/navbar",
// //   app: () => System.import("@demo/navbar"),
// //   activeWhen: ["/"]
// // });
//
// start({
//   urlRerouteOnly: true,
// });
const messageBus = new MessageBus();
registerApplication(
    "@demo/single-spa-demo-nav",
    () => System.import("@demo/single-spa-demo-nav"),
    isActive.nav,
    { domElement: document.getElementById("nav-container"), messageBus: messageBus}
);

registerApplication(
    "@demo/single-spa-demo-page-1",
    () => System.import("@demo/single-spa-demo-page-1"),
    isActive.page1,
    { domElement: document.getElementById("page-1-container"), messageBus: messageBus}
);

registerApplication(
    "@demo/single-spa-demo-page-2",
    () => System.import("@demo/single-spa-demo-page-2"),
    isActive.page2,
    { domElement: document.getElementById("page-2-container"), messageBus: messageBus}
);

start();        

In here we register each of the applications, register application takes in params for the application(or parcel), from where to get the code (System.import), we also leverage the activity functions we defined above and what all additional custom props to pass. The second argument to registerApplication must be either a function that returns a promise loading function or the resolved Application.

No hierarchy will be maintained between the applications. Instead, the applications will be siblings and will be mounted and unmounted according to their own activity functions.

Here we going to load each of the parcel in a specific div (identified by id) this helps us fix position for each of the application. The message bus passed here is used for micro frontends communications.

At the end we invoke start() - The start() api must be called by our single spa config in order for applications to actually be mounted.

Lets now see where we have imported the parcels and created the respective divs to load these parcels.

We would also have an index.ejs file, this is the file that houses this code for imports or we call them import map (key being our parcel or app name and value is the actual resolution url) here we need to add the following lines

<% if (isLocal) { %>
<script type="systemjs-importmap">
  {
    "imports": {
      "@demo/root-config": "https://localhost:9000/demo-root-config.js",
      "@demo/single-spa-demo-nav": "https://localhost:9001/demo-single-spa-demo-nav.js",
      "@demo/single-spa-demo-page-1": "https://localhost:9002/demo-single-spa-demo-page-1.js",
      "@demo/single-spa-demo-page-2": "https://localhost:9003/demo-single-spa-demo-page-2.js"
    }
  }
</script>
<% } %>        

Since we are leveraging react micro frontend parcels (applications), we need to specify the react-dom and react systemjs-importmap

<script type="systemjs-importmap">
  {
    "imports": {
      "single-spa": "https://cdn.jsdelivr.net/npm/[email protected]/lib/system/single-spa.min.js",
      "react": "https://cdn.jsdelivr.net/npm/[email protected]/umd/react.production.min.js",
       "react-dom": "https://cdn.jsdelivr.net/npm/[email protected]/umd/react-dom.production.min.js"
    }
  }
</script>        

This file also has the body tag that should have the div with specific ids to load the micro frontends. We will also specify some custom styling for our body element (non mandatory but we do it for this demo).

<body>
<!--   <noscript> -->
<!--     You need to enable JavaScript to run this app. -->
<!--   </noscript> -->
<!--   <main></main> -->
  <main>
    <div id="page-1-container"></div>
    <div id="page-2-container"></div>
  </main>
  <div id="nav-container"></div>
  <script>
    System.import('@demo/root-config');
  </script>
  <import-map-overrides-full show-when-local-storage="devtools" dev-libs></import-map-overrides-full>
</body>        

The complete index.ejs files looks like below.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Root Config</title>
  <!--
    Remove this if you only support browsers that support async/await.
    This is needed by babel to share largeish helper code for compiling async/await in older
    browsers. More information at https://github.com/single-spa/create-single-spa/issues/112
  -->
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/runtime.min.js"></script>
  <!--
    This CSP allows any SSL-enabled host and for arbitrary eval(), but you should limit these directives further to increase your app's security.
    Learn more about CSP policies at https://content-security-policy.com/#directive
  -->
  <meta http-equiv="Content-Security-Policy" content="default-src 'self' https: localhost:*; script-src 'unsafe-inline' 'unsafe-eval' https: localhost:*; connect-src https: localhost:* ws://localhost:*; style-src 'unsafe-inline' https:; object-src 'none';">
  <meta name="importmap-type" content="systemjs-importmap" />
  <!-- If you wish to turn off import-map-overrides for specific environments (prod), uncomment the line below -->
  <!-- More info at https://github.com/joeldenning/import-map-overrides/blob/master/docs/configuration.md#domain-list -->
  <!-- <meta name="import-map-overrides-domains" content="denylist:prod.example.com" /> -->

  <!-- Shared dependencies go into this import map. Your shared dependencies must be of one of the following formats:

    1. System.register (preferred when possible) - https://github.com/systemjs/systemjs/blob/master/docs/system-register.md
    2. UMD - https://github.com/umdjs/umd
    3. Global variable

    More information about shared dependencies can be found at https://single-spa.js.org/docs/recommended-setup#sharing-with-import-maps.
  -->
  <script type="systemjs-importmap">
    {
      "imports": {
        "single-spa": "https://cdn.jsdelivr.net/npm/[email protected]/lib/system/single-spa.min.js",
        "react": "https://cdn.jsdelivr.net/npm/[email protected]/umd/react.production.min.js",
         "react-dom": "https://cdn.jsdelivr.net/npm/[email protected]/umd/react-dom.production.min.js"
      }
    }
  </script>
  <link rel="preload"  as="script">

  <!-- Add your organization's prod import map URL to this script's src  -->
  <!-- <script type="systemjs-importmap" src="/importmap.json"></script> -->
  <% if (isLocal) { %>
  <script type="systemjs-importmap">
    {
      "imports": {
        "@demo/root-config": "https://localhost:9000/demo-root-config.js",
        "@demo/single-spa-demo-nav": "https://localhost:9001/demo-single-spa-demo-nav.js",
        "@demo/single-spa-demo-page-1": "https://localhost:9002/demo-single-spa-demo-page-1.js",
        "@demo/single-spa-demo-page-2": "https://localhost:9003/demo-single-spa-demo-page-2.js"
      }
    }
  </script>
  <% } %>

  <!--
    If you need to support Angular applications, uncomment the script tag below to ensure only one instance of ZoneJS is loaded
    Learn more about why at https://single-spa.js.org/docs/ecosystem-angular/#zonejs
  -->
  <!-- <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/zone.min.js"></script> -->

  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/import-map-overrides.js"></script>
  <% if (isLocal) { %>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/system.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/extras/amd.js"></script>
  <% } else { %>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/system.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/extras/amd.min.js"></script>
  <% } %>
  <style>
    body, html { margin: 0; padding: 0; font-size: 16px; font-family: Arial, Helvetica, sans-serif; height: 100%; }
    body { display: flex; flex-direction: column; }
    * { box-sizing: border-box; }
  </style>
</head>
<body>
<!--   <noscript> -->
<!--     You need to enable JavaScript to run this app. -->
<!--   </noscript> -->
<!--   <main></main> -->
  <main>
    <div id="page-1-container"></div>
    <div id="page-2-container"></div>
  </main>
  <div id="nav-container"></div>
  <script>
    System.import('@demo/root-config');
  </script>
  <import-map-overrides-full show-when-local-storage="devtools" dev-libs></import-map-overrides-full>
</body>
</html>
        

We are using node to manage dependencies hence we have the package.json that look like below, this container layer will run on localhost port 9000 hence this is where we will see our micro frontends embedded.

{
  "name": "@demo/root-config",
  "scripts": {
    "start": "webpack serve --port 9000 --env isLocal",
    "lint": "eslint src --ext js",
    "test": "cross-env BABEL_ENV=test jest --passWithNoTests",
    "format": "prettier --write .",
    "check-format": "prettier --check .",
    "prepare": "husky install",
    "build": "concurrently npm:build:*",
    "build:webpack": "webpack --mode=production"
  },
  "devDependencies": {
    "@babel/core": "^7.23.3",
    "@babel/eslint-parser": "^7.23.3",
    "@babel/plugin-transform-runtime": "^7.23.3",
    "@babel/preset-env": "^7.23.3",
    "@babel/runtime": "^7.23.3",
    "concurrently": "^6.2.1",
    "cross-env": "^7.0.3",
    "eslint": "^7.32.0",
    "eslint-config-important-stuff": "^1.1.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-prettier": "^3.4.1",
    "html-webpack-plugin": "^5.3.2",
    "husky": "^7.0.2",
    "jest": "^27.5.1",
    "jest-cli": "^27.5.1",
    "prettier": "^2.3.2",
    "pretty-quick": "^3.1.1",
    "serve": "^13.0.0",
    "webpack": "^5.89.0",
    "webpack-cli": "^4.10.0",
    "webpack-config-single-spa": "^5.0.0",
    "webpack-dev-server": "^4.0.0",
    "webpack-merge": "^5.8.0"
  },
  "dependencies": {
    "@types/jest": "^27.0.1",
    "@types/systemjs": "^6.1.1",
    "single-spa": "^4.4.2"
  }
}        

Communication between micro frontends

Before exploring the code for various parcels and seeing what their responsibility is, let's see how the different micro frontends can communicate with each other. Its important to communicate state or share information(in some situations) because there might be common use case where the state change of one micro frontend changes the rendering or layout of the other, for example say we have two micro frontends, one is for user domain and other is for payment domain, now basis on our selection of a particular user we might see different payment means or outstanding payment details.

Ways to communicate data

  1. Shared State Management: Implement a global state manager, like Redux that all micro frontends can access.
  2. Custom Events: Use the browser's event system to broadcast and listen for data changes (windows publish and subscribe)
  3. Pub/Sub Systems: Implement a publish/subscribe pattern with libraries or custom event bus or message bus (we are going to explore this in this demo)
  4. Shared Services: Use shared APIs or services that microfrontends can call to exchange data.

Lets now take a look at out event bus or message bus as we call it

export default class MessageBus {
    
    subscriptions = { }

    publishEvent(eventType, arg) {
        if(!this.subscriptions[eventType])
            return

        Object.keys(this.subscriptions[eventType]).forEach(key => this.subscriptions[eventType][key](arg))
    }
        

    subscribeEvent(eventType, callback, domain) {

        if(!(this.subscriptions)[eventType])
            this.subscriptions[eventType] = { }

        this.subscriptions[eventType][domain] = callback
    }
    

    getSubscribedDomainsForEventType(eventType){
        let domains = []
        if(!(this.subscriptions)[eventType]){
            return []
        }
        Object.keys(this.subscriptions[eventType]).forEach(key => domains.push(key));
        return domains;
    }

    unsubscribe(eventType, callback, domain){
        delete this.subscriptions[eventType][domain]
        if(Object.keys(this.subscriptions[eventType].length === 0)) delete this.subscriptions[eventType]
    }

}        

Here we let the subscribers call the subscribe method and they tell which eventType they would like to subscribe for (get notified), whats their unique domain and whats the handler (callback) that needs to be invoked to notify them for the specified interested eventType.

Unsubscribe does the opposite of subscribe, it simply removes the subscription.

Similarly the publishers can invoke the publish method to publish eventType and data that they want to publish under that.

Another method here is to get the domains which are listening for a particular event type (this need not be there is most of the event bus, but is needed in the scenario we are developing for).

Scenario

Lets now consider a scenario where we have three micro frontends, one micro frontend is say for user domain(app1) which for now simply let's the user enter the user name(this is the single-spa-demo-page-1). The second micro frontend is for company domain(app2) which lets us enter the company name (this is the single-spa-demo-page-2). The third one is for navigation this micro frontend (single-spa-demo-nav) simply subscribes for the sate events from single-spa-demo-page-1 and single-spa-demo-page-2 micro frontends and publishes events to validate data as well.

Next let's look at and understand the various micro frontends we have.

single-spa-demo-nav

This is a simple navigational component, hence its a simple button element. Its main responsibility is that on click of the button it publishes the event to all the subscribers to validate the input data (or respective state). It also gets to know that from which all domains it should get the validation results by capturing the subscriber domains for buttonClickValidate event type. Also it subscribes for data from app1 and app2 (we will see those next).

Note: Validation could trigger calls to BFF(backend for frontend) but here we simply console log the data

There will be file having exports of all lifecycle methods (this is pre -generated since we created this as a single spa parcel instead of manually adding dependency.) This code piece will be common across all applications/parcels in this blog as it just exports this as a Single spa react application with some lifecycle methods exposed to mount or unmount the app. Here we also signify that component named rootComponent will be rendered on the dom as main component for this micro frontend.

import React from "react";
import ReactDOM from "react-dom";
import singleSpaReact from "single-spa-react";
import Root from "./root.component";

const lifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: Root,
  errorBoundary(err, info, props) {
    // Customize the root error boundary for our microfrontend here.
    return null;
  },
});

export const { bootstrap, mount, unmount } = lifecycles;        

The root.component.js will house the code that we want to render and it gets passed the custom props we passed while registering application (i.e the message bus).

import React, { useEffect, useRef, useState } from "react";
import "./root-component.css";

export default function Root(props) {
  let validationDomains;

  useEffect(() => {
    //window.addEventListener('app2.data', listener2)
    //window.addEventListener('app1.data', listener1)
    props.messageBus.subscribeEvent("app2.data", listener, "common");
    props.messageBus.subscribeEvent("app1.data", listener, "common");

    return () => {
      props.messageBus.unsubscribeEvent("app2.data", listener, "common");
      props.messageBus.unsubscribeEvent("app1.data", listener, "common");
      //window.removeEventListener('app2.data', listener2)
      //window.removeEventListener('app1.data', listener1)

    };

  }, []);

  const listener = (event) => {
    console.log(event);
    let domain = event.domain;
    validationDomains = validationDomains.filter(function(item) {
      return item !== domain;
    });
    if (validationDomains.length === 0) {
      console.log("going for 2nd page");
      //return window.location = "https://localhost:9000/page2";
    }
  };

  const handleClick = () => {
    let registeredDomainForValidation = props.messageBus.getSubscribedDomainsForEventType("buttonClickValidate");
    console.log(registeredDomainForValidation);
    validationDomains = registeredDomainForValidation;
    //const buttonClick = new CustomEvent('buttonClickValidate', {});
    //window.dispatchEvent(buttonClick);
    props.messageBus.publishEvent("buttonClickValidate", {});
    //window.dispatchEvent(buttonClick);
  };
  return (
    <div className="buttonContainer">
      <p>App 3</p>
      <button onClick={handleClick}>Next</button>
    </div>
  );
}        

For now we can say that it just renders a button and has a click listener that publishes events. It also subscribes for events from app1 and app2.

Note: We also have commented code for communication leveraging windows event dispatch and subscribe mechanism. If uncommented it should work as expected.

As we see here there is a styling class so we have the following file root-component.css

.buttonContainer {
    background: #244f7a;
    color: white;
    padding: 20px;
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 1;
    font-size: 3rem;
}        

The package.json for this navigational component looks something like below

{
  "name": "@demo/single-spa-demo-nav",
  "scripts": {
    "start": "webpack serve --port 9001 --env isLocal",
    "start:standalone": "webpack serve --env standalone",
    "build": "concurrently npm:build:*",
    "build:webpack": "webpack --mode=production",
    "analyze": "webpack --mode=production --env analyze",
    "lint": "eslint src --ext js",
    "format": "prettier --write .",
    "check-format": "prettier --check .",
    "test": "cross-env BABEL_ENV=test jest",
    "watch-tests": "cross-env BABEL_ENV=test jest --watch",
    "prepare": "husky install",
    "coverage": "cross-env BABEL_ENV=test jest --coverage"
  },
  "devDependencies": {
    "@babel/core": "^7.23.3",
    "@babel/eslint-parser": "^7.23.3",
    "@babel/plugin-transform-runtime": "^7.23.3",
    "@babel/preset-env": "^7.23.3",
    "@babel/preset-react": "^7.23.3",
    "@babel/runtime": "^7.23.3",
    "@testing-library/jest-dom": "^5.17.0",
    "@testing-library/react": "^12.0.0",
    "babel-jest": "^27.5.1",
    "concurrently": "^6.2.1",
    "cross-env": "^7.0.3",
    "eslint": "^7.32.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-config-react-important-stuff": "^3.0.0",
    "eslint-plugin-prettier": "^3.4.1",
    "husky": "^7.0.2",
    "identity-obj-proxy": "^3.0.0",
    "jest": "^27.5.1",
    "jest-cli": "^27.5.1",
    "prettier": "^2.3.2",
    "pretty-quick": "^3.1.1",
    "webpack": "^5.89.0",
    "webpack-cli": "^4.10.0",
    "webpack-config-single-spa-react": "^4.0.0",
    "webpack-dev-server": "^4.0.0",
    "webpack-merge": "^5.8.0"
  },
  "dependencies": {
    "react": "^16.12.0",
    "react-dom": "^16.12.0",
    "react-router-dom": "^6.11.1",
    "single-spa-react": "^4.3.1"
  }
}        

Though we say its running on port 9001 but since its a parcel we cannot access it standalone, it needs to be embedded.


single-spa-demo-page-1

This microfrontend is used to display a textbox and captures the input of user name. It maintains the state, state ref. It is a subscriber for buttonClickValidate and exposes its domain as app1. It also publishes the app1 state info as below.

{ domain: "app1", data: firstNameRef.current }        

The root.component.js for this component looks something like this

import React, { useEffect, useRef, useState } from "react";
import "./root.component.css";

export default function Root(props) {
  const [firstName, setFirstName] = useState("");
  const firstNameRef = useRef();
  useEffect(() => {
    props.messageBus.subscribeEvent("buttonClickValidate", listener, "app1");
    //window.addEventListener('buttonClickValidate', listener)
    firstNameRef.current = firstName;
    return () => {
      props.messageBus.unsubscribe("buttonClickValidate", listener, "app1");
      //window.removeEventListener('buttonClickValidate', listener)

    };
    //window.addEventListener('buttonClick', listener)
  }, []);

  useEffect(() => {
    //window.addEventListener('buttonClickValidate', listener)
    firstNameRef.current = firstName;
    //window.addEventListener('buttonClick', listener)
  }, [firstName]);

  const listener = (data) => {
    //setProductsInCartCount(detail.count);
    console.log(firstNameRef.current);
    props.messageBus.publishEvent("app1.data", { domain: "app1", data: firstNameRef.current });
    //const data = new CustomEvent('app1.data', {data: firstNameRef.current});
    //window.dispatchEvent(data)

  };

  return (
    <div className="container1">
      <p>App 1</p>
      <input placeholder="Employee Name" value={firstName} onChange={e => setFirstName(e.target.value)} />
    </div>
  );
}        

Here we have added paragraph tag to display App1 and input component of react, we are leveraging state hook from react to initialise and update state as per user input.

We are leveraging the useRef so that we can refer the current state value while passing information via the message bus, this was important as else we were always passing the initial value which was empty.

Note: We also have commented code for communication leveraging windows event dispatch and subscribe mechanism. If uncommented it should work as expected.

There is styling applied to this component that comes from root-component.css

.container1 {
    background: #1098f7;
    color: white;
    padding: 20px;
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 1;
    font-size: 3rem;
}        

The package.json file for this component looks like below

{
  "name": "@demo/single-spa-demo-page-1",
  "scripts": {
    "start": "webpack serve --port 9002 --env isLocal",
    "start:standalone": "webpack serve --env standalone",
    "build": "concurrently npm:build:*",
    "build:webpack": "webpack --mode=production",
    "analyze": "webpack --mode=production --env analyze",
    "lint": "eslint src --ext js",
    "format": "prettier --write .",
    "check-format": "prettier --check .",
    "test": "cross-env BABEL_ENV=test jest",
    "watch-tests": "cross-env BABEL_ENV=test jest --watch",
    "prepare": "husky install",
    "coverage": "cross-env BABEL_ENV=test jest --coverage"
  },
  "devDependencies": {
    "@babel/core": "^7.23.3",
    "@babel/eslint-parser": "^7.23.3",
    "@babel/plugin-transform-runtime": "^7.23.3",
    "@babel/preset-env": "^7.23.3",
    "@babel/preset-react": "^7.23.3",
    "@babel/runtime": "^7.23.3",
    "@testing-library/jest-dom": "^5.17.0",
    "@testing-library/react": "^12.0.0",
    "babel-jest": "^27.5.1",
    "concurrently": "^6.2.1",
    "cross-env": "^7.0.3",
    "eslint": "^7.32.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-config-react-important-stuff": "^3.0.0",
    "eslint-plugin-prettier": "^3.4.1",
    "husky": "^7.0.2",
    "identity-obj-proxy": "^3.0.0",
    "jest": "^27.5.1",
    "jest-cli": "^27.5.1",
    "prettier": "^2.3.2",
    "pretty-quick": "^3.1.1",
    "webpack": "^5.89.0",
    "webpack-cli": "^4.10.0",
    "webpack-config-single-spa-react": "^4.0.0",
    "webpack-dev-server": "^4.0.0",
    "webpack-merge": "^5.8.0"
  },
  "dependencies": {
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "single-spa-react": "^4.3.1"
  }
}        

This micro frontend is available on port 9002.

Note: The demo-single-spa-demo-page-1.js which has the export of singleSpaReact application looks similar to nav's file.

single-spa-demo-page-2

This micro frontend is to capture company name, its domain is called app2. It also has similar code structure as the single-spa-demo-page-1 micro frontend.

Lets checkout the code files

root.component.js

import React, { useEffect, useRef, useState } from "react";
import "./root.component.css";

export default function Root(props) {
  const [companyName, setCompanyName] = useState("");
  const companyNameRef = useRef();

  useEffect(() => {
    companyNameRef.current = companyName;
    //window.addEventListener('buttonClick', listener)
    props.messageBus.subscribeEvent("buttonClickValidate", listener, "app2");
    //window.addEventListener('buttonClickValidate', listener)
    return () => {
      //props.messageBus.unsubscribe('buttonClickValidate', listener, 'app2');
      //window.removeEventListener('buttonClickValidate', listener)

    };

  }, []);

  useEffect(() => {
    companyNameRef.current = companyName;
    //window.addEventListener('buttonClick', listener)

  }, [companyName]);
  const listener = (data) => {
    //setProductsInCartCount(detail.count);
    console.log(companyNameRef.current);
    props.messageBus.publishEvent("app2.data", { domain: "app2", data: companyNameRef.current });
    //const data = new CustomEvent('app2.data', {data: companyNameRef.current});
    //window.dispatchEvent(data)

  };
  return (
    <div className="container2">
      <p>App 2</p>
      <input placeholder="Company Name" value={companyName} onChange={e => setCompanyName(e.target.value)} />
    </div>
  );
}        

Note: We also have commented code for communication leveraging windows event dispatch and subscribe mechanism. If uncommented it should work as expected.

root.component.css has styling

.container2 {
    background: #9e4770;
    color: white;
    padding: 20px;
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 1;
    font-size: 3rem;
}        

Note: The demo-single-spa-demo-page-2.js which has the export of singleSpaReact application looks similar to nav's file.

package.json looks something like this.

{
  "name": "@demo/single-spa-demo-page-2",
  "scripts": {
    "start": "webpack serve --port 9003 --env isLocal",
    "start:standalone": "webpack serve --env standalone",
    "build": "concurrently npm:build:*",
    "build:webpack": "webpack --mode=production",
    "analyze": "webpack --mode=production --env analyze",
    "lint": "eslint src --ext js",
    "format": "prettier --write .",
    "check-format": "prettier --check .",
    "test": "cross-env BABEL_ENV=test jest",
    "watch-tests": "cross-env BABEL_ENV=test jest --watch",
    "prepare": "husky install",
    "coverage": "cross-env BABEL_ENV=test jest --coverage"
  },
  "devDependencies": {
    "@babel/core": "^7.23.3",
    "@babel/eslint-parser": "^7.23.3",
    "@babel/plugin-transform-runtime": "^7.23.3",
    "@babel/preset-env": "^7.23.3",
    "@babel/preset-react": "^7.23.3",
    "@babel/runtime": "^7.23.3",
    "@testing-library/jest-dom": "^5.17.0",
    "@testing-library/react": "^12.0.0",
    "babel-jest": "^27.5.1",
    "concurrently": "^6.2.1",
    "cross-env": "^7.0.3",
    "eslint": "^7.32.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-config-react-important-stuff": "^3.0.0",
    "eslint-plugin-prettier": "^3.4.1",
    "husky": "^7.0.2",
    "identity-obj-proxy": "^3.0.0",
    "jest": "^27.5.1",
    "jest-cli": "^27.5.1",
    "prettier": "^2.3.2",
    "pretty-quick": "^3.1.1",
    "webpack": "^5.89.0",
    "webpack-cli": "^4.10.0",
    "webpack-config-single-spa-react": "^4.0.0",
    "webpack-dev-server": "^4.0.0",
    "webpack-merge": "^5.8.0"
  },
  "dependencies": {
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "single-spa-react": "^4.3.1"
  }
}        

Point to note: As it can be seen the app1 and app2 Micro frontends are on different react and react-dom versions as compared to the nav micro frontend. But they will be embedded in the same shell layer without any conflicts.

Next for each of the root config and three parcels, run the following command, this will help start these on specific ports.

npm start        

Next load the shell layer with all parcels embedded. The page looks something like this


Point to note: If we change something(autonomously) on any of the parcels we do not need to rebuild the shell or any other component since we are on runtime integration.

Next lets add some data in both of our text boxes and click on the next button. For now we simply console log the data consumed by subscribers and when all the domains who subscribed for validation event have responded with their data(both app1 and app2 have published events which nav micro frontend subscribes for) we simple print going for 2nd page.


Note: In nav parcel can even cause us to go to "/page2". In this case we would be expecting to see just the button on screen since our activity functions control the mount for parcels and return false for other two micro frontends(parcels).

Key challenges of Micro frontends

  1. Consistent UI/UX: Maintaining a consistent user interface and user experience across different micro frontends requires careful design and coordination.
  2. Component Isolation: Ensuring that each micro frontend operates independently and does not interfere with others can be challenging.
  3. Integration: Coordinating how different micro frontends interact and integrate with each other can be complex.
  4. Performance issues: Due to network load time it takes to load various micro frontends
  5. Resource Management: Efficiently managing shared resources (like libraries) to avoid duplication and reduce bundle size can be challenging.
  6. Sharing state and Communication: Since we decomposed monolithic frontend service into autonomous micro frontends, does not mean that they need not communicate or share state or take actions/reactions

Apart from these key challenges there can be other issues like ever evolving and different tech stack, team coordination and managing access control across different micro frontends.

Micro frontend vs Monolithic frontend

Summary

In this blog we got introduced to the idea of micro frontends, their key advantages, code management techniques, micro frontend integration options, we zoomed into run time integration exploring single-spa framework. We also took a look at the single-spa framework aspects and developed a demo application with shell layer hosting three micro frontend parcel apps. We even explored ways of sharing information or state between micro frontends and implemented a message bus for our use case. In the end we looked at challenges with micro frontend and its comparison with monolithic frontends. We can see that each have their own pros an cons and it depends largely on the use case we are dealing with that helps us make the right choice.

Sources of Knowledge

https://www.turing.com/blog/micro-frontends-what-are-they-when-to-use-them

https://dzone.com/articles/micro-frontends-by-example-8

https://single-spa.js.org/docs/microfrontends-concept/

https://redux.js.org/tutorials/fundamentals/part-1-overview

https://dev.to/florianrappl/communication-between-micro-frontends-41fe

https://www.dhiwise.com/post/react-hook-on-unmount-best-practices

https://webpack.js.org/concepts/module-federation/

要查看或添加评论,请登录

社区洞察

其他会员也浏览了