Реализация Single-SPA для микрофронтендов
Single-SPA — оркестратор микрофронтендов. Управляет жизненным циклом приложений: когда монтировать, когда размонтировать, как переключаться между ними без перезагрузки страницы. В отличие от Module Federation, Single-SPA фреймворко-нейтрален — один microfrontend может быть на React, другой на Vue, третий на Angular.
Каждое приложение экспортирует три функции: bootstrap, mount, unmount. Single-SPA вызывает их по расписанию на основе URL.
Что входит в работу
Настройка root config (оркестратор), регистрация microfrontend-приложений, адаптеры для React/Vue/Angular, parcel-компоненты (не привязаны к роутам), import map для управления версиями, обмен данными, CI/CD.
Архитектура
root-config → single-spa оркестратор, import map
├── @company/navbar → навигация (parcel, всегда активна)
├── @company/catalog → /products/* (React)
├── @company/checkout → /checkout/* (React)
├── @company/account → /account/* (Vue)
└── @company/legacy → /legacy/* (Angular, старый код)
Установка root-config
npx create-single-spa --moduleType root-config
# или вручную
npm install single-spa
root-config — регистрация приложений
// src/index.ts (root-config)
import { registerApplication, start } from 'single-spa'
registerApplication({
name: '@company/navbar',
app: () => System.import('@company/navbar'),
activeWhen: () => true, // всегда активна
customProps: {
domElement: document.getElementById('navbar-container'),
},
})
registerApplication({
name: '@company/catalog',
app: () => System.import('@company/catalog'),
activeWhen: (location) => location.pathname.startsWith('/products'),
})
registerApplication({
name: '@company/checkout',
app: () => System.import('@company/checkout'),
activeWhen: (location) => location.pathname.startsWith('/checkout'),
})
registerApplication({
name: '@company/account',
app: () => System.import('@company/account'),
activeWhen: ['/account'],
})
start({
urlRerouteOnly: true, // не вызывать перемонтирование при hash-change
})
index.html с import map
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="importmap-type" content="systemjs-importmap" />
<script type="systemjs-importmap">
{
"imports": {
"single-spa": "https://cdn.jsdelivr.net/npm/single-spa@6/lib/es2015/esm/single-spa.min.js",
"react": "https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js",
"react-dom": "https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js",
"@company/navbar": "https://cdn.example.com/navbar/latest/company-navbar.js",
"@company/catalog": "https://cdn.example.com/catalog/latest/company-catalog.js",
"@company/checkout": "https://cdn.example.com/checkout/latest/company-checkout.js",
"@company/account": "https://cdn.example.com/account/latest/company-account.js"
}
}
</script>
<script src="https://cdn.jsdelivr.net/npm/systemjs@6/dist/extras/amd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/systemjs@6/dist/system.min.js"></script>
</head>
<body>
<div id="navbar-container"></div>
<div id="single-spa-application:@company/catalog"></div>
<div id="single-spa-application:@company/checkout"></div>
<div id="single-spa-application:@company/account"></div>
<script src="./src/index.js"></script>
</body>
</html>
React microfrontend
npx create-single-spa --moduleType app-parcel --framework react
// apps/catalog/src/index.tsx
import React from 'react'
import { createRoot, Root } from 'react-dom/client'
import singleSpaReact from 'single-spa-react'
import App from './App'
const lifecycles = singleSpaReact({
React,
ReactDOM: { createRoot: (el: Element) => createRoot(el) } as unknown,
rootComponent: App,
errorBoundary(err, info, props) {
return <div>Catalog app crashed: {err.message}</div>
},
})
export const { bootstrap, mount, unmount } = lifecycles
// apps/catalog/src/App.tsx
import React from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
// Single-SPA передаёт customProps — используем для получения domElement, eventBus и т.д.
interface CatalogProps {
eventBus?: EventBus
basePath?: string
}
export default function App({ eventBus, basePath = '/products' }: CatalogProps) {
return (
<BrowserRouter basename={basePath}>
<Routes>
<Route path="/" element={<ProductListPage />} />
<Route path="/:id" element={<ProductDetailPage />} />
<Route path="/category/:slug" element={<CategoryPage />} />
</Routes>
</BrowserRouter>
)
}
Vue microfrontend
npx create-single-spa --moduleType app-parcel --framework vue
npm install single-spa-vue
// apps/account/src/main.ts
import { createApp, App as VueApp, h } from 'vue'
import singleSpaVue from 'single-spa-vue'
import App from './App.vue'
import router from './router'
let app: VueApp | null = null
const vueLifecycles = singleSpaVue({
createApp,
appOptions: {
render() {
return h(App, {
// single-spa props
...this.$props,
})
},
},
handleInstance(appInstance) {
appInstance.use(router)
app = appInstance
},
})
export const bootstrap = vueLifecycles.bootstrap
export const mount = vueLifecycles.mount
export const unmount = vueLifecycles.unmount
Parcels — компоненты без привязки к роутам
Parcel — microfrontend без роут-условия. Используется для виджетов (корзина, уведомления, чат):
// apps/catalog/src/components/MiniCart.tsx
import { mountRootParcel } from 'single-spa'
function ProductPage() {
const parcelRef = useRef<HTMLDivElement>(null)
const parcelRef2 = useRef<ParcelObject | null>(null)
useEffect(() => {
if (!parcelRef.current) return
const parcel = mountRootParcel(
() => System.import('@company/cart'),
{
domElement: parcelRef.current,
singleSpa: window.singleSpa,
}
)
parcelRef2.current = parcel
return () => parcel.unmount()
}, [])
return <div ref={parcelRef} />
}
Или через React-компонент:
import Parcel from 'single-spa-react/parcel'
function Layout() {
return (
<div>
<Parcel
config={() => System.import('@company/notifications')}
mountParcel={mountRootParcel}
wrapWith="div"
wrapClassName="notifications-wrapper"
/>
</div>
)
}
Коммуникация между приложениями
Single-SPA рекомендует cross-microfrontend imports через import map:
// packages/shared-auth — отдельный npm-пакет в import map
// "@company/auth": "https://cdn.example.com/auth/auth.js"
// в catalog:
import { getUser, eventBus } from '@company/auth'
const user = getUser()
eventBus.on('auth:logout', () => clearLocalCart())
Или через CustomEvent на window — без прямой зависимости:
// catalog публикует
window.dispatchEvent(new CustomEvent('@company/cart:item-added', {
detail: { productId: '123', quantity: 1 }
}))
// checkout слушает
window.addEventListener('@company/cart:item-added', (e: CustomEvent) => {
checkoutStore.syncCartItem(e.detail)
})
Import map overrides — dev-режим
npm install import-map-overrides
<!-- в index.html -->
<script src="https://cdn.jsdelivr.net/npm/import-map-overrides@2/dist/import-map-overrides.js"></script>
<import-map-overrides-full show-when-local-storage="devtools"></import-map-overrides-full>
Разработчик catalog открывает браузер, нажимает на панель overrides и меняет URL @company/catalog на http://localhost:3001/catalog.js. Остальные microfrontends продолжают работать с CDN.
Обработка ошибок жизненного цикла
import { addErrorHandler, getAppStatus, SKIP_BECAUSE_BROKEN } from 'single-spa'
addErrorHandler((error) => {
console.error('Single-SPA error:', error)
monitoring.captureException(error, {
tags: { appName: error.appOrParcelName },
})
// если приложение сломалось — не блокируем всё
if (getAppStatus(error.appOrParcelName) === SKIP_BECAUSE_BROKEN) {
return
}
})
webpack.config.js для microfrontend
// systemjs совместимый output
module.exports = {
output: {
library: { type: 'system' },
publicPath: '',
},
externals: ['react', 'react-dom', 'single-spa', 'react-router-dom'],
}
Ключевое — externals. Все shared-зависимости не включаются в бандл, загружаются из import map.
Мониторинг
import { addErrorHandler, getAppNames } from 'single-spa'
// время монтирования каждого приложения
const mountTimes: Record<string, number> = {}
window.addEventListener('single-spa:before-app-change', (e: CustomEvent) => {
const { newAppStatuses } = e.detail
Object.keys(newAppStatuses).forEach((name) => {
if (newAppStatuses[name] === 'MOUNTED') {
mountTimes[name] = performance.now()
}
})
})
window.addEventListener('single-spa:app-change', (e: CustomEvent) => {
const { newAppStatuses } = e.detail
Object.keys(newAppStatuses).forEach((name) => {
if (newAppStatuses[name] === 'MOUNTED' && mountTimes[name]) {
const duration = performance.now() - mountTimes[name]
analytics.track('mf_mount_time', { app: name, duration })
}
})
})
Что делаем
Настраиваем root-config с SystemJS и import maps, создаём single-spa адаптеры для каждого microfrontend (React, Vue или Angular), настраиваем import-map-overrides для разработчиков, организуем коммуникацию через event bus или shared модули, выстраиваем CI/CD с версионированием через import map.
Срок: 8–15 дней — базовая архитектура, root-config, 2–3 microfrontend-приложения, dev-инструменты.







