← React/Микрофронтенды: независимые приложения в одном UI#290 из 383← ПредыдущийСледующий →+20 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksМаршрут: старт с нуля

Микрофронтенды: независимые приложения в одном UI

Что такое микрофронтенды

Микрофронтенды — архитектурный подход, при котором один пользовательский интерфейс состоит из нескольких независимых приложений, каждое из которых разрабатывается, тестируется и деплоится отдельной командой.

Аналогия с микросервисами на бэкенде: вместо одного монолитного бэкенда — несколько сервисов. Вместо одного монолитного фронтенда — несколько независимых приложений.

┌─────────────── Shell (оболочка) ─────────────────┐
│                                                   │
│  ┌──────────────┐  ┌────────────┐  ┌───────────┐ │
│  │  Команда A   │  │ Команда B  │  │ Команда C  │ │
│  │              │  │            │  │           │ │
│  │ /catalog     │  │ /cart      │  │ /checkout │ │
│  │ (React)      │  │ (Vue)      │  │ (Angular) │ │
│  └──────────────┘  └────────────┘  └───────────┘ │
│                                                   │
└───────────────────────────────────────────────────┘

Module Federation (Webpack 5)

Самый популярный способ реализации микрофронтендов — Module Federation:

В приложении-хосте (Shell):

// webpack.config.js
new ModuleFederationPlugin({
  name: 'shell',
  remotes: {
    catalog: 'catalog@https://catalog.example.com/remoteEntry.js',
    cart: 'cart@https://cart.example.com/remoteEntry.js',
  },
})

// Использование в коде:
const CatalogApp = lazy(() => import('catalog/App'))
const CartWidget = lazy(() => import('cart/Widget'))

В удалённом приложении (catalog):

// webpack.config.js
new ModuleFederationPlugin({
  name: 'catalog',
  filename: 'remoteEntry.js',
  exposes: {
    './App': './src/App',        // экспортируем компонент
    './Widget': './src/Widget', // и виджет
  },
  shared: {
    react: { singleton: true },  // одна копия React
    'react-dom': { singleton: true },
  },
})

Single-SPA

Альтернатива — Single-SPA фреймворк, который управляет жизненным циклом нескольких SPA:

import { registerApplication, start } from 'single-spa'

// Регистрируем приложения:
registerApplication({
  name: 'catalog',
  app: () => import('@company/catalog-app'),
  activeWhen: (location) => location.pathname.startsWith('/catalog'),
})

registerApplication({
  name: 'cart',
  app: () => import('@company/cart-app'),
  activeWhen: (location) => location.pathname.startsWith('/cart'),
})

start()  // Single-SPA управляет mount/unmount

// Каждое приложение экспортирует lifecycle:
// export { bootstrap, mount, unmount }

Обмен состоянием между микрофронтендами

Это одна из главных сложностей архитектуры. Несколько подходов:

// 1. Custom Events (самый простой)
// Приложение Cart публикует событие:
window.dispatchEvent(new CustomEvent('cart:item-added', {
  detail: { productId: 123, quantity: 1 }
}))

// Приложение Catalog подписывается:
window.addEventListener('cart:item-added', (e) => {
  updateCartCount(e.detail.quantity)
})

// 2. Общее хранилище в window (осторожно!)
window.__sharedStore__ = createStore(rootReducer)

// 3. URL как источник истины
// Состояние кодируется в URL — все приложения читают его

// 4. Pub/Sub через общую библиотеку
import { eventBus } from '@company/shared'
eventBus.emit('user:logged-in', { userId: 1 })
eventBus.on('user:logged-in', handler)

Когда НЕ использовать микрофронтенды

Микрофронтенды добавляют значительную сложность. YAGNI (You Aren't Gonna Need It) применимо здесь особенно:

Не используйте когда:

  • Команда меньше 5-8 человек
  • Приложение небольшое или среднее
  • Нет реальных проблем с монолитным деплоем
  • Нет чётких границ между доменами
  • Используйте когда:

  • Несколько команд по 5+ человек
  • Разные части развёртываются с разной частотой
  • Нужно смешивать технологии (React + Vue + Legacy)
  • Команды хотят независимых деплоев
  • Ясные доменные границы (catalog, cart, user, payments)
  • Преимущества и недостатки

    | Преимущества | Недостатки |

    |---|---|

    | Независимые деплои | Сложность инфраструктуры |

    | Технологический выбор | Дублирование зависимостей |

    | Изоляция команд | Производительность (несколько бандлов) |

    | Масштабируемость команды | Сложность отладки |

    | Постепенная миграция legacy | Межкомандная координация |

    Производительность

    Главная опасность — несколько копий React:

    // Module Federation решает через shared:
    shared: {
      react: {
        singleton: true,      // одна копия
        requiredVersion: '^18.0.0',
        eager: true,          // загружаем сразу в shell
      }
    }
    // Без этого: Shell + Catalog + Cart = 3 копии React!

    Примеры

    Реализация паттерна микрофронтендов: динамическая загрузка скриптов, система монтирования приложений, EventBus для коммуникации между микроприложениями

    // Демонстрируем архитектуру микрофронтендов через динамическую загрузку модулей.
    // Это аналог Module Federation на чистом JavaScript.
    
    // --- EventBus: обмен сообщениями между микроприложениями ---
    
    function createEventBus() {
      const listeners = new Map()
      const eventLog = []
    
      return {
        emit(event, data) {
          eventLog.push({ event, data, timestamp: Date.now() })
          const handlers = listeners.get(event) || []
          handlers.forEach(handler => {
            try {
              handler(data)
            } catch (err) {
              console.error('[EventBus] Ошибка в обработчике', event, err.message)
            }
          })
          console.log('[EventBus] emit:', event, JSON.stringify(data))
        },
    
        on(event, handler) {
          if (!listeners.has(event)) listeners.set(event, [])
          listeners.get(event).push(handler)
          console.log('[EventBus] Подписка на:', event)
          return () => this.off(event, handler)  // unsubscribe
        },
    
        off(event, handler) {
          const handlers = listeners.get(event) || []
          listeners.set(event, handlers.filter(h => h !== handler))
        },
    
        getLog: () => [...eventLog],
        getListenerCount: (event) => (listeners.get(event) || []).length,
      }
    }
    
    // --- Реестр микроприложений ---
    
    function createMicroAppRegistry() {
      const apps = new Map()
      const mounted = new Map()
    
      return {
        register(name, definition) {
          apps.set(name, {
            name,
            ...definition,
            status: 'registered',
          })
          console.log('[Registry] Зарегистрировано приложение:', name)
        },
    
        async mount(name, container, props = {}) {
          const app = apps.get(name)
          if (!app) throw new Error('Приложение не найдено: ' + name)
    
          console.log('[Registry] Монтирование:', name, '→', container)
    
          // 1. Bootstrap (инициализация)
          if (app.bootstrap) {
            await app.bootstrap()
          }
    
          // 2. Mount
          if (app.mount) {
            await app.mount({ container, props })
          }
    
          app.status = 'mounted'
          mounted.set(name, { container, props, mountedAt: Date.now() })
          console.log('[Registry] Смонтировано:', name)
    
          return {
            unmount: () => this.unmount(name)
          }
        },
    
        async unmount(name) {
          const app = apps.get(name)
          if (!app || app.status !== 'mounted') return
    
          console.log('[Registry] Размонтирование:', name)
    
          if (app.unmount) {
            await app.unmount()
          }
    
          app.status = 'unmounted'
          mounted.delete(name)
          console.log('[Registry] Размонтировано:', name)
        },
    
        getMounted: () => Array.from(mounted.keys()),
        getStatus: (name) => apps.get(name)?.status || 'not-found',
      }
    }
    
    // --- Симуляция удалённых микроприложений ---
    
    function createCatalogApp(eventBus) {
      let state = { products: [], isLoaded: false }
    
      return {
        name: 'catalog',
    
        async bootstrap() {
          console.log('[Catalog] Bootstrap: загрузка конфигурации...')
          await new Promise(r => setTimeout(r, 50))
          console.log('[Catalog] Готов к монтированию')
        },
    
        async mount({ container, props }) {
          state.products = [
            { id: 1, name: 'MacBook Pro', price: 150000 },
            { id: 2, name: 'iPhone 15',   price: 90000 },
            { id: 3, name: 'AirPods Pro', price: 20000 },
          ]
          state.isLoaded = true
    
          console.log('[Catalog] Смонтирован в', container)
          console.log('[Catalog] Товаров загружено:', state.products.length)
    
          // Слушаем запросы от других приложений
          eventBus.on('cart:request-product', ({ productId }) => {
            const product = state.products.find(p => p.id === productId)
            if (product) {
              eventBus.emit('catalog:product-found', { product })
            }
          })
        },
    
        async unmount() {
          state = { products: [], isLoaded: false }
          console.log('[Catalog] Размонтирован, данные очищены')
        },
    
        getProducts: () => state.products,
      }
    }
    
    function createCartApp(eventBus) {
      let state = { items: [], total: 0 }
    
      return {
        name: 'cart',
    
        async bootstrap() {
          console.log('[Cart] Bootstrap: восстановление корзины из localStorage...')
        },
    
        async mount({ container }) {
          console.log('[Cart] Смонтирован в', container)
    
          // Слушаем добавление товаров от Catalog
          eventBus.on('catalog:add-to-cart', ({ product, quantity }) => {
            state.items.push({ ...product, quantity })
            state.total += product.price * quantity
            console.log('[Cart] Добавлен товар:', product.name)
            eventBus.emit('cart:updated', { itemCount: state.items.length, total: state.total })
          })
        },
    
        addItem(product, quantity = 1) {
          eventBus.emit('catalog:add-to-cart', { product, quantity })
        },
    
        getState: () => ({ ...state }),
      }
    }
    
    // --- Демонстрация ---
    
    async function runDemo() {
      const bus = createEventBus()
      const registry = createMicroAppRegistry()
    
      // Подписка Shell на обновления корзины
      bus.on('cart:updated', ({ itemCount, total }) => {
        console.log('[Shell] Корзина обновлена: ' + itemCount + ' товаров, итого: ' + total + '₽')
      })
    
      // Создаём приложения
      const catalog = createCatalogApp(bus)
      const cart = createCartApp(bus)
    
      // Регистрируем в реестре
      registry.register('catalog', catalog)
      registry.register('cart', cart)
    
      // Монтируем
      console.log('
    === Монтирование микроприложений ===')
      await registry.mount('catalog', '#catalog-container')
      await registry.mount('cart', '#cart-container')
    
      console.log('
    Смонтированы:', registry.getMounted())
    
      // Взаимодействие через EventBus
      console.log('
    === Взаимодействие между приложениями ===')
      const products = catalog.getProducts()
    
      cart.addItem(products[0], 1)  // MacBook Pro
      cart.addItem(products[2], 2)  // AirPods Pro x2
    
      const cartState = cart.getState()
      console.log('
    Итог корзины:', cartState.total + '₽')
      console.log('Событий в EventBus:', bus.getLog().length)
    
      // Размонтирование
      console.log('
    === Размонтирование ===')
      await registry.unmount('catalog')
      console.log('Смонтированы после unmount:', registry.getMounted())
    }
    
    runDemo()

    Микрофронтенды: независимые приложения в одном UI

    Что такое микрофронтенды

    Микрофронтенды — архитектурный подход, при котором один пользовательский интерфейс состоит из нескольких независимых приложений, каждое из которых разрабатывается, тестируется и деплоится отдельной командой.

    Аналогия с микросервисами на бэкенде: вместо одного монолитного бэкенда — несколько сервисов. Вместо одного монолитного фронтенда — несколько независимых приложений.

    ┌─────────────── Shell (оболочка) ─────────────────┐
    │                                                   │
    │  ┌──────────────┐  ┌────────────┐  ┌───────────┐ │
    │  │  Команда A   │  │ Команда B  │  │ Команда C  │ │
    │  │              │  │            │  │           │ │
    │  │ /catalog     │  │ /cart      │  │ /checkout │ │
    │  │ (React)      │  │ (Vue)      │  │ (Angular) │ │
    │  └──────────────┘  └────────────┘  └───────────┘ │
    │                                                   │
    └───────────────────────────────────────────────────┘

    Module Federation (Webpack 5)

    Самый популярный способ реализации микрофронтендов — Module Federation:

    В приложении-хосте (Shell):

    // webpack.config.js
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        catalog: 'catalog@https://catalog.example.com/remoteEntry.js',
        cart: 'cart@https://cart.example.com/remoteEntry.js',
      },
    })
    
    // Использование в коде:
    const CatalogApp = lazy(() => import('catalog/App'))
    const CartWidget = lazy(() => import('cart/Widget'))

    В удалённом приложении (catalog):

    // webpack.config.js
    new ModuleFederationPlugin({
      name: 'catalog',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App',        // экспортируем компонент
        './Widget': './src/Widget', // и виджет
      },
      shared: {
        react: { singleton: true },  // одна копия React
        'react-dom': { singleton: true },
      },
    })

    Single-SPA

    Альтернатива — Single-SPA фреймворк, который управляет жизненным циклом нескольких SPA:

    import { registerApplication, start } from 'single-spa'
    
    // Регистрируем приложения:
    registerApplication({
      name: 'catalog',
      app: () => import('@company/catalog-app'),
      activeWhen: (location) => location.pathname.startsWith('/catalog'),
    })
    
    registerApplication({
      name: 'cart',
      app: () => import('@company/cart-app'),
      activeWhen: (location) => location.pathname.startsWith('/cart'),
    })
    
    start()  // Single-SPA управляет mount/unmount
    
    // Каждое приложение экспортирует lifecycle:
    // export { bootstrap, mount, unmount }

    Обмен состоянием между микрофронтендами

    Это одна из главных сложностей архитектуры. Несколько подходов:

    // 1. Custom Events (самый простой)
    // Приложение Cart публикует событие:
    window.dispatchEvent(new CustomEvent('cart:item-added', {
      detail: { productId: 123, quantity: 1 }
    }))
    
    // Приложение Catalog подписывается:
    window.addEventListener('cart:item-added', (e) => {
      updateCartCount(e.detail.quantity)
    })
    
    // 2. Общее хранилище в window (осторожно!)
    window.__sharedStore__ = createStore(rootReducer)
    
    // 3. URL как источник истины
    // Состояние кодируется в URL — все приложения читают его
    
    // 4. Pub/Sub через общую библиотеку
    import { eventBus } from '@company/shared'
    eventBus.emit('user:logged-in', { userId: 1 })
    eventBus.on('user:logged-in', handler)

    Когда НЕ использовать микрофронтенды

    Микрофронтенды добавляют значительную сложность. YAGNI (You Aren't Gonna Need It) применимо здесь особенно:

    Не используйте когда:

  • Команда меньше 5-8 человек
  • Приложение небольшое или среднее
  • Нет реальных проблем с монолитным деплоем
  • Нет чётких границ между доменами
  • Используйте когда:

  • Несколько команд по 5+ человек
  • Разные части развёртываются с разной частотой
  • Нужно смешивать технологии (React + Vue + Legacy)
  • Команды хотят независимых деплоев
  • Ясные доменные границы (catalog, cart, user, payments)
  • Преимущества и недостатки

    | Преимущества | Недостатки |

    |---|---|

    | Независимые деплои | Сложность инфраструктуры |

    | Технологический выбор | Дублирование зависимостей |

    | Изоляция команд | Производительность (несколько бандлов) |

    | Масштабируемость команды | Сложность отладки |

    | Постепенная миграция legacy | Межкомандная координация |

    Производительность

    Главная опасность — несколько копий React:

    // Module Federation решает через shared:
    shared: {
      react: {
        singleton: true,      // одна копия
        requiredVersion: '^18.0.0',
        eager: true,          // загружаем сразу в shell
      }
    }
    // Без этого: Shell + Catalog + Cart = 3 копии React!

    Примеры

    Реализация паттерна микрофронтендов: динамическая загрузка скриптов, система монтирования приложений, EventBus для коммуникации между микроприложениями

    // Демонстрируем архитектуру микрофронтендов через динамическую загрузку модулей.
    // Это аналог Module Federation на чистом JavaScript.
    
    // --- EventBus: обмен сообщениями между микроприложениями ---
    
    function createEventBus() {
      const listeners = new Map()
      const eventLog = []
    
      return {
        emit(event, data) {
          eventLog.push({ event, data, timestamp: Date.now() })
          const handlers = listeners.get(event) || []
          handlers.forEach(handler => {
            try {
              handler(data)
            } catch (err) {
              console.error('[EventBus] Ошибка в обработчике', event, err.message)
            }
          })
          console.log('[EventBus] emit:', event, JSON.stringify(data))
        },
    
        on(event, handler) {
          if (!listeners.has(event)) listeners.set(event, [])
          listeners.get(event).push(handler)
          console.log('[EventBus] Подписка на:', event)
          return () => this.off(event, handler)  // unsubscribe
        },
    
        off(event, handler) {
          const handlers = listeners.get(event) || []
          listeners.set(event, handlers.filter(h => h !== handler))
        },
    
        getLog: () => [...eventLog],
        getListenerCount: (event) => (listeners.get(event) || []).length,
      }
    }
    
    // --- Реестр микроприложений ---
    
    function createMicroAppRegistry() {
      const apps = new Map()
      const mounted = new Map()
    
      return {
        register(name, definition) {
          apps.set(name, {
            name,
            ...definition,
            status: 'registered',
          })
          console.log('[Registry] Зарегистрировано приложение:', name)
        },
    
        async mount(name, container, props = {}) {
          const app = apps.get(name)
          if (!app) throw new Error('Приложение не найдено: ' + name)
    
          console.log('[Registry] Монтирование:', name, '→', container)
    
          // 1. Bootstrap (инициализация)
          if (app.bootstrap) {
            await app.bootstrap()
          }
    
          // 2. Mount
          if (app.mount) {
            await app.mount({ container, props })
          }
    
          app.status = 'mounted'
          mounted.set(name, { container, props, mountedAt: Date.now() })
          console.log('[Registry] Смонтировано:', name)
    
          return {
            unmount: () => this.unmount(name)
          }
        },
    
        async unmount(name) {
          const app = apps.get(name)
          if (!app || app.status !== 'mounted') return
    
          console.log('[Registry] Размонтирование:', name)
    
          if (app.unmount) {
            await app.unmount()
          }
    
          app.status = 'unmounted'
          mounted.delete(name)
          console.log('[Registry] Размонтировано:', name)
        },
    
        getMounted: () => Array.from(mounted.keys()),
        getStatus: (name) => apps.get(name)?.status || 'not-found',
      }
    }
    
    // --- Симуляция удалённых микроприложений ---
    
    function createCatalogApp(eventBus) {
      let state = { products: [], isLoaded: false }
    
      return {
        name: 'catalog',
    
        async bootstrap() {
          console.log('[Catalog] Bootstrap: загрузка конфигурации...')
          await new Promise(r => setTimeout(r, 50))
          console.log('[Catalog] Готов к монтированию')
        },
    
        async mount({ container, props }) {
          state.products = [
            { id: 1, name: 'MacBook Pro', price: 150000 },
            { id: 2, name: 'iPhone 15',   price: 90000 },
            { id: 3, name: 'AirPods Pro', price: 20000 },
          ]
          state.isLoaded = true
    
          console.log('[Catalog] Смонтирован в', container)
          console.log('[Catalog] Товаров загружено:', state.products.length)
    
          // Слушаем запросы от других приложений
          eventBus.on('cart:request-product', ({ productId }) => {
            const product = state.products.find(p => p.id === productId)
            if (product) {
              eventBus.emit('catalog:product-found', { product })
            }
          })
        },
    
        async unmount() {
          state = { products: [], isLoaded: false }
          console.log('[Catalog] Размонтирован, данные очищены')
        },
    
        getProducts: () => state.products,
      }
    }
    
    function createCartApp(eventBus) {
      let state = { items: [], total: 0 }
    
      return {
        name: 'cart',
    
        async bootstrap() {
          console.log('[Cart] Bootstrap: восстановление корзины из localStorage...')
        },
    
        async mount({ container }) {
          console.log('[Cart] Смонтирован в', container)
    
          // Слушаем добавление товаров от Catalog
          eventBus.on('catalog:add-to-cart', ({ product, quantity }) => {
            state.items.push({ ...product, quantity })
            state.total += product.price * quantity
            console.log('[Cart] Добавлен товар:', product.name)
            eventBus.emit('cart:updated', { itemCount: state.items.length, total: state.total })
          })
        },
    
        addItem(product, quantity = 1) {
          eventBus.emit('catalog:add-to-cart', { product, quantity })
        },
    
        getState: () => ({ ...state }),
      }
    }
    
    // --- Демонстрация ---
    
    async function runDemo() {
      const bus = createEventBus()
      const registry = createMicroAppRegistry()
    
      // Подписка Shell на обновления корзины
      bus.on('cart:updated', ({ itemCount, total }) => {
        console.log('[Shell] Корзина обновлена: ' + itemCount + ' товаров, итого: ' + total + '₽')
      })
    
      // Создаём приложения
      const catalog = createCatalogApp(bus)
      const cart = createCartApp(bus)
    
      // Регистрируем в реестре
      registry.register('catalog', catalog)
      registry.register('cart', cart)
    
      // Монтируем
      console.log('
    === Монтирование микроприложений ===')
      await registry.mount('catalog', '#catalog-container')
      await registry.mount('cart', '#cart-container')
    
      console.log('
    Смонтированы:', registry.getMounted())
    
      // Взаимодействие через EventBus
      console.log('
    === Взаимодействие между приложениями ===')
      const products = catalog.getProducts()
    
      cart.addItem(products[0], 1)  // MacBook Pro
      cart.addItem(products[2], 2)  // AirPods Pro x2
    
      const cartState = cart.getState()
      console.log('
    Итог корзины:', cartState.total + '₽')
      console.log('Событий в EventBus:', bus.getLog().length)
    
      // Размонтирование
      console.log('
    === Размонтирование ===')
      await registry.unmount('catalog')
      console.log('Смонтированы после unmount:', registry.getMounted())
    }
    
    runDemo()

    Задание

    Создай Shell-приложение для микрофронтендов с динамической загрузкой виджетов. Shell должен: отображать навигацию между "приложениями"; использовать React.lazy для ленивой загрузки; показывать Suspense fallback при загрузке; иметь EventBus для коммуникации между виджетами. Заполни пропуски (???) для: lazy импорта компонента, fallback в Suspense, отправки события в EventBus.

    Подсказка

    Для lazy: React.lazy(() => Promise.resolve({ default: CatalogWidget })). Для fallback: <div>Загрузка виджета...</div>. Для emit: EventBus.emit("cart:add", item).

    Загружаем среду выполнения...
    Загружаем AI-помощника...