Micro-frontend architecture alongside Vue 3 migration

Damien Le Dantec - Jun 21 - - Dev Community

Introduction

The micro-frontend architecture can resolve many issues with your frontend application.
In my context, my need was to split a Vue.js application into independent modules.
Because the original application was in Vue 2, a migration to Vue 3 was necessary.
I used the micro-frontend architecture to do a progressive migration, module per module.
In this article, I will explain how it works.

Preliminary informations

There are many ways to create a micro-frontend architecture. In our case, we use the module federation with Vite (Webpack also has his module federation plugin).

To be clear on the terms, in the context of module federation, the part of the architecture that carries all the others is named the shell and each micro-frontend is named a remote.
For our progressive migration, the shell is the initial application in Vue 2 and the remotes will be created in Vue 3. Each remote will be created one by one to do a progressive migration.

Setting up modules federation

Setting up module federation is pretty simple. My starting point is to have a Vue 2.7 application using Vite.
Once the module federation plugin is installed, you can create remotes applications (Each application is a micro-frontend).

How does modules federation works?

All the architecture is based on the shell. It is in charge of fetching and loading the remotes in the shell application.
Using module federation, the remotes are loaded using dynamic import.
In our case, we have a slight adaptation to cohabit Vue 2 shell and Vue 3 remotes using Web Components. This is the Web Component technology that isolates modules from each other and allows us to do a progressive Vue 3 migration.

Configuration of the shell

To set up module federation on the shell side, you just need to update your Vite config using the vite-plugin-federation plugin.

[shell] vite.config.js

import federation from "@originjs/vite-plugin-federation";

export default defineConfig({
  plugins: [
    vue(),
    federation({
      name: "my-application", // Name of your application
      remotes: {
        remote1: "http://localhost:4173", // URL of the remote
      },
      shared: ["vue"], // We share "vue" dependency with all our remotes
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

Configuration of the remote

[remote1] vite.config.js

import federation from "@originjs/vite-plugin-federation";

export default defineConfig({
  base: '/remote1', // The remote will be loaded under "remote1" folder
  plugins: [
    vue(),
    federation({
      name: 'remote1', // Name of the remote
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/main.ts'
      },
      shared: ['vue'] // We share "vue" dependency with all our remotes
    })
  ]
})
Enter fullscreen mode Exit fullscreen mode

Using this configuration, the entry point of our remote will be /dist/assets/remoteEntry.js.
We expose our entire application created by the src/main.ts file. We will see the content of this file later.

On the shell side, we will load our remote using import(remote1/App).

Deployment of remotes in subfolders

As you can see in the configuration, each remote will be deployed as a subfolder of the shell application (for example, we access to remote1 via http://myapp.com/remote1).

This is done to facilitate application routing.

In production mode, we will use a reverse proxy to redirect HTTP requests to the proper remote. Here is an example of location config using Nginx:

location /remote1/assets/ {
    proxy_pass http://${REMOTE1_INSTANCE}/assets/;
}
Enter fullscreen mode Exit fullscreen mode

The usage of the suffix "assets" is to handle the refresh of the page and do a redirection to the shell, which is in charge of routing (The shell fetches remotes with a <remote>/assets/remoteEntry.js URL).

TypeScript configuration

Because remote1/App is unknown for our shell application, we need to specify to TypeScript that "remote1/App" is a valid path.
To do this, you can create a remotes.d.ts file with this content:

declare module 'remote1/App';
Enter fullscreen mode Exit fullscreen mode

Multi framework approach

Using only the previous configurations cause errors because Vue 2 shell and Vue 3 remotes will be incompatible.
To fix this, we use Web Components. We create one Web Component per remote that will be transformed in classic Vue 2 component in the shell.

schema_multi_framework

To create a Web Component per remote, see the src/main.ts file of the remote:

[remote1] src/main.ts

import { createApp } from "vue";
import App from "./App.vue";
import router from './router.ts';

if (import.meta.env.DEV) {
  // In dev mode, we load the component to be able to launch the remote in standalone mode
  createApp(App).use(router).mount("#app");
} else {
  // In production mode, we generate the Web Component
  class RemoteWebComponent extends HTMLElement {
    connectedCallback() {
      createApp(App).use(router).mount(this); // We mount the Vue component in the Web Component
    }
  }

  customElements.define("remote1-web-component", RemoteWebComponent);
}
Enter fullscreen mode Exit fullscreen mode

Once our Web Component is created, we need to have a Vue 2 component to load it in the shell application:

[shell] src/RemoteWrapper.ts

<script lang="ts">
export default {
  props: {
    elementName: { // Name of the web component that will be loaded
      type: String,
      required: true,
    },
    loadChild: { // Function to load remote (using dynamic import)
      type: Function,
      required: true,
    }
  },
  async mounted() {
    const element = document.createElement(this.elementName);
    await this.loadChild();

    const container = this.$refs.container as HTMLElement;
    if (element && container) {
      container.appendChild(element);
    }
  }
}
</script>

<template>
  <div ref="container"></div>
</template>
Enter fullscreen mode Exit fullscreen mode

Connection of remotes with shell

See how the RemoteWrapper is used in the router to load our remote:

[shell] src/routes.ts

const routes = [
  {
    path: "/remote1",
    component: {...RemoteWrapper}, # We use new component for each route to be sure that the routing works properly
    props: {
      loadChild: import("remote1/App"),
      elementName: "remote1-web-component",
    },
    children: [
      {
        path: "*", // In case of we have routing in our remote
        component: {...RemoteWrapper},
        props: {
          loadChild: import("remote1/App"),
          elementName: "remote1-web-component",
        },
      },
    ],
  },
];
Enter fullscreen mode Exit fullscreen mode

Mode standalone

As you can see in the src/main.ts file of the remote, we separate the dev mode and the production mode. The dev mode is used to launch the remote independently and not be aware of the shell during the development process.

Conclusion

To split the application into independent modules, the use of module federation is enough. However, to cohabit Vue 2 and Vue 3 modules, and do a progressive migration, we needed to use Web Components.

Once the migration is complete, we can remove the Web Component logic that adds some useless complexity at this point.

To go further, some ideas would be:

  • Use a monorepo tool to easily manage your remotes and your shell
  • Use shared library in your monorepo to share code between remotes
  • Use utility function to get the URL of the remotes about the dev or prod mode.
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .