Mono & Msa : Front 5. Single Spa


Posted on Sat, Dec 3, 2022 architecture frontend node vue singlespa

πŸ‘·β€β™€οΈ Building a Micro-Frontend With Single-SPA

Single-Spa technology πŸ”₯

Have you ever wondered why many people prefer frameworks over reinventing the wheel by writing everything in their vanilla/raw form? The answer is simple, simplicity and conformity.

That's exactly what Single-Spa does in the micro frontend ecosystem. Single-Spa removes the complexity in setting up a micro frontend app by providing boilerplates and scaffolding the entire process.

So you can get your micro frontend apps up and running in a short period of time with the help of Single-Spa!

Single-Spa is basically a Javascript router for front-end micro frontends. It allows us to build and connect many Single Page Applications (SPAs) micro-frontends and provide a means of communication for them so that they can function seamlessly together.

There are many limitations that we face when building our frontend applications. Some of these problems include the inability to deploy some part of the frontend application. Without deploying the whole application.

🀷 Why use Single-Spa?

Other micro frontend frameworks exist, but below are some reasons why we'll be using Single-Spa to manage our micro-frontend application.

  1. Use many Javascript frameworks in one application

    This is a general feature of micro-frontends. Single-spa being a micro-frontend tool enables you to use different JavaScript frameworks in the same application.

  2. Deploy your micro-frontends independently

    With Single-spa, you can easily deploy each part of your application independently with deploying the whole application. This can be very handy as it promotes agile development.

  3. Lazy loading for improved load time

    Single-spa loads only essential components of your application on the browser. The other units can be loaded later on as per use. This practice will improve the loading speed of webpages.

  4. It supports routing between micro-frontends and shared resources

    Single-Spa serves basically as a router between micro-frontend and shared dependencies. It accomplishes this task with its importmap function.

State Management in Micro Frontends

State management is quite tricky in the micro frontend ecosystem. Using a single global state manager like redux, mobx, and other global state management libraries is not a recommended approach by the Single-Spa team.

Instead, if you wish to use a state manager in your application, it's advisable to use a state management tool to manage the state of a single micro frontend instead of a single store for all of your micro frontends.

This is so, because using a global store to manage the state of an application makes it difficult to deploy our application independently. Hence, use local component state for your application components, or a store for each of your micro frontends.

For more details on state management, visit Inter-app communication.

Module Federation

Module Federation is a feature introduced in Webpack 5, which allows you to share code between different applications running in separate environments, such as (micro frontends).

It enables applications to consume code from other applications as if it were locally available, without the need for a build step or a separate server. This allows you to build modular, scalable applications that can be developed and deployed independently.

🧠 Example : you could have an e-commerce application with a separate checkout application, and use Module Federation to share code between them, such as the shopping cart module.

πŸ’» The tech under the hood : Module Federation works by creating a remote entry point in one application, and a remote container in another. The remote entry point exposes modules that can be consumed by other applications, and the remote container consumes those modules as if they were locally available.

Single-Spa Setup and Usage

We've been talking about singles. It's now time to get our feet wet by setting up a micro frontend application with Single-Spa. The process is rigorous. We're going to cover everything from scratch, so you don't have to worry about files that we didn't create.

Overview of the Application We're Building

We've so far covered what Single-Spa and Module Federation are. It's time to build our first micro frontend project application. I know you're wondering what this application does. Below is a breakdown of the Single-Spa and Module Federation application we're going to build in this section.

The aforementioned is the overview of the full application we'll be building. You can check out the source code at : https://github.com/xotoboil/xotoboil-simple-multifront

Let’s start the building process ‡️

1. Setting up the root folder

πŸ“¦ The package.json

Before implementing the micro-frontends app, we need a package.json file at the root. The three apps are included in one repo as a monorepo, so we’ll use yarn workspaces to share some packages across the entire app.

Create the package.json at project root

Before we get started with our micro-frontends app creation, create a package.json file at the root. I'll be using a yarn workspace.

Add the code below to package.json :

{
  "name": "xotoboil-simple-multifront",
  "version": "1.0.0",
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "scripts": {
    "start": "concurrently \"wsrun --parallel start\""
  },
  "devDependencies": {
    "concurrently": "^5.3.0",
    "html-webpack-plugin": "5.5.0",
    "single-spa": "^5.8.3",
    "typescript": "5.0.2",
    "webpack": "latest",
    "webpack-cli": "^4.3.0",
    "webpack-dev-server": "^3.11.1",
    "wsrun": "^5.2.4"
  },
  "dependencies": {
    "@types/single-spa-react": "^4.0.0"
  }
}

Run the code below to install all dependencies :

yarn

2. Building the Home App

This Home App holds other micro frontends applications. This App is where all the other micro frontend applications are used together. It performs the task of rendering HTML pages and the JavaScript that registers the applications.

Our Home folder structure will look like this :

β”œβ”€β”€ packages
β”‚   └── home
β”‚       β”œβ”€β”€ public
β”‚       β”‚   └── index.html
β”‚       β”œβ”€β”€ src
β”‚       β”‚   └── index.ts
β”‚				β”œβ”€β”€ package.json
β”‚       β”œβ”€β”€ tsconfig.json
β”‚       └── webpack.config.js

We'll create these files one after the other.

{
  "name": "home",
  "scripts": {
    "start": "webpack serve --port 3000",
    "build": "webpack --mode=production"
  },
  "version": "1.0.0",
  "private": true,
  "devDependencies": {
    "@babel/core": "^7.8.6",
    "@babel/preset-typescript": "^7.12.7",
    "babel-loader": "^8.2.2"
  }
}

{
  "compilerOptions": {
    "target": "es5",
    "moduleResolution": "node",
    "module": "ESNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

const path = require('path')

const HtmlWebpackPlugin = require('html-webpack-plugin')
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')
const outputPath = path.resolve(__dirname, 'dist')

module.exports = {
    entry: './src/index',
    cache: false,
    mode: 'development',
    devtool: 'source-map',
    optimization: {
        minimize: false,
    },
    output: {
        publicPath: 'http://localhost:3000/',
    },
    resolve: {
        extensions: ['.jsx', '.js', '.json', '.ts', '.tsx'],
    },
    devServer: {
        contentBase: outputPath,
    },
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                loader: require.resolve('babel-loader'),
                options: {
                    presets: [require.resolve('@babel/preset-typescript')],
                },
            },
        ],
    },
    plugins: [
        new ModuleFederationPlugin({
            name: 'home',
            library: { type: 'var', name: 'home' },
            filename: 'remoteEntry.js',
            remotes: {
            },
            exposes: {
            },
            shared: [ 'react' ],
        }),
        new HtmlWebpackPlugin({
            template: './public/index.html',
        }),
    ],
}

This is a basic webpack configuration. You'll understand how everything works better in the following section using remotes, exposes, and shared parameters.

1️⃣ remotes

The remotes field takes the name of the federated micro-frontend app to consume the code. Our home app will use a navigation app and body app in this case. Hence, we need to specify the name of them in the remotes field. Here's how the remotes parameter will soon look like for our Home App.

remotes: {
	navigation: 'navigation',
	body: 'body',
}

2️⃣ exposes

The exposes parameter is used to export files to other applications. For example, if you want other applications to access button component, you can do it this way.

exposes: {
	Button: './src/Button',
}

⚠️

β€œWe aren’t going to build a Button component for now, so keep the exposes object blank."

3️⃣ shared

This parameter has a list of all shared dependencies in support of exported files. For instance, if you export React's components, you will have to list them containing React.

shared: ['react']

This prevents duplication of packages and libraries in our micro frontend application.

  • Next, create public/index.html file and add the code below :

<!DOCTYPE html>
<html lang="en">
	<head>
	    <link rel="stylesheet" href="https://cdn.rawgit.com/filipelinhares/ress/master/dist/ress.min.css" />
	    <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Oswald:300,400,500,600,700|Roboto:400,700&display=swap" />
	</head>
	<body>
		<main>
			body
		</main>
	</body>
</html>

  • And finally create a src/index.ts file and add the codes below :

import { start } from 'single-spa'
start()

We’ll soon add more to index.ts file when we register a new application.

The files we created so far will be used to register our micro-frontends app in the home app.

3. Building the Navigation App

We're done with the setup of our home app, let’s implement the navigation app in React.

Below is the folder structure of our micro frontend application that we’ll get after adding the navigation app :

β”œβ”€β”€ packages
β”‚   β”œβ”€β”€ home
β”‚   β”‚   β”œβ”€β”€ package.json
β”‚   β”‚   β”œβ”€β”€ public
β”‚   β”‚   β”‚   └── index.html
β”‚   β”‚   β”œβ”€β”€ src
β”‚   β”‚   β”‚   └── index.ts
β”‚   β”‚   β”œβ”€β”€ tsconfig.json
β”‚   β”‚   └── webpack.config.js
β”‚   └── navigation
β”‚       β”œβ”€β”€ package.json
β”‚       β”œβ”€β”€ src
β”‚       β”‚   β”œβ”€β”€ Footer.tsx
β”‚       β”‚   β”œβ”€β”€ Header.tsx
β”‚       β”‚   └── index.ts
β”‚       β”œβ”€β”€ tsconfig.json
β”‚       β”œβ”€β”€ webpack.config.js

In this, we are going to create two components β€” Header and Footer. We will export the components to the home app that we built earlier.

To do that, we first have to create navigation app files.

  • Create a folder called navigation inside the packages folder

  • Create a package.json file inside the navigation folder and add the code below :

{
  "name": "navigation",
  "scripts": {
    "start": "webpack serve --port 3001",
    "build": "webpack --mode=production"
  },
  "version": "1.0.0",
  "private": true,
  "devDependencies": {
    "@babel/core": "^7.8.6",
    "@babel/preset-react": "^7.12.10",
    "@babel/preset-typescript": "^7.12.7",
    "@types/react": "^17.0.0",
    "@types/react-dom": "^17.0.0",
    "babel-loader": "^8.2.2",
    "single-spa-react": "^3.2.0"
  },
  "dependencies": {
    "@babel/preset-env": "^7.20.2",
    "react": "^17.0.1",
    "react-dom": "^17.0.1",
    "ts-loader": "^9.4.2"
  }
}

We will use React in building our navigation app. So we will be installing it as a dependency as you can see above.

  • Next, create a tsconfig.json file inside the navigation folder. Add this code inside it :

{
  "compilerOptions": {
    "jsx": "react",
    "target": "es5",
    "moduleResolution": "node",
    "module": "ESNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

  • Finally, create a webpack.config.js inside navigation folder and add the code below :

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const path = require("path");
const outputPath = path.resolve(__dirname, "dist");

module.exports = {
    entry: "./src/index.ts",
    cache: false,
    mode: "development",
    devtool: "source-map",
    optimization: {
        minimize: false,
    },
    output: {
        publicPath: "http://localhost:3001/",
    },
    resolve: {
        extensions: [".jsx", ".js", ".json", ".ts", ".tsx"],
    },
    devServer: {
        contentBase: outputPath,
    },
    module: {
        rules: [
            {
                test: /\.(js|jsx)$/,
                exclude: /node_modules/,
                use: ["babel-loader"],
            },
            {
                test: /\.(ts|tsx)$/,
                exclude: /node_modules/,
                use: ["ts-loader"],
            },
        ],
    },
    plugins: [
        new ModuleFederationPlugin({
            name: "navigation",
            library: { type: "var", name: "navigation" },
            filename: "remoteEntry.js",
            remotes: {},
            exposes: {
                "./Header": "./src/Header",
                "./Footer": "./src/Footer",
            },
            shared: ["react", "react-dom", "single-spa-react"],
        }),
    ],
};

πŸ‘·β€β™‚οΈ More about the above webpack.config.js

If you go through the codes, you'll see a line containing publicPath inside output

output: {
		publicPath: "http://localhost:3001/",
}

publicPath is the base name of the remote URL that our home app will use. In this case, the navigation app will be served at http://localshot:3001

Header and Footer components will be exported to the Home app as they're in the values of exposes parameter.

exposes: {
		"./Header": "./src/Header",
		"./Footer": "./src/Footer",
},

Shared has a list of libraries that we are sharing. In this case, we need to write react, react-dom, and single-spa-react. As we're building our application with React library.

shared: ["react", "react-dom", "single-spa-react"]

4. Creating Header and Footer Components

  • Create a src/Header.tsx file and insert the code below :

import React from "react";
import ReactDOM from "react-dom";
import singleSpaReact from "single-spa-react";

const Header: React.VFC = () => {
    return (
        <header
            style={{
                width: "100%",
                background: "#6565bf",
                color: "#FFFFFFFF",
                padding: "1rem",
                minHeight: "50px",
            }}
        >
            Header from React
        </header>
    );
};

const lifecycles = singleSpaReact({
    React,
    ReactDOM,
    rootComponent: Header,
});

export const bootstrap = lifecycles.bootstrap;
export const mount = lifecycles.mount;
export const unmount = lifecycles.unmount;

  • Create a src/Footer.tsx file and insert the code below :

import React from "react";
import ReactDOM from "react-dom";
import singleSpaReact from "single-spa-react";

const Footer: React.VFC = () => {
    return (
        <footer
            style={{
                width: "100%",
                background: "#6565bf",
                color: "#FFFFFFFF",
                padding: "1rem",
                minHeight: "50px",
            }}
        >
            Footer from React
        </footer>
    );
};

const lifecycles = singleSpaReact({
    React,
    ReactDOM,
    rootComponent: Footer,
});

export const bootstrap = lifecycles.bootstrap;
export const mount = lifecycles.mount;
export const unmount = lifecycles.unmount;

  • Finally, create a src/index.ts file and add the following code :

import "./Footer"
import "./Header"

5. Registering the Navigation Application

We're done with our Navigation App, It's now time to register it in the Home application.

To register the micro-frontend app, the following steps are required :

  • Include the script tag
  • List in remotes
  • Register the app
  • Include a container div

We'll take the steps one after the other.

  • Include the script tag in home/public/index.html file

In order to use the code from the navigation app, we have to include it in the HTML file.

To do so, go to home/public/index.html and add the publicPath of the navigation app in the script tag . Include the script tag as shown in the code below :

<!DOCTYPE html>
<html lang="en">
<head>
	<script src="<http://localhost:3001/remoteEntry.js>"></script>
	...
</head>

  • List in remotes

Next, go to home/webpack.config.js and specify the navigation app in the remotes section like below :

remotes: {
	home-nav: 'navigation',
},

We are using navigation as it is the name we used in the configuration in navigation/webpack.config.js file.

The home-nav is the name used in the home app to refer to the navigation app.

  • Register the app

Next, go to the home/src/index.ts file to register a navigation application. Change the code so that it looks like below :

import { registerApplication, start } from "single-spa";

registerApplication(
  "header",
  // @ts-ignore
  () => import("home-nav/Header"),
  (location) => location.pathname.startsWith("/")
);

registerApplication(
  "footer",
  // @ts-ignore
  () => import("home-nav/Footer"),
  (location) => location.pathname.startsWith("/")
);

start();

  • Include a DIV container

We are almost done. It's now time to add a DIV container to hold the footer and header component.

Go to home/public/index.html and add them :

<body>
	<div style="height: 100%; display: flex; flex-direction: column;">
		<div id="single-spa-application:header"></div>
		<main>
			body
		</main>
		<div id="single-spa-application:footer"></div>
	</div>
</body>

πŸ€– Single-Spa by default will search for the id single-spa-application:{app name} and render the HTML there.

In this case, we’ve already registered the Header and Footer as β€œheader” and β€œfooter” so it will find the id β€” single-spa-application:header and single-spa-application:footer

  • Let’s run this Application

Run the code below again, to install all dependencies :

yarn

And start the server from the root folder :

yarn start

Navigate to http://localhost:3000 and you will find that two React components rendered successfully.

❀️‍πŸ”₯ Congratulations on your new app. Let's keep the fire burning ‡️

6. Building the Home Application

We're almost done. It's now time to build our Body App. The process is very similar to the one we did for the Navigation Application.

The folder structure of our Body App will look like this :

β”œβ”€β”€ packages
β”‚   β”œβ”€β”€ body
β”‚   β”‚   β”œβ”€β”€ package.json
β”‚   β”‚   β”œβ”€β”€ src
β”‚   β”‚   β”‚   β”œβ”€β”€ App.vue
β”‚   β”‚   β”‚   β”œβ”€β”€ app.js
β”‚   β”‚   β”‚   └── index.js
β”‚   β”‚   β”œβ”€β”€ tsconfig.json
β”‚   β”‚   └── webpack.config.js

  • Create a folder body inside the packages folder

mkdir packages/body

  • Create body/package.json and add the code below :

{
  "name": "body",
  "scripts": {
    "start": "webpack serve --port 3002",
    "build": "webpack --mode=production"
  },
  "version": "1.0.0",
  "private": true,
  "devDependencies": {
    "@babel/core": "^7.8.6",
    "@babel/preset-env": "^7.10.3",
    "@vue/compiler-sfc": "^3.0.0-rc.10",
    "babel-loader": "^8.2.2",
    "css-loader": "^3.5.3",
    "postcss-loader": "^4.1.0",
    "sass": "^1.60.0",
    "sass-loader": "^10.1.0",
    "style-loader": "2.0.0",
    "vue-loader": "16.0.0-beta.7",
    "vue-style-loader": "^4.1.2"
  },
  "dependencies": {
    "autoprefixer": "^10.1.0",
    "postcss": "^8.2.1",
    "single-spa-vue": "^2.1.0",
    "vue": "^3.0.0"
  }
}

From the above file, you can see that we're using Vuejs as one of the dependencies instead of React for this app.

  • Next, create a tsconfig.json and add the code below :

{
  "compilerOptions": {
    "target": "es5",
    "moduleResolution": "node",
    "module": "ESNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

  • Finally, create a body/webpack.config.js and add the code below :

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const path = require("path");
const outputPath = path.resolve(__dirname, "dist");
const { VueLoaderPlugin } = require("vue-loader");

module.exports = {
    entry: "./src/index.ts",
    cache: false,
    mode: "development",
    devtool: "source-map",
    optimization: {
        minimize: false,
    },
    output: {
        publicPath: "http://localhost:3002/",
    },
    resolve: {
        extensions: [".jsx", ".js", ".json", ".ts", ".tsx", ".vue"],
    },
    devServer: {
        contentBase: outputPath,
    },
    module: {
        rules: [
            {
                test: /\.s[ac]ss$/i,
                use: [
                    "vue-style-loader",
                    "style-loader",
                    "css-loader",
                    "postcss-loader",
                    "sass-loader",
                ],
            },
            {
                test: /\.vue$/,
                loader: "vue-loader",
            },
            {
                test: /\.js$/,
                loader: "babel-loader",
            },
            {
                test: /\.tsx?$/,
                loader: "ts-loader",
                options: {
                    appendTsSuffixTo: [/\.vue$/],
                },
                exclude: /node_modules/,
            },
        ],
    },
    plugins: [
        new VueLoaderPlugin(),
        new ModuleFederationPlugin({
            name: "body",
            library: { type: "var", name: "body" },
            filename: "remoteEntry.js",
            remotes: {},
            exposes: {
                "./Body": "./src/app",
            },
            shared: ["vue", "single-spa", "single-spa-vue"],
        }),
    ],
};

As you can see, we’re exposing the Body component from this app.

  • Next, let’s add the Vue component

Create a body/src/App.vue and add the code below :

<template>
  <div class="body">
    <div class="body--container">
      Body from Vue.js
    </div>
  </div>
</template>
<style scoped lang="scss">
.body {
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  
  &--container {
    width: 100%;
    height: 300px;
    display: flex;
    padding: 1rem;
    color: white;
    align-items: center;
    background: #42b983;
  }
}
</style>

Next, create a body/src/app.ts and insert the following code :

import singleSpaVue, { AppOptions } from "single-spa-vue";
import { h, createApp, render } from "vue";
import App from "./App.vue";

const lifecycles = singleSpaVue({
    createApp,
    async appOptions(props: AppOptions) {

            return h(App, {
                props: {
                    // single-spa props are available on the "this" object. Forward them to your component as needed.
                    // <https://single-spa.js.org/docs/building-applications#lifecyle-props>
                    name: props.name,
                    mountParcel: props.mountParcel,
                    singleSpa: props.singleSpa,
                },
            });
    },
});

export const bootstrap = lifecycles.bootstrap;

export const mount = lifecycles.mount;

export const unmount = lifecycles.unmount;

Then create a index.ts which imports the app component.

import "./app"

Finally, create a src/declarations/shims-vue.d.ts file and add the code below :

declare module "*.vue" {
	import type { DefineComponent } from "vue";
	const component: DefineComponent<{}, {}, any>;
	export {component}
}

7. Registering The Body Application

Next, let’s register the body in the home app as we did before for the navigation app.

  • Go to home/public/index.html and add another script tag for the Body App :

<head>
	<script src="<http://localhost:3001/remoteEntry.js>"></script>
	<script src="<http://localhost:3002/remoteEntry.js>"></script>
	...
</head>

http://localhost:3002/remoteEntry.js is the publicPath for our Body component as mentioned in the webpack.config.js of the Body app.

  • Then go to home/webpack.config.js file and add home-body to the remotes object :

remotes: {
	'home-nav': 'navigation',
	'home-body': 'body',
}

  • Finally, go to home/src/index.ts to register it :

import { registerApplication, start } from "single-spa";

registerApplication(
  "header",
  // @ts-ignore
  () => import("home-nav/Header"),
  (location) => location.pathname.startsWith("/")
);

registerApplication(
  "footer",
  // @ts-ignore
  () => import("home-nav/Footer"),
  (location) => location.pathname.startsWith("/")
);

registerApplication(
  "body",
  // @ts-ignore
  () => import("home-body/Body"),
  (location) => location.pathname.startsWith("/")
);

start();

We added another registerApplication function call for the body app.

8. Running Our Full Application

Once again, install all dependencies at the root :

yarn

And start the server :

yarn start

Navigate to http://localhost:3000 to view the application.

🀜 Congratulations ! you’ve created your first micro frontend application with Single-Spa, React, and Vue !

9. In Conclusion

In conclusion, Single-spa is a powerful tool for building complex web applications using multiple frameworks and micro services.

Its modular and flexible architecture allows developers to create seamless and efficient user experiences while maintaining independence between the different parts of the application.

With its extensive documentation and active community, Single-spa is a great choice for developers and large companies looking to streamline and scale their development process to deliver high-quality applications without the need to rely on any single framework.

➑️ To learn more about Single-Spa, visit the Single-Spa example page and for the articles source code feel free to checkout : https://github.com/xotoboil/xotoboil-simple-multifront.

Thank and see you on the next one πŸš€ !