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