iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🌟

Building a Micro Frontends Infrastructure with Vite and single-spa

に公開

I would like to introduce a foundation I tried to create for splitting an ultra-large frontend.

Prerequisites for this article

  • You want to divide and conquer a huge frontend
  • SSR (Server Side Rendering) is not considered
  • Support only modern browsers (No IE11 support)

This article will not introduce single-spa or micro frontends. Please refer to these articles instead:

single-spa is a tool that introduces simple conventions to the application lifecycle and is likely the most widely used one. Generally, you configure each application by combining this with Vite, but the same can be achieved with webpack.

What's Working

Demo

https://microfront-base.netlify.app/

What was achieved here:

  • Common header
  • Switching content built in different environments per route
  • Coexistence of react-router apps and vue-router apps
    • Each application can have its own routing within its scope, but can also transition to other applications
  • Divide and conquer for independent deployment
    • In the development environment, most parts point to production while allowing specific parts to run as local builds
    • => Does not require building all applications for execution

GitHub: https://github.com/mizchi/microfront-base

Below is an introduction to the implementation steps.

Creating a single-spa Host Environment

single-spa has various features, but we mainly use the following two:

  • Activity Check: Define whether an Application is active at a given URL using activeWhen: (loc: Location) => boolean
  • Lifecycle: Define mount(props) {...} and unmount(props) {...} to specify processing for when the application is activated or deactivated based on the activity check.

Note that the URL is the only judgment condition, and each application is not necessarily tied to a specific DOM element.

In this example, we will register a header that always exists, a home application that reacts to /, and an about application that reacts to /about.

First, we create a single-spa host environment named shell.

(I'm intending to borrow this name from the shell in the App Shell model. The App Shell Model | Web | Google Developers)

mkdir microfront-base
cd microfront-base
yarn create @vitejs/app shell
cd shell
yarn add single-spa

Implement the single-spa host environment here.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Microfront Base</title>
  </head>
  <body>
    <div id="header"></div>
    <div id="main"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

First, I prepared two elements for mounting: header and main.

Then, register the Applications that manage them.

// src/main.ts
import {registerApplication, start} from "single-spa";

const header = document.querySelector("#header");
registerApplication({
  name: "header",
  activeWhen: () => true,
  app: {
    bootstrap() {},
    mount() {
      // Write implementation for header here
      header.textContent = "mounted";
    },
    unmount() {
      // Write cleanup for header here, though it won't be called since it stays mounted
      header.textContent = ""
    }
  }
});

const main = document.querySelector("#main");
registerApplication({
  name: "home",
  activeWhen: "/",
  app: {
    bootstrap() {},
    mount() {
      // Mount a button that jumps to about
      const button = document.createElement("button");
      button.textContent = "go to about";
      button.addEventListener("click", () => {
        navigateToUrl("/about");
      });
      main.appendChild(button);
    },
    unmount() {
      // Cleanup process
      main.textContent = "";
    }
  }
});

// about
registerApplication({
  name: "about",
  activeWhen: "/about",
  app: {
    bootstrap() {},
    mount() {
      main.textContent = "about";
    },
    unmount() {
      main.textContent = "";
    }
  }
});

start();

Define the application lifecycle. This part is framework-independent, and in practice, you would implement the logic to mount each application here.

You can transition between pages using single-spa's navigateToUrl.

Mounting an Application by Specifying an External URL

I wrote the following utility to initialize external assets.

// shell/src/utils.ts

const permanentRoot = document.querySelector("#main") as HTMLElement;
let el: HTMLElement | null;

export function getRoot(): HTMLElement {
  if (permanentRoot.firstChild == null) {
    cleanup();
  }
  return el as HTMLElement;
}

let cycle = 0;

export function cleanup() {
  el?.remove();
  el = document.createElement("div");
  el.id = "root";
  el.dataset.cycle = (cycle++).toString();
  permanentRoot.appendChild(el);
}

export function createExternalApp(options: { endpoint: string }): Application {
  let unmountListeners: Array<Disposable> = [];
  return {
    async bootstrap() {},
    async mount(props) {
      console.log("[external:mount]", options.endpoint);
      const root = getRoot();
      // Disable Vite's dynamic import. In other words, use ES Modules as they are.
      const mod = await import(/* @vite-ignore */ options.endpoint);
      try {
        const isActiveYet = !!root.parentElement;
        if (isActiveYet) {
          const disposable = await mod.default(props);
          unmountListeners.push(disposable);
        } else {
          console.warn("disposed");
        }
      } catch (err) {
        console.warn("mount error", err);
      }
    },
    async unmount() {
      console.log("[external:unmount]", options.endpoint);
      await Promise.all(unmountListeners.map((disposable) => disposable()));
      unmountListeners = [];
      cleanup();
    },
    async update() {},
  };
}

This defines an application that starts via dynamic import.

One small tweak I made here is recreating the root element under main every time. I realized later that passing HTML elements to various frameworks often causes them to be processed, leaving behind various listeners and properties. In particular, when passed to Vue, the state after unmounting was so cluttered with proprietary properties that it became unusable by other frameworks.

Using this utility, we add it as a single-spa application.

// shell/src/main.ts
// ...
import {createExternalApp, getRoot} from "./utils"
registerApplication({
  name: "external",
  activeWhen: (loc) => loc.pathname.startsWith("external")),
  app: createExternalApp({ endpoint: "/external.js" }),
  customProps: {
    getRoot
  }
});

customProps allows you to pass functions as props. Since getRoot can be called here, we retrieve the root element for mounting and pass it.

In Vite, the public directory is directly added as static assets, so let's place a public/external.js that follows our convention.

// shell/public/external.js
export default async (props) => {
  const root = props.getRoot();
  root.textContent = "external";
  return () => {
    console.log('unmount external')
  };
};

This is the simplest form of an External Application.

External Application Built with Vite

The shell/public/external.js specified in the shell earlier is assumed to be a pre-built JS file.
Now, let's try mounting an application consisting of multiple chunks built with Vite.

Create a new project named nested in the project root.

$ yarn create @vitejs/app nested

I selected react-ts for the preset.
While it runs as a standalone Vite application with yarn dev, we will modify it to be loaded using createExternalApp.

Edit vite.config.js as follows:

nested/vite.config.js
import { defineConfig } from "vite";
import reactRefresh from "@vitejs/plugin-react-refresh";
import path from "path";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [reactRefresh()],
  base: `/nested/`,
  build: {
    emptyOutDir: true,
    // This time, instead of deploying it independently, we output it to the shell's public directory.
    outDir: path.join(__dirname, "../shell/public/nested"),
    lib: {
      formats: ["es"],
      entry: path.join(__dirname, "src/index.tsx"),
    },
  },
});

The important parts are the base and outDir specifications. base is the relative path root for loading the output JS chunks.
In this example, I'm taking a shortcut: normally, to keep deployment units separate, you should specify your own CDN and upload there. However, for now, I'm modifying outDir to push it into the shell's public directory. In this setup, the relative path of the nested assets from the shell's preview environment becomes /nested/.

The output filename will be the name in package.json, so specify "name": "nested".

Next, modify src/index.tsx specified in the entry point as follows:

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

export default (props) => {
  const root = props.getRoot();
  ReactDOM.render(<App ...>, root);
  return () => {
    // Cleanup process
    ReactDOM.unmountComponentAtNode(root);
  }
}

In this state, running yarn build should generate shell/public/nested/nested.es.js.

The structure should look like this now:

nested/
  src/...
  vite.config.js
shell/
  public/
    nested/
      nested.es.js
      assets/...
  src/
    main.ts
    utils.ts

Add this to be loaded from the shell.

shell/src/main.ts
import {createExternalApp, getRoot} from "./utils"
registerApplication({
  name: "nested",
  activeWhen: (loc) => loc.pathname.startsWith("/nested")),
  app: createExternalApp({ endpoint: "/nested/nested.es.js" }),
  customProps: {
    getRoot
  }
});

Start the shell with vite dev and open http://localhost:3000/nested.
The application will now launch as a standalone application, even when split into multiple chunks.

It will also work even if you transition using react-router within this nested application.

Partial Local Execution

In k8s, you can use Telepresence to swap out part of a cluster with a local environment. I wanted to achieve something similar with micro-frontends.

Home | Telepresence

Since each screen has an independent configuration, assuming pointing to production or staging is fine, it seems possible to swap only the parts you are responsible for with a local environment.

If each frontend uploads to a CDN, you could switch using a configuration file like this:

type RemoteConfig = {
  name: string;
  endpoint: string;
};

const HOST = `${location.protocol}//${location.host}`;

const remoteConfigList: RemoteConfig[] = [
  {
    name: "foo",
    // endpoint: "http://localhost:3001/foo/foo.es.js",
    endpoint: "https://cdn.example.com/dist/foo/foo.es.js"
  },
  {
    name: "bar",
    // endpoint: "http://localhost:3001/foo/foo.es.js",
    endpoint: "https://cdn.example.com/dist/bar/bar.es.js"
  },
];

remoteConfigList.forEach((config) => {
  registerApplication({
    name: config.name,
    activeWhen: (loc) => loc.pathname.startsWith("/" + config.name),
    app: createExternalApp({ endpoint: config.endpoint }),
    customProps: sharedProps,
  });
});

By commenting out and controlling the destination as needed, you no longer need to run screens other than those you are responsible for locally.


I might write the following later after actually operating it...

Deployment Management with GitHub Actions

Authentication and API

Discussion