Micro implementation of micro frontends with Vue 3

If you want to divide your application into client services or if you already have separate applications which are meant to be displayed together on one page, one of the simplest solutions is to use iframes. This solution is relatively easy to implement and provides good encapsulation of applications (no conflicts in JS, no common dependencies or styles, etc).

There are a lot of different approaches to this topic and a lot of different solutions that depend on what is the goal you want to achieve. Iframe-based solution also has its drawbacks so it's worth looking at other options before implementing it (pretty interesting comparison).

If you want to embed one or more applications within a single page, it is worth trying to implement an intermediate layer to ensure the readability of such a solution. Communication with the parent application from an iframe is possible using the postMessage mechanism. As for the parent–child communication, it is also possible to use postMessage, but the additional communication channel is the child application URL (path & query params).

Let's assume we have something better than postMessage mechanism

I don't know if you've had to deal with the postMessage mechanism before. If not, take my word for it, there are more interesting ways to commit suicide.

So instead of using this mechanism directly, let's think about what the communication between child and parent might look like if we were to invent it ourselves.

My idea is to make embedding the application and communicating with it as easy as using a component. To make this happen, I can imagine that there is a wrapper component MicroFrontend that will inject props to child application and receive it's messages and translate them into Vue events.

Host application (usage)

Let's assume that there is a separate application running on path /account and it exposes the form to create an account at /account/create that can be embedded on other pages. You can prefill form with an email by passing prop email. After account is created child app sends the event account-created with some payload of type UserData.

Here is how it may look like from host application perspective (our goal):

<template>  <MicroFrontend    frame-src="/account/create"    :email="email"    @account-created="onAccountCreated"  /></template><script setup lang="ts">import { ref } from 'vue'import { MicroFrontend } from '@/services/micro-frontends'// ...const email = ref('user@example.com')const onAccountCreated = (userData: UserData) => {  // ...}</script>

Short and sweet! Pretty easy to read, isn't it?

Guest application (usage)

So now we need a mechanism to emit an event from a child application. Usage may look like this:

<template>  <form @submit.prevent="createAccount">    <!-- ... -->  </form></template><script setup lang="ts">import { useHostApp } from "@/services/micro-frontends";const { emit, props, hasHostApplication } = useHostApp<{ email: string; }>();const userData = reactive({ email: props.email }); // automatically typed as { email: string }// ...const createAccount = () => {  // ...  emit("account-created", userData);  if (!hasHostApplication.value) {    router.push("/success");  }};</script>

Note that sometimes we want to have different behavior depending on whether the application was displayed inside the iframe or runs independently - this is why hasHostApplication may also be a useful tool.

Let's the dream come true

And that would be pretty much it - we do have a two-way communication, everything is very minimalistic and easy to use. It's also implemented in a "vue way".

If you like it, well... we know what is the goal - it's time to implement it! 🧑‍💻

Basic MicroFrontend component

Let's start from a MicroFrontend component. We need to do a couple of things here:

  • render the iframe (obviously)
  • listen to events from child application and emit them as vue events
  • pass props down to the child component (this is probably the trickiest part, so let's skip it for now)

<template>  <iframe    ref="iframeRef"    :src="props.frameSrc"    allow-top-navigation  /></template><script setup lang="ts">import { onMounted, onUnmounted, ref } from "vue";import { isEmitterEvent } from "@/services/micro-frontends";const props = defineProps<{  frameSrc: string;}>();const emit = defineEmits<{  (e: string, payload?: unknown): void;}>();const iframeRef = ref<HTMLIFrameElement>();const onMessage = (event: MessageEvent) => {  const iframe = iframeRef.value;  if (iframe && event.source === iframe.contentWindow && isEmitterEvent(event)) {    emit(event.data.event, event.data.payload);  }};onMounted(() => window.addEventListener("message", onMessage));onUnmounted(() => window.removeEventListener("message", onMessage));</script>

Note: isEmitterEvent is a function that checks if an event has the expected structure.

Guest application tools

From child app perspective we need a mechanism to send events:

export type EmitterEvent = { event: string; payload: unknown; }export const isEmitterEvent = (message: MessageEvent): message is MessageEvent<EmitterEvent> => {  return !!message.data && typeof (message.data as EmitterEvent).event === "string";};const hasHostApplication = computed(() => window.parent !== window.self);const appEmit = (event: string, payload?: unknown) => {  if (hasHostApplication.value) {    const message: EmitterEvent = { event, payload };    window.parent.postMessage(message, "*");  }};export const useHostApp = () => {  return {    hasHostApplication,    emit: appEmit,  };};

Reactive props

To pass props down to the child application we need a couple of things. Firstly, we need to send an event to child application on iframe render and also after each props change. To do that, we need to know be sure that child app is ready to receive them. Secondly, we have to consume this data on a child-side and inject it down to components in a reactive way.

Are you ready?

We need an initialization step on client side, that will happen at application start, to tell parent app that we are ready to consume props. Lets implement it and introduce 2 special events (that will go through the same communication channel we established already previously):

  • hook:ready - child application should send it when ready to receive props
  • hook:update-props - parent app should send it after hook:ready and after each props change

Guest app part may look like this:

const propsValue = reactive<Record<string, any>>({});const initializeMicroFrontend = () => {  if (!hasHostApplication.value) return;  // listen to props updates  window.addEventListener("message", (event: MessageEvent) => {    if (event.source === window.parent && isEmitterEvent(event)) {      if (event.data.event === "hook:update-props") {        // consume new props        Object.assign(propsValue, event.data.payload);        // remove props that are not there anymore        Object.keys(propsValue).forEach((key) => {          if (!Object.prototype.hasOwnProperty.call(event.data.payload, key)) {            delete propsValue[key];          }        });      }    }  });  // tell parent app that we are ready  appEmit("hook:ready");};

To run this initialization in child application and also to inject props down to components we can create a simple plugin:

import { /* ... */ inject, InjectionKey, Plugin, readonly } from "vue";// ...const hostPropsInjectionKey = Symbol() as InjectionKey<Record<string, unknown>>;export const plugin: Plugin = {  install: (app) => {    initializeMicroFrontend();    app.provide(hostPropsInjectionKey, readonly(propsValue));  },};

and use it in main.ts:

import { plugin as MicroFrontends } from "./services/micro-frontends";// ...app.use(MicroFrontends);// ...

And the last step for a child component is to extend a bit useHostApp composable to expose props:

export const useHostApp = <Props extends Record<string, any> = Record<string, any>>() => {  const props = inject(hostPropsInjectionKey) as Props;  return {    // ...    props,  };};

Client part is done!

Here, grab my data!

From parent perspective we also need a couple of changes in MicroFrontend component.

We don't know exactly what props will be passed (as MicroFrontend is pretty generic component) so we cannot define them in advance. We can use attrs to pass everything that was passed to the component (and is not declared as a prop).

First, make sure that attrs are not inherited:

<script lang="ts">export default {  inheritAttrs: false,};</script>

Then, watch them and send them to client app:

const attrs = useAttrs();const isChildReady = ref(false);const updateProps = () => {  const iframe = iframeRef.value;  if (!iframe || !isChildReady.value) return;  // filter out function props (listeners)  const payload = Object.keys(attrs).reduce((acc, key) => {    return typeof attrs[key] === "function" ? acc : { ...acc, [key]: attrs[key] }  }, {} as Record<string, unknown>);  const message: EmitterEvent = { event: "hook:update-props", payload, };  iframe.contentWindow?.postMessage(message, "*");};watch(() => [attrs, isChildReady.value], updateProps, { deep: true });const onMessage = (event: MessageEvent) => {  // ...  if (event.data.event === "hook:ready") {    isChildReady.value = true;  }};

And that's it! Enjoy reactive props changes 🕺

One more thing: size matters (optional)

One of the drawbacks of iframes is the fact that from parent perspective we cannot know the actual dimensions of content of an iframe. However, because we have an intermediate layer when it comes to using micro frontend, we can hide additional logic inside this layer, such as sharing knowledge about content size.

Guest application

Lets emit a special event hook:resize every time size of the content changes.

export const initializeMicroFrontend = () => { // ... const onResize = () =>    appEmit("hook:resize", {      height: document.body.scrollHeight,      width: document.body.scrollWidth,    });  onResize();  const observer = new ResizeObserver(onResize);  observer.observe(document.body);}

Host application

On the host application side we need to extend MicroFrontend component a bit more:

<!-- ... --><script setup lang="ts">// ...const iframeStyle = reactive({ minHeight: "", minWidth: "" });const onMessage = (event: MessageEvent) => {  // ...  if (event.data.event === "hook:resize") {    const payload = event.data.payload as { height: number; width: number };    iframeStyle.minHeight = `${payload.height}px`;    iframeStyle.minWidth = `${payload.width}px`;  }  // ...}// ...</script>

Final notes

It's always a good idea to hide hard parts somewhere in the services and make usage clean and easy to read/understand.

Keep in mind that this is a PoC. Some parts could be written more comprehensively, but I decided to keep it simple to make it easier to capture the general ideas. To use it in practice you will most likely need to extend it a bit for your specific case.

Demo & code