Micro-frontend architecture alongside Vue 3 migration
Tags: frontend, javascript, vue, microfrontend
Published: 2024-06-21 08:45:29

## 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](https://github.com/originjs/vite-plugin-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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/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](https://github.com/originjs/vite-plugin-federation).

**[shell] vite.config.js**

```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
    }),
  ],
});
```

### Configuration of the remote

**[remote1] vite.config.js**

```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
    })
  ]
})
```

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/;
}
```

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';
```

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tqcqz5qbforlcyd2ld46.png)

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

**[remote1] src/main.ts**

```js
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);
}
```

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**

```js
<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>
```

## Connection of remotes with shell

See how the _RemoteWrapper_ is used in the router to load our _remote_:

**[shell] src/routes.ts**

```js
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",
        },
      },
    ],
  },
];
```

## 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.