Lesson 08-Micro Frontend Communication and State Management

Micro Frontend Communication Mechanisms

Event Bus Implementation

An Event Bus is an implementation of the publish-subscribe pattern, enabling micro frontend applications to communicate through events without direct references to each other.

Basic Implementation

// eventBus.js
class EventBus {
  constructor() {
    this.events = {};
  }

  on(eventName, listener) {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }
    this.events[eventName].push(listener);
  }

  off(eventName, listener) {
    if (this.events[eventName]) {
      this.events[eventName] = this.events[eventName].filter(
        l => l !== listener
      );
    }
  }

  emit(eventName, ...args) {
    if (this.events[eventName]) {
      this.events[eventName].forEach(listener => {
        try {
          listener.apply(null, args);
        } catch (error) {
          console.error(`Error in event listener for ${eventName}:`, error);
        }
      });
    }
  }
}

// Create a global event bus instance
const eventBus = new EventBus();
export default eventBus;

Cross-Micro Frontend Usage

Main Application Registers Event Listener:

import eventBus from './eventBus';

// Listen for events from sub-applications
eventBus.on('user-login', (userData) => {
  console.log('User logged in:', userData);
  // Update main application state
});

Sub-Application Triggers Event:

import eventBus from './eventBus';

// Trigger event after successful login
function handleLoginSuccess(userData) {
  eventBus.emit('user-login', userData);
}

Cross-Window/Iframe Communication

When micro frontend applications are distributed across different windows or iframes, window.postMessage is used to implement the event bus:

// In the main application
const eventBus = {
  on(eventName, listener) {
    window.addEventListener('message', (event) => {
      if (event.data?.type === eventName) {
        listener(event.data.payload);
      }
    });
  },
  
  emit(eventName, payload) {
    window.parent.postMessage(
      { type: eventName, payload },
      '*' // In production, specify a specific origin
    );
  }
};

Custom Events and Publish-Subscribe

A custom event mechanism allows micro frontend applications to define their own event types and communicate through a global event system.

Implementing Custom Events

// customEvent.js
class CustomEvent {
  constructor(type, detail = {}) {
    this.type = type;
    this.detail = detail;
    this.timeStamp = Date.now();
  }
}

class EventTarget {
  constructor() {
    this.listeners = {};
  }

  addEventListener(type, callback) {
    if (!this.listeners[type]) {
      this.listeners[type] = [];
    }
    this.listeners[type].push(callback);
  }

  removeEventListener(type, callback) {
    if (this.listeners[type]) {
      this.listeners[type] = this.listeners[type].filter(
        cb => cb !== callback
      );
    }
  }

  dispatchEvent(event) {
    if (this.listeners[event.type]) {
      this.listeners[event.type].forEach(callback => {
        try {
          callback.call(this, event);
        } catch (error) {
          console.error(`Error in event listener for ${event.type}:`, error);
        }
      });
    }
  }
}

// Create a global event target
const globalEventTarget = new EventTarget();
export { CustomEvent, globalEventTarget };

Usage Example

Main Application Registers Listener:

import { CustomEvent, globalEventTarget } from './customEvent';

// Listen for custom events
globalEventTarget.addEventListener('cart-updated', (event) => {
  console.log('Cart updated:', event.detail);
  // Update main application cart UI
});

Sub-Application Triggers Event:

import { CustomEvent, globalEventTarget } from './customEvent';

// Trigger event after adding a product to the cart
function addToCart(product) {
  // ...Add product logic
  
  // Dispatch global event
  globalEventTarget.dispatchEvent(
    new CustomEvent('cart-updated', { detail: { product, timestamp: Date.now() } })
  );
}

Window.postMessage Practice

postMessage is a browser-provided API for cross-document communication, suitable for cross-window/iframe communication in micro frontend architectures.

Basic Usage

Sending a Message:

// Main application sends a message to sub-application
const childWindow = document.getElementById('child-iframe').contentWindow;
childWindow.postMessage(
  { type: 'INITIALIZE', payload: { user: currentUser } },
  'https://child-app-domain.com'
);

Receiving a Message:

// Sub-application listens for messages
window.addEventListener('message', (event) => {
  // Verify origin
  if (event.origin !== 'https://main-app-domain.com') return;
  
  // Handle different message types
  switch (event.data.type) {
    case 'INITIALIZE':
      initializeApp(event.data.payload);
      break;
    case 'USER_UPDATED':
      updateUser(event.data.payload.user);
      break;
    default:
      console.warn('Unknown message type:', event.data.type);
  }
});

Security Considerations

  1. Origin Verification: Always check event.origin and event.source.
  2. Restrict Target Domain: Specify the target domain in postMessage.
  3. Data Validation: Validate and sanitize received data.

Advanced Pattern

Request-Response Pattern:

// Main application sends a request and awaits a response
function requestFromChild(action, data) {
  return new Promise((resolve) => {
    function handleMessage(event) {
      if (event.data.responseTo === messageId && event.origin === childOrigin) {
        window.removeEventListener('message', handleMessage);
        resolve(event.data.result);
      }
    }
    
    const messageId = generateUniqueId();
    window.addEventListener('message', handleMessage);
    
    childWindow.postMessage(
      { 
        type: 'REQUEST', 
        action, 
        data, 
        messageId 
      },
      childOrigin
    );
  });
}

// Sub-application processes requests and returns responses
window.addEventListener('message', (event) => {
  if (event.origin !== mainOrigin) return;
  
  if (event.data.type === 'REQUEST') {
    // Process request
    const result = handleRequest(event.data.action, event.data.data);
    
    // Return response
    event.source.postMessage(
      { 
        type: 'RESPONSE', 
        responseTo: event.data.messageId, 
        result 
      },
      event.origin
    );
  }
});

Micro Frontend State Management

Shared State Design

Shared State Patterns

  1. Global State Tree: Design a global state tree containing all shared state.
  2. State Partitioning: Divide state into areas based on functional domains.
  3. State Version Control: Add version numbers to state for synchronization and conflict resolution.

Example State Structure:

{
  user: {
    id: '123',
    name: 'John Doe',
    auth: { token: 'abc123' }
  },
  cart: {
    items: [{ id: 'p1', qty: 2 }],
    total: 19.99
  },
  settings: {
    theme: 'dark',
    language: 'en'
  }
}

State Sharing Strategies

  1. Explicit Sharing: Pass shared state explicitly through props or context.
  2. Implicit Sharing: Share state implicitly via global state management tools.
  3. On-Demand Sharing: Share only the minimal necessary state set.

Redux Implementation in Micro Frontends

Approach 1: Single Redux Store (Suitable for Tightly Coupled Micro Frontends)

// Main application configures Redux store
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './features/userSlice';
import cartReducer from './features/cartSlice';

const store = configureStore({
  reducer: {
    user: userReducer,
    cart: cartReducer
  }
});

// Provide to sub-applications
window.sharedStore = store;

Sub-Application Accesses Store:

// Access shared store in sub-application
const dispatch = window.sharedStore.dispatch;
const state = window.sharedStore.getState();

function updateCart(item) {
  dispatch(addToCart(item));
}

Approach 2: Multi-Store Pattern (Suitable for Loosely Coupled Micro Frontends)

// Main application store
const mainStore = configureStore({
  reducer: { /* Main application state */ }
});

// Sub-application store
const childStore = configureStore({
  reducer: { /* Sub-application state */ },
  // Middleware for syncing with main application
  middleware: (getDefaultMiddleware) => 
    getDefaultMiddleware({
      serializableCheck: false
    }).concat(syncWithMainStore)
});

Synchronization Middleware Example:

const syncWithMainStore = store => next => action => {
  const result = next(action);
  // Notify main application of sub-application state changes
  window.parent.postMessage({
    type: 'STATE_UPDATE',
    appName: 'child-app',
    state: store.getState()
  }, '*');
  return result;
};

State Isolation and Synchronization

State Isolation Strategies

  1. Modular State: Each micro frontend maintains its own state module.
  2. Sandbox Environment: Use iframes or Web Workers to isolate state.
  3. Namespaces: Add namespace prefixes to shared state.

Namespace Example:

// Main application state
{
  'app1.user': { id: '1', name: 'Alice' },
  'app2.user': { id: '2', name: 'Bob' },
  'shared.cart': { items: [...] }
}

State Synchronization Solutions

  1. Event-Driven Synchronization: Synchronize state changes through an event bus.
  2. Polling Synchronization: Periodically check for state changes.
  3. Operation Log: Record state change operations for replay.

Event-Based Synchronization Implementation:

// Main application listens for state changes
eventBus.on('state-change', ({ appName, stateKey, newState }) => {
  // Merge into main state
  const currentState = store.getState()[stateKey] || {};
  store.dispatch(updateState({
    [stateKey]: { ...currentState, ...newState }
  }));
});

// Sub-application triggers state change event
function updateLocalState(newState) {
  // Update local state
  // ...
  
  // Publish state change event
  eventBus.emit('state-change', {
    appName: 'child-app',
    stateKey: 'user-profile',
    newState
  });
}

Communication Optimization

Communication Performance Optimization

Batch Message Processing

// Main application sends messages in batches
let messageQueue = [];
let isSending = false;

function sendMessageBatch(messages) {
  if (isSending) {
    messageQueue.push(...messages);
    return;
  }
  
  isSending = true;
  
  function sendNextBatch() {
    const batch = messageQueue.splice(0, 10); // Send up to 10 messages at a time
    if (batch.length > 0) {
      window.postMessage({ type: 'BATCH', messages: batch }, '*');
      setTimeout(sendNextBatch, 50); // Delay sending next batch
    } else {
      isSending = false;
      if (messageQueue.length > 0) {
        sendNextBatch();
      }
    }
  }
  
  sendNextBatch();
}

Lazy Loading Communication Modules

// Load communication module on demand
let communicationModule;

function getCommunicationModule() {
  if (!communicationModule) {
    communicationModule = import('./communication').then(module => {
      // Initialize communication module
      return module.default;
    });
  }
  return communicationModule;
}

// Usage
getCommunicationModule().then(communication => {
  communication.send('data');
});

Data Serialization and Transmission

Efficient Serialization Solutions

JSON Optimization:

// Custom serialization to exclude unnecessary data
function optimizedStringify(data) {
  const filtered = filterSensitiveData(data);
  return JSON.stringify(filtered);
}

Binary Serialization (Suitable for complex scenarios):

// Use MessagePack or similar binary format
import msgpack from 'msgpack-lite';

const packed = msgpack.encode(data);
window.postMessage({ type: 'DATA', payload: packed }, '*');

Transmission Optimization

Compressed Transmission:

// Use gzip compression
import pako from 'pako';

const compressed = pako.deflate(JSON.stringify(data));
window.postMessage({ 
  type: 'DATA', 
  payload: btoa(String.fromCharCode(...compressed)) 
}, '*');

Incremental Updates:

// Send only changed parts
function getDelta(oldState, newState) {
  const delta = {};
  for (const key in newState) {
	if (oldState[key] !== newState[key]) {
	  delta[key] = newState[key];
	}
  }
  return delta;
}

Error Handling and Retry

Robust Communication Mechanism

class RobustMessenger {
  constructor(targetWindow, targetOrigin) {
    this.targetWindow = targetWindow;
    this.targetOrigin = targetOrigin;
    this.retryQueue = [];
    this.maxRetries = 3;
    this.retryDelay = 1000;
  }

  send(message, retry = true) {
    try {
      this.targetWindow.postMessage(message, this.targetOrigin);
      
      // Set timeout for retry
      const timeoutId = setTimeout(() => {
        if (retry && this.retryQueue.some(m => m.id === message.id)) {
          this.retry(message, retry - 1);
        }
      }, 5000);
      
      // Store message ID for tracking
      if (retry) {
        this.retryQueue.push({
          id: message.id,
          message,
          retryCount: 0,
          timeoutId
        });
      }
    } catch (error) {
      console.error('Message send failed:', error);
      if (retry) {
        this.retry(message, this.maxRetries - 1);
      }
    }
  }

  retry(message, attemptsLeft) {
    clearTimeout(this.retryQueue.find(m => m.id === message.id)?.timeoutId);
    
    if (attemptsLeft <= 0) {
      console.error('Max retries reached for message:', message.id);
      return;
    }
    
    setTimeout(() => {
      this.send(message, false);
    }, this.retryDelay * Math.pow(2, this.maxRetries - attemptsLeft));
  }

  // Requires message listening and acknowledgment mechanism
}

Error Recovery Strategies

Message Acknowledgment Mechanism:

// Sender
function sendMessageWithAck(message) {
  return new Promise((resolve, reject) => {
	const id = generateUniqueId();
	const timeout = setTimeout(() => {
	  reject(new Error('Timeout waiting for ACK'));
	}, 3000);
	
	function handleAck(event) {
	  if (event.data?.type === 'ACK' && event.data.id === id) {
		clearTimeout(timeout);
		window.removeEventListener('message', handleAck);
		resolve();
	  }
	}
	
	window.addEventListener('message', handleAck);
	window.postMessage({ ...message, id }, '*');
  });
}

Dead Letter Queue:

// Record failed messages for later processing
const deadLetterQueue = [];

function handleFailedMessage(message, error) {
  deadLetterQueue.push({
	message,
	error,
	timestamp: Date.now(),
	retryCount: 0
  });
  
  // Can set up a scheduled task to process dead letter queue
}

Summary

Communication and state management in micro frontend architectures are among the core challenges of system design, requiring comprehensive consideration of the following key points:

  1. Communication Mechanisms:
    • Select appropriate communication methods (event bus, postMessage, etc.) based on the scenario.
    • Design efficient serialization and transmission solutions.
    • Implement robust error handling and retry mechanisms.
  2. State Management:
    • Reasonably divide shared and local state.
    • Design clear state synchronization strategies.
    • Ensure state isolation and security.
  3. Performance Optimization:
    • Batch processing and lazy loading of communication modules.
    • Optimize data transmission formats and sizes.
    • Implement incremental updates to reduce transmission volume.
  4. Maintainability:
    • Establish unified communication protocols and standards.
    • Provide clear error handling and logging.
    • Design scalable architectures to support future requirement changes.

By adopting thoughtful architectural design and optimization strategies, it is possible to build efficient, reliable, and maintainable micro frontend systems that meet the complex demands of modern web applications.

Share your love