Микрофронтенды — архитектурный подход, при котором один пользовательский интерфейс состоит из нескольких независимых приложений, каждое из которых разрабатывается, тестируется и деплоится отдельной командой.
Аналогия с микросервисами на бэкенде: вместо одного монолитного бэкенда — несколько сервисов. Вместо одного монолитного фронтенда — несколько независимых приложений.
┌─────────────── Shell (оболочка) ─────────────────┐
│ │
│ ┌──────────────┐ ┌────────────┐ ┌───────────┐ │
│ │ Команда A │ │ Команда B │ │ Команда C │ │
│ │ │ │ │ │ │ │
│ │ /catalog │ │ /cart │ │ /checkout │ │
│ │ (React) │ │ (Vue) │ │ (Angular) │ │
│ └──────────────┘ └────────────┘ └───────────┘ │
│ │
└───────────────────────────────────────────────────┘Самый популярный способ реализации микрофронтендов — 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 фреймворк, который управляет жизненным циклом нескольких 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) применимо здесь особенно:
Не используйте когда:
Используйте когда:
| Преимущества | Недостатки |
|---|---|
| Независимые деплои | Сложность инфраструктуры |
| Технологический выбор | Дублирование зависимостей |
| Изоляция команд | Производительность (несколько бандлов) |
| Масштабируемость команды | Сложность отладки |
| Постепенная миграция 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 (оболочка) ─────────────────┐
│ │
│ ┌──────────────┐ ┌────────────┐ ┌───────────┐ │
│ │ Команда A │ │ Команда B │ │ Команда C │ │
│ │ │ │ │ │ │ │
│ │ /catalog │ │ /cart │ │ /checkout │ │
│ │ (React) │ │ (Vue) │ │ (Angular) │ │
│ └──────────────┘ └────────────┘ └───────────┘ │
│ │
└───────────────────────────────────────────────────┘Самый популярный способ реализации микрофронтендов — 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 фреймворк, который управляет жизненным циклом нескольких 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) применимо здесь особенно:
Не используйте когда:
Используйте когда:
| Преимущества | Недостатки |
|---|---|
| Независимые деплои | Сложность инфраструктуры |
| Технологический выбор | Дублирование зависимостей |
| Изоляция команд | Производительность (несколько бандлов) |
| Масштабируемость команды | Сложность отладки |
| Постепенная миграция 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).