<

Defining Fragments Composed in Micro Frontends as Web Components and Sharing them with Module Federation

This is a brief introduction on how to define fragments composed in Micro Frontends (hereafter, MFE) as Web Components and share them with Module Federation.

The sample code can be found in the following repository.

※ For more information about MFE, please read the following blog article.

https://silverbirder.github.io/blog/contents/mfe/

Terminology

  • Fragment
    • UI parts (HTML, CSS, JS, etc.) provided by each frontend team
    • It can also be referred to as a component
  • Composition
    • Building the entire page using fragments

From the famous article by Michael Geers on MFE, there is the following sample diagram.

[Translated article] Micro Frontends > mfe-three-teams
[Translated article] Micro Frontends > mfe-three-teams

This example is a sample of an EC site. The checkout team uses React, and the fragments are the following two.

  • Purchase button (buy for 66.00)
  • Basket (busket: 0 items(s))

The composition is handled by the product team. Because of the difficulty of coordination, it might be good to have a dedicated team for composition.

Defining Fragments as Web Components

Fragments can be freely defined by each frontend team. They can be written in React, Vue, etc. From the perspective of the team composing the fragments, it would be easier to use if the interfaces of the fragments are unified. Therefore, let's define the fragments as Web Components. (The contents of the definition can be React, Vue, etc.)

I think this method can be applied to any of the following three design patterns for implementing MFE.

  • Build-time composition pattern
  • Server-side composition pattern
  • Client-side composition pattern

Next, I will introduce the sample code.

Search Button Fragment

I will write a search button fragment (Web Components). It is a simple one that defines a button and a click handler. I chose React as the framework.

// ./packages/team-search/src/components/SearchButton/SearchButton.tsx
import React, { createContext } from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

export const CustomElementContext = createContext<HTMLElement>(
  document.createElement("div")
);

export class SearchButton extends HTMLElement {
  connectedCallback() {
    const mountPoint = document.createElement("span");
    this.attachShadow({ mode: "open" }).appendChild(mountPoint);
    ReactDOM.createRoot(mountPoint as HTMLElement).render(
      <React.StrictMode>
        <CustomElementContext.Provider value={this}>
          <App />
        </CustomElementContext.Provider>
      </React.StrictMode>
    );
  }
}
// ./packages/team-search/src/components/SearchButton/App.tsx
import { useContext } from "react";
import { CustomElementContext } from "./SearchButton";

const App = () => {
  const customElement = useContext(CustomElementContext);
  const onClick = () => {
    customElement.dispatchEvent(
      new CustomEvent("search", { detail: { num: Math.random() } })
    );
  };
  return <button onClick={onClick}>Search</button>;
};

export default App;

This Web Components can be used by writing <search-button />.

If you want to cooperate with other fragments, use custom events. This Web Components fires a custom event called CustomEvent("search", { detail: <object> }) when the click button is pressed.

JSON Display Fragment

Next, I will write a fragment (Web Components) that displays the data (<object>) of this event. It is a simple one that just displays the given json string. There are three ways to give data to Web Components. (There may be more)

  • HTML attributes (ex. <div attribute="value">)
    • Used with primitive values (numbers, characters, etc.)
  • Event listeners (eventlistener)
    • Used with non-primitive values (arrays, etc.)
  • Slot (<slot name="xxx">)
    • Used when you want to insert HTML elements

This time, we have chosen HTML attributes.

// ./packages/team-content/src/components/JsonDiv/JsonDiv.tsx
import ReactDOM from "react-dom/client";
import App from "./App";

export class JsonDiv extends HTMLElement {
  root: ReactDOM.Root | undefined;
  static get observedAttributes() {
    return ["value"];
  }

  attributeChangedCallback() {
    const value = this.getAttribute("value") || ("{}" as string);
    const props = { json: value };
    if (this.root) {
      this.root.render(<App {...props} />);
    }
  }

  connectedCallback() {
    const value = this.getAttribute("value") || ("{}" as string);
    const props = { json: value };
    const mountPoint = document.createElement("span");
    this.attachShadow({ mode: "open" }).appendChild(mountPoint);
    this.root = ReactDOM.createRoot(mountPoint as HTMLElement);
    this.root.render(<App {...props} />);
  }
}

This Web Components is used like <json-div value="{}" />.

// ./packages/team-content/src/components/JsonDiv/App.tsx
type AppProps = {
  json: string;
};

const App = (props: AppProps) => {
  const { json } = props;
  return <div>{json}</div>;
};

export default App;

App.tsx simply displays the given json in a <div>.

Composition

We will compose the fragments introduced so far. In order to compose, we need a mechanism to provide the fragments. Therefore, we use Webpack's Module Federation.

※ Adopting Module Federation has the disadvantage of tying each front-end team's build system to Webpack.

※ I thought importmap might be another mechanism to provide, but it is unverified.

Module Federation

Module Federation is a feature introduced from Webpack@5.

Each build acts as a container and also consumes other builds as containers. This way each build is able to access any other exposed module by loading it from its container.

Module Federation allows each build to function as a container and use other containers. In this case, we containerize the Web Components' SearchButton and JsonDiv and refer to the containers in the composition build.

Let me introduce the specific code.

Containerization

Let's containerize the search button. (The fragment that displays JSON has the same code)

// .packages/team-search/src/remoteEntry.ts
export { SearchButton } from "./SearchButton";

Export what you provide as a container. Next, define the webpack plugins code.

// .packages/team-search/webpack.config.js
...
const config = {
  entry: "./src/index",
  plugins: [
    new ModuleFederationPlugin({
      name: "search",
      filename: "remoteEntry.js",
      exposes: {
        "./App": "./src/remoteEntry",
      },
      shared: {
        react: {
          singleton: true,
          strictVersion: true,
          requiredVersion: "^18.0.0",
          eager: true,
        },
        "react-dom": {
          singleton: true,
          strictVersion: true,
          requiredVersion: "^18.0.0",
          eager: true,
        },
      },
    }),
  ]
};
...

Set the file you exported earlier in exposes. Set shared to prevent duplicate loading of libraries. Now, you can containerize and provide the SearchButton.

Composition

Now, let's look at the composition side build (webpack) that loads the container.

// ./webpack.config.js
const URL_MAP = {
  content: process.env.CONTENT_URL || "http://localhost:3001",
  search: process.env.SEARCH_URL || "http://localhost:4001",
};

const config = {
  entry: "./src/index",
  plugins: [
    new ModuleFederationPlugin({
      name: "all",
      remotes: {
        content: `content@${URL_MAP.content}/remoteEntry.js`,
        search: `search@${URL_MAP.search}/remoteEntry.js`,
      },
      shared: {
        react: {
          singleton: true,
          strictVersion: true,
          requiredVersion: "^18.0.0",
        },
        "react-dom": {
          singleton: true,
          strictVersion: true,
          requiredVersion: "^18.0.0",
        },
      },
    }),
  ],
};

Set the URL to load the container in remotes. Next is the entry code.

// ./src/index.ts
// @see: https://webpack.js.org/concepts/module-federation/#uncaught-error-shared-module-is-not-available-for-eager-consumption
import("./bootstrap");
export {};

As you can see from @see, the entry code needs to be dynamically loaded with import. Next is the bootstrap code.

// ./src/bootstrap.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
// ./src/App.tsx
import { useEffect } from "react";

const App = () => {
  useEffect(() => {
    import("content/App").then((module) => {
      const { JsonDiv } = module;
      if (customElements.get("json-div") === undefined) {
        customElements.define("json-div", JsonDiv);
      }
    });
    import("search/App").then((module) => {
      const { SearchButton } = module;
      if (customElements.get("search-button") === undefined) {
        customElements.define("search-button", SearchButton);
      }
      const SearchButtonElement = document.querySelector("search-button");
      SearchButtonElement?.addEventListener("search", ((e: CustomEvent) => {
        document
          .querySelector("json-div")
          ?.setAttribute("value", JSON.stringify(e.detail));
      }) as EventListener);
    });
  }, []);

  return (
    <>
      <search-button />
      <json-div />
    </>
  );
};

export default App;

Here, import("content/App") and import("search/App") are dynamically loading the container. What is loaded is Web Components, so define it with customElements.define. Also, write a process to listen to the search event handler of search-button and set the event data to the value attribute of json-div. With this, the composition is complete.

If you want to see how it actually works, please check the README.md of the repository and give it a try.

Advantages of this method

The advantages and disadvantages of using Web Components as fragments are as follows:

  • Advantages
    • Compatibility
      • Since Web Components are a web standard technology, compatibility with libraries is easy
      • You can use custom HTML tags just like using HTML tags
    • Independence
      • Can be developed in a sandbox environment called Shadow DOM
  • Disadvantages
    • Javascript needs to be running

In conclusion

We have introduced a method of defining fragments composed in Micro Frontends with Web Components and sharing them with Module Federation. Although I have no practical experience, I thought it might be useful as an idea.

If it was helpful, support me with a ☕!

Share

Related tags