Предыстория

Около года назад я написал первую часть статьи о Single SPA — о том, как я выбирал архитектурный подход, боролся с монолитом и пробовал собрать первые микрофронтенды. В статье были разобраны базовые принципы работы Single SPA, подключение importmap, сравнение с другими архитектурными решениями и настройка сборки на Webpack и Vite.

Эта статья — продолжение цикла. Здесь я поделюсь практикой: как на самом деле живётся с Single SPA, какие есть подводные камни и что можно вынести в виде рекомендаций.

Сразу скажу: Single SPA — не «серебряная пуля» и уж точно не современный тренд фронтенд-разработки. В 2025-м появилось еще больше других подходов, которые решают похожие задачи иначе. Но Single SPA всё ещё актуален там, где есть огромные легаси-системы, которые невозможно переписать с нуля. И вот именно для таких кейсов мой опыт может быть полезен.

Связываем микрофронты в единое приложение

В первой части я остановился на самом интересном месте:

Теперь осталось связать наши микрофронты в единое приложение...

По сути, именно здесь и начинается настоящая магия Single SPA.

Итак, у нас есть два типа артефактов:

  • App Shell (хост) — приложение, которое регистрирует и монтирует микрофронты.

  • Микрофронты (MF) — самостоятельные сервисы, экспортирующие методы bootstrap, mount, unmount.

Идея простая: хост подтягивает точки входа MF через importmap, а single-spa решает, что и когда монтировать.

Именно он превращает набор разрозненных сервисов в «единое приложение» для пользователя.

Точка входа MF — всё как в первой части статьи: бандл, экспортирующий bootstrap/mount/unmount. Здесь повторять не буду.

Что там с хостом?

Для глубокого понимания происходящего можно опираться на мой репозиторий с примером хоста, собирающегося на Vite.

index.html подключает es-module-shims и две импортмапы, а также скрипт с layout (app-shell.ts):

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link href="./index.css" rel="stylesheet" />
    <link rel="icon" href="/favicon.ico" type="image/x-icon">

    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0, viewport-fit=cover"
    />
    <meta name="description" content="Single SPA Example App Shell" />
    <meta name="importmap-type" content="importmap-shim" />

    <title>Single SPA Example App Shell</title>

    <script async src="https://ga.jspm.io/npm:import-map-overrides@6.1.0/dist/import-map-overrides.js"></script>
    <script async src="https://ga.jspm.io/npm:es-module-shims@2.0.0/dist/es-module-shims.js"></script>

    <script type="importmap-shim" defer src="/shared.importmap.json"></script>
    <script type="importmap-shim" async src="/modules.importmap.json"></script>

    <import-map-overrides-full
      show-when-local-storage="devtools"
    ></import-map-overrides-full>

    <% if (isLocal) { %>
      <script>window.esmsInitOptions = { mapOverrides: true }</script>
      <script type="module-shim" src="/devSupport.ts"></script>
    <% } %>
  </head>

  <body>
    <script type="module-shim" async src="/app-shell.<%= isLocal ? 'ts' : 'js' %>"></script>
  </body>
</html>

Про импортмапы: в моем примере shared.importmap.json указывает на общие зависимости, а modules.importmap.json на сами входные точки MF соответственно.

Основной скрипт хоста (layout) может содержать такую структуру (пример из документации):

// single-spa-config.js
import { registerApplication, start } from "single-spa";

// Simple usage
registerApplication(
  "app2",
  () => import("src/app2/main.js"),
  (location) => location.pathname.startsWith("/app2"),
  { some: "value" },
);

// Config with more expressive API
registerApplication({
  name: "app1",
  app: () => import("src/app1/main.js"),
  activeWhen: "/app1",
  customProps: {
    some: "value",
  },
});

start();

Мне же такой вариант не очень зашел вследствие любви к декларативности, тем более вместо ручного вызова registerApplication для каждого модуля имеется возможность использовать Layout Definition. Он позволяет описать структуру приложения декларативно, прямо в JSON или напрямую в HTML, а затем сгенерировать из этого роуты и список микрофронтов.

Пример подхода JSON Layouts:

import { constructRoutes } from 'single-spa-layout'

const routes = constructRoutes({
  routes: [
    {
      type: 'route',
      path: 'tickets',
      routes: [
        {
          type: 'application',
          name: '@organization/react-app'
        }
      ],
      default: false
    }
  ]
})

Пример подхода HTML Layouts:

<template id="single-spa-layout">
  <single-spa-router mode="history">
    <main>
      <route default>
        <application name="@organization/react-app"></application>
      </route>
    </main>
  </single-spa-router>
</template>

Более подробно со всеми нюансами можно ознакомиться в документации.

Самый большой плюс такого подхода — возможность легко добавлять новые модули без пересборки хоста (особенно, если layout инжектится сервером).

Локальная разработка и HMR

Как запустить все это добро?

  1. Поднимаем микрофронт на Vite в режиме dev (у меня это http://localhost:3001).
    Важно: именно vite dev, не preview.

  2. Поднимаем хост (у меня http://localhost:8000).
    В index.html хоста должно заранее лежать подключение es-module-shims, импортмап и скрипт для корректной работы HMR при локальной разработке.

    <% if (isLocal) { %>
      <script>window.esmsInitOptions = { mapOverrides: true }</script>
      <script type="module-shim" src="/devSupport.ts"></script>
    <% } %>
  3. Говорим хосту, где брать микрофронт. Тут два удобных пути:

    Вариант A: через import-map-overrides (рекомендую). Включаем панель через браузерную консоль:

    localStorage.setItem('devtools','true'); location.reload();

    Далее есть два удобных варианта использования:

    1) Через UI

    • Включаем Dev tools (см. выше) → на странице появится панель.

      Интерфейс importmap-overrides
      Интерфейс importmap-overrides
    • Тыкаем Add new module и вбиваем ключ MF и URL на локальную входную точку (/src/...).

    • Кнопка Apply overrideready! Выключить override — Disable override или через Reset all overrides.

    2) Через консоль

    // включить/поменять
    importMapOverrides.addOverride(
      '@organization/react-app',
      'http://localhost:3001/src/react-app.tsx'
    )
    
    // выключить один
    importMapOverrides.removeOverride('@organization/react-app')
    
    // сбросить все
    importMapOverrides.resetOverrides()
    

    Вариант B: прописать локально в modules.importmap.json.
    На время разработки можно напрямую прописать:

    {
      "imports": {
        "@organization/react-app": "http://localhost:3001/src/react-app.tsx"
      }
    }
    

    Плюс — минимум кликов, минус — правите файл (и не так удобно переключаться).

  4. Открываем маршрут, где монтируется MF (в моём примере — /tickets) и начинаем работать: меняем код в MF — обновления прилетают без перезагрузки.

Зачем нужен devSupport.ts и что он даёт

Хост в реальности тянет MF через importmap + es-module-shims, то есть без Vite-клиента на странице. Поэтому просто "перекинуть" импорт на localhost мало — HMR сам по себе не стартанёт.

В связи с этим я написал небольшой вспомогательный скрипт devSupport.ts, который и послужит решением проблемы:

const uniq = <T>(a: T[]) => Array.from(new Set(a))

const isDevEntry = (u: string): boolean => {
  try {
    const { hostname } = new URL(u, location.href)

    return hostname.toLowerCase().includes('localhost')
  } catch {
    return false
  }
}

const readImportMaps = async (): Promise<Record<string, string>> => {
  const scripts = Array.from(
    document.querySelectorAll<HTMLScriptElement>(
      'script[type="importmap-shim"]'
    )
  )
  const out: Record<string, string> = {}

  for (const s of scripts) {
    try {
      const text = s.src
        ? await (await fetch(s.src)).text()
        : s.textContent ?? ''
      const map = JSON.parse(text)
      Object.assign(out, map?.imports ?? {})
    } catch {}
  }

  return out
}

const readOverrides = (): Record<string, unknown> => {
  const imo = (window as any).importMapOverrides
  const maps = imo?.getOverrideMap?.()?.imports ?? {}

  return Object.fromEntries(
    Object.entries(maps).filter(([k]) => !imo?.isDisabled?.(k))
  )
}

const injected = new Set<string>()
const injectOnce = (key: string, el: HTMLScriptElement) => {
  if (injected.has(key)) return

  injected.add(key)
  document.head.append(el)
}

const installFor = async (origin: string) => {
  const isVite = await fetch(`${origin}/@vite/client`, { method: 'HEAD' })
    .then((r) => r.ok)
    .catch(() => false)

  if (!isVite) return

  const client = document.createElement('script')

  client.type = 'module-shim'
  client.src = `${origin}/@vite/client`
  injectOnce(`${origin}::vite-client`, client)

  const hasRefresh = await fetch(`${origin}/@react-refresh`, { method: 'HEAD' })
    .then((r) => r.ok)
    .catch(() => false)

  if (hasRefresh) {
    const pre = document.createElement('script')
    pre.type = 'module-shim'
    pre.text = `
      import { injectIntoGlobalHook } from '${origin}/@react-refresh';
      injectIntoGlobalHook(window);
      window.$RefreshReg$ = () => {};
      window.$RefreshSig$ = () => (t) => t;
      window.__vite_plugin_react_preamble_installed__ = true;
    `
    injectOnce(`${origin}::refresh`, pre)
  }
}

const collectDevOrigins = async (): Promise<string[]> => {
  const maps = await readImportMaps()
  const overrides = readOverrides()
  const allUrls = [...Object.values(maps), ...Object.values(overrides)].filter(
    (u): u is string => typeof u === 'string' && Boolean(u)
  )
  const devUrls = allUrls.filter(isDevEntry)

  return uniq(devUrls.map((u) => new URL(u, location.href).origin))
}

const installAll = async () => {
  const origins = await collectDevOrigins()
  await Promise.all(origins.map(installFor))

  return true
}

;(window as any).__vitePreambleReady = installAll()

Если не вдаваться в подробности, то скрипт проходит по импортмапам и overrides, находит все локальные origin’ы (например, localhost:*) и единожды для каждого origin подключает:

  • @vite/client — собственно HMR;

  • (опционально) @react-refresh — если ваш MF на React и у вас стоит @vitejs/plugin-react или @vitejs/plugin-react-swc. В обоих случаях Fast Refresh работает, а с swc ещё и сборка шустрее =)

Для Vue / Angular ничего дополнительного не нужно: Vite-клиента хватает.

Несколько микрофронтов? Без проблем. Делаете overrides (или записи в importmap) на каждый ключ: разные порты — разные origin’ы. devSupport.ts сам поставит HMR-клиент по одному на origin, дубликатов не будет. Если два MF сидят на одном порту — клиент подключится один раз и обслужит оба.

Проблемы на практике

На бумаге всё выглядит очень красиво: декларативный layout, изоляция сервисов, независимый деплой. Но при реальном использовании появляются нюансы, о которых стоит знать заранее.

Несогласованность роутинга

Каждый микрофронт живёт своей жизнью и хочет свой роутер (e.g., react-router, vue-router, etc.). В итоге два уровня маршрутизации:

  • Хост рулит глобальными урлами (e.g., /tickets, etc.).

  • MF рулит своими внутренними путями — относительно базового префикса.

Если базовый путь не прокинуть — будет мешанина: редиректы в никуда, дублирующиеся слэши, 404 не там, где надо.

Пример: в layout у MF маршрут tickets. Значит, react-router внутри @organization/react-app должен знать, что его базовый путь — /tickets.

Решение: пробрасывать basename через props из хоста в микрофронт:

const routes = constructRoutes({
  routes: [
    {
      type: 'route',
      path: 'tickets',
      routes: [
        {
          type: 'application',
          name: '@organization/react-app',
          // здесь задаём значения пропсов для микрофронта
          props: { basename: '/tickets' },
        },
      ],
      default: false,
    },
  ],
})

А внутри микрофронта рутовый компонент в свою очередь может принять эти пропсы:

// Входная точка MF
import React from 'react'
import ReactDOMClient from 'react-dom/client'
import singleSpaReact from 'single-spa-react'
import Root, { type HostContext } from '@/app/providers'

export const { bootstrap, mount, unmount } = singleSpaReact({
  React,
  ReactDOMClient,
  rootComponent: Root,
  errorBoundary(err, info, props: HostContext) {
    console.error('err:', err)
    console.error('info:', info)
    console.error('props:', props) // здесь будет { basename: '/tickets' }
    return null
  },
})
// Root-компонент
import { BrowserRouter } from 'react-router-dom'

export type HostContext = {
  basename?: string
}

const Root = ({ basename }: HostContext) => {
  console.log('Я реально получил пропсы!', basename)

  return (
    <BrowserRouter basename={basename}>
      {/* ...локальные роуты... */}
    </BrowserRouter>
  )
}

export default Root

Вуаля!

Конфликты зависимостей

Классика жанра: один MF на React 19, другой ещё на 18; где-то router v6, где-то v5. В лучшем случае — два React’а в бандле. В худшем — runtime падает.

Что делать:

  • Выделить “общие зависимости” в отдельную импортмапу (условно shared.importmap.json) и жёстко закрепить версии. Так вы гарантируете единые версии библиотек для всех MF.

  • Если уж одной версией не обойтись (жизнь сложная) — используйте scopes в импортмапе: разные микрофронты получают свои версии библиотек, без взаимного влияния.

    Пример shared.importmap.json:

{
  "imports": {
    "react": "https://ga.jspm.io/npm:react@18.3.1/index.js",
    "react-dom": "https://ga.jspm.io/npm:react-dom@18.3.1/index.js",
    "react-router": "https://ga.jspm.io/npm:react-router@6.23.1/dist/main.js",
    "react-router-dom": "https://ga.jspm.io/npm:react-router-dom@6.23.1/dist/main.js",
    "single-spa": "https://ga.jspm.io/npm:single-spa@6.0.1/lib/es2015/esm/single-spa.min.js",
    "single-spa-layout": "https://ga.jspm.io/npm:single-spa-layout@2.2.0/dist/esm/single-spa-layout.min.js"
  },
  "scopes": {
    "https://ga.jspm.io/": {
      "@remix-run/router": "https://ga.jspm.io/npm:@remix-run/router@1.16.1/dist/router.cjs.js",
    }
  }
}

Чтобы не вбивать каждую зависимость вручную, выручает Import Map Generator (JSPM) — онлайн-сервис и CLI, которые собирают валидную импортмапу с корректными скоупами зависимостей.

Подробнее — сюда.

Props и конфигурация через layout

Сильная сторона single-spa-layout — можно скормить конфиг прямо из разметки: feature-флаги, базовые урлы API, локаль / тенант — всё сюда.

Исходя из примера выше с basename при использовании подхода JSON Layouts получается такой конфиг:

{
  type: 'application',
  name: '@organization/react-app',
  // прокидываем необходимые пропсы
  props: {
    featureFlag: 'new-dashboard',
    apiBase: '/api/v2',
    locale: 'ru-RU'
  }
}

Для HTML Layouts используется другой подход, так как определить сложные типы данных в HTML не так просто, поскольку атрибуты HTML всегда являются строками. Чтобы обойти эту проблему, single-spa-layout позволяет вам передать ваши пропсы в HTML, но определять их значения в layout:

<application name="settings" props="authToken,loggedInUser"></application>
import { constructRoutes } from "single-spa-layout";

const data = {
  props: {
    authToken: "fds789dsfyuiosodusfd",
    loggedInUser: fetch("/api/logged-in-user").then((r) => r.json()),
  },
};

const routes = constructRoutes(
  document.querySelector("#single-spa-template"),
  data,
);

Где удобно:

  • A/B-тесты без перекомпиляции;

  • мульти-тенантность;

  • переключение backend-эндпоинтов.

Где осторожнее:

  • Props статичны на момент загрузки. Если нужно менять динамически — генерируйте layout сервером (SSR/SSG) и отдавайте на лету.

А если микрофронт упадет?

CDN дернулся, релиз сломался, 404 вместо module.js. Без страховки Host завалится следом.

Быстрое решение: оборачиваем loadApp и отдаём безопасную заглушку:

const applications = constructApplications({
  routes,
  loadApp: async ({ name }) => {
    try {
      return await import(/* @vite-ignore */ name as string)
    } catch (e) {
      console.error(`Не удалось загрузить ${name}`, e)

      return {
        bootstrap: () => Promise.resolve(),
        mount: () => Promise.resolve(),
        unmount: () => Promise.resolve()
      }
    }
  }
})

Параллельно имеет смысл показывать «плашку» или скелетон в зоне микрофронта и логировать инцидент.

Overengineering

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

Как не улететь в космос:

  • Делите только по реальным зонам ответственности (команды / домены), а не «каждую кнопку — в свой MF».

  • Заводите единый список shared зависимостей (React, Router, стейт-менеджеры, дата-форматтеры и т.д.) и следите за версиями централизованно.

  • Ограничьте «самоуправство» на уровне CI: линтеры / валидаторы мап, минимальный набор правил для сборок.

Что по тестированию?

В данном вопросе речь должна идти именно о E2E тестах (юниты — зона каждого микрофронта отдельно).

Цель: убедиться, что хост + микрофронты вместе живут нормально: корректно роутятся, монтируются / размонтируются и не валят друг друга.

Что проверять?

  • Навигацию по layout. Переход на URL микрофронта монтирует нужный микрофронт; внутренние роуты работают относительно basename.

  • Жизненный цикл. Уход со страницы вызывает unmount, возврат — mount (без "висящих" таймеров и подписок).

  • Деградацию. Если MF не загрузился / упал — у хоста показывается заглушка / ошибка в зоне MF, а не "белый экран".

Что гонять?

Staging через ингрессы — максимально близко к прод-реальности.

  • Хост: https://staging.app.yourcompany.com

  • MF: https://staging-mf-*.yourcompany.com/...

  • Общие зависимости — из shared.importmap.json на CDN.

На стейджинге / проде импортмапы уже смотрят на собранные бандлы. Никаких @vite/client, никаких overrides — фиксированная среда, меньше случайностей.

Чем гонять?

Любой из «большой двойки» — Playwright или Cypress. Оба подходят, выбирайте по команде / опыту.

Важно

  • Зафиксируйте импортмапы на необходимом стенде (immutable версии), чтобы тесты не ловили "дрейф" зависимостей.

  • Отключите Service Worker (если он есть) на время E2E (флагом через env) или добавляйте ?nocache=..., чтобы не упираться в кеш.

  • Тестовые данные / feature-флаги. Договоритесь про предсказуемые сиды / флаги для сценариев (иначе флак будет вечным).

  • Наблюдаемость. Снимайте логи и сетевые ошибки страницы — проще понять, какой MF уронил связку.

Глубина покрытия

  • Пара smoke-сценариев на каждый раздел (роут layout → базовые действия внутри MF).

  • Один сценарий на размонтирование / повторное монтирование.

  • Один сценарий на fallback при недоступности MF.

  • Всё остальное — в юнит- / интеграционные тесты внутри команд, отвечающих за микрофронт.

Итого по тестированию

E2E должны подтверждать, что «склейка» работает: роутинг хоста, basename MF, жизненный цикл и деградация.

Не перегреваем пирамиду — проверяем именно интеграцию между хостом и микрофронтами, а не всю бизнес-логику каждого приложения.

CI/CD: как это живёт в проде

В микрофронтендах важен один простой принцип: хост не пересобираем каждый раз, когда обновляется любой MF. Он должен узнавать о новых версиях по импортмапе.

Базовый use case

Обобщенная схема работы CI/CD
Обобщенная схема работы CI/CD
  1. Сборка MF.
    Каждая команда билдит свой MF (module.js + assets с хешами).

  2. Публикация артефактов.
    Катим на CDN / облако (e.g., S3 + CloudFront, GCS + CDN, etc.). Артефакты immutable: в путях есть версия / хеш.

  3. Обновление импортмап.
    После успешной публикации обновляем мапу так, чтобы ключ MF указывал на новый URL (версированный). Это атомная операция и её удобно делать через import-map-deployer (см. ниже).

  4. Хост.
    Хост не пересобираем — он на старте (или периодически) тянет импортмапу микрофронтендов и монтирует их по актуальным ссылкам.

  5. Смоук + промоушн.
    Пробежали e2e на стейджинге → "продвинули" ту же запись импортмап на прод (без перекладки артефактов).

Зачем нужен import-map-deployer

Это маленький сервис / CLI от single-spa, который:

  • хранит и меняет импортмапы для окружений (dev / staging / prod);

  • даёт историю / реверсии (быстрый rollback);

  • делает атомные апдейты (без состояния «полумежду»);

  • предоставляет HTTP API для пайплайнов.

Как внедрить по-простому:

  • поднимаете import-map-deployer (как сервис или через serverless);

  • указываете, где хранить мапы (S3 / файл / репозиторий);

  • в CI микрофронта после загрузки на CDN делаете POST в деплойер:
    «обнови ключ @organization/react-app → на https://cdn/.../react-app.<hash>.js»;

  • хост читает modules.importmap.json с URL деплойера / CDN — и всегда видит свежие версии.

Более подробно с import-map-deployer можно ознакомиться в данном гайде.

Пара практичных правил

  • Версии только immutable. Бандлы с хешами, Cache-Control: immutable, max-age=31536000. Ссылку на них меняет только импортмапа.

  • Мапа с коротким кешем. Cache-Control: max-age=30–60s + ETag. Хосту важна свежесть именно импортмапы.

  • Общие зависимости — отдельной импортмапой. shared.importmap.json держим централизованно и обновляем синхронно (через тот же деплойер), чтобы не разъехались версии библиотек.

  • Промо окружений. Стадия staging → «promote» той же мапы на prod после смоуков. Никаких «пересборок под prod».

  • Canary по имени. Хотите потестить часть трафика — заведите параллельный ключ (@organization/react-app@canary) и рулите раскладкой на стороне хоста / feature-флага.

  • Быстрый откат. Любая проблема — «revert import map to №1» и вы снова на стабильной версии за секунды.

Повторный краткий ликбез

Микрофронт собирается и публикуется на CDN как версионированные, неизменяемые артефакты. Затем через Import Map Deployer обновляется modules.importmap.json на staging, запускаются e2e / смоук-тесты, после чего выполняется promote этой же мапы на prod.

Хост деплоится независимо и редко; в рантайме он подгружает две мапы — shared.importmap.json с CDN и modules.importmap.json из IMD — и монтирует актуальные версии без собственной пересборки. Откат — переключение версии modules.importmap.json в IMD.

Кеширование браузером и Service Worker

Что кешируем и как

  • Артефакты MF (JS / CSS, шрифты) — immutable.
    Отдаём с CDN с Cache-Control: public, max-age=31536000, immutable. Имена — с хэшами. Обновление = новый URL в modules.importmap.json. Браузер сам подтянет свежие версии.

  • Импортмапы — короткий TTL.
    modules.importmap.json и shared.importmap.json: Cache-Control: max-age=30–60, must-revalidate + ETag/Last-Modified. Хосту важна актуальность мап, не их кеш.

  • HTML / хост-бандл — как обычно.
    HTML — no-cache/max-age=0, must-revalidate; бандл хоста — можно кешировать, но без фанатизма (часть ссылок на модули тянется из импортмапы в рантайме).

Service Worker — где и зачем

  • SW ставим на хосте, не в каждом MF.
    SW действует в пределах origin + scope. Он не контролирует CDN и чужие домены MF. Поэтому централизуем офлайн / кеш-политику на хосте.

  • Стратегии по умолчанию:

    • Импортмапы — network-first (не закешировать "навечно").

    • HTML — network-first.

    • Статика хоста — stale-while-revalidate.

    • Артефакты MF с CDN — доверяем CDN (долгий кеш на стороне CDN + версионные урлы), SW не трогаем.

  • Обновления SW.
    Включите skipWaiting/clientsClaim (или registerType: 'autoUpdate' при использовании Vite PWA), и покажите пользователю тост «Доступна новая версия — Обновить», это снимет «залипшие» клиенты.

  • Dev / CI.
    На dev и в E2E выключайте SW, чтобы не ловить фантомные кеши (флагом окружения или отдельным index.html без регистрации SW).

Выводы: плюсы, минусы и когда Single SPA действительно уместен

Что в нём сильного

  • Плавная миграция с монолита. Можно «выпиливать» куски по одному, не стопоря разработку всего продукта.

  • Независимость команд. Отдельные репозитории, отдельные релизы, изоляция зон ответственности.

  • Технологическая нейтральность. Реально уживаются React / Vue / Angular / Web Components — хосту всё равно, лишь бы были bootstrap/mount/unmount.

  • Безопасный деплой. Сломался один микрофронт — остальные живут (если есть фолбэки).

  • Гибкий CI/CD. С импортами + IMD (import-map-deployer) можно делать promote / rollback без пересборки хоста.

За что придётся заплатить

  • Оверхед на координацию. Общие зависимости, версии, импортмапы, IMD, правила кеширования — всё это надо дисциплинированно поддерживать.

  • Производительность. Разные фреймворки в одной странице → дубли рантаймов, рост веса бандла, сложнее оптимизировать критический рендер.

  • DX и тесты. Локальная разработка, HMR, E2E на стейджинге — всё чуть сложнее, чем в «одном большом приложении».

  • Коммуникации между MF. Шины событий, контракт пропсов, навигация, аутентификация — нужно продумывать заранее.

  • SSR / SEO. Рендер на сервере и консистентный HTML сложнее, чем у монолитных фреймворков/мета-фреймворков.

Когда Single SPA — хорошая идея

  • У вас крупный легаси-монолит, который нельзя переписать с нуля.

  • Есть несколько зрелых команд с разными стеками / ритмами релизов.

  • Важны независимые деплои частей продукта и быстрый откат на уровне импортмапы.

  • Продукт уже "тяжёлый", а риски больших релизов надо снижать.

Когда лучше не надо

  • Новый продукт или средний по размеру проект.

  • Единый стек и нет потребности в изоляции релизов.

  • Нужны SSR, RSC / стриминг, SEO-критичные страницы и тонкая оптимизация на уровне фреймворка.

  • 80% функционала — общий код / дизайн-система, а «разделение» даст больше боли, чем пользы.

Что выбрать в 2025 вместо (или рядом с) Single SPA

  • Один фреймворк + модульная архитектура: монорепа (Nx / Turbo), «пакетная» сегментация, дизайн-система, изолированный релиз фич через Module Federation (в том числе для Vitevite-plugin-federation) без кросс-фреймворковости.

  • Мета-фреймворки для приложений: Next.js / Nuxt / SvelteKit / SolidStart — SSR, RSC / стриминг, отличный DX, встроенный роутинг и сборка.

  • Островная архитектура: Astro (это не про микрофронтенды, но отличная стратегия для перфоманса и композиции виджетов / страниц).

  • Web Components: когда нужна лёгкая интеграция независимых виджетов без тяжёлого рантайма и с хорошей переиспользуемостью.

To sum up

Single SPA — это не модная «серебряная пуля», а рабочий инструмент миграции и изоляции в больших системах. Он блестяще решает задачу «как жить с тем, что уже есть», но требует взрослой инженерной культуры: импортмапы, единые версии зависимостей, правила деплоя и кеширования, etc.

Для новых проектов чаще выгоднее выбрать современный мета-фреймворк или модульную архитектуру в одном стеке. Для легаси-продуктов, где переписывать всё — роскошь, Single SPA всё ещё «игра стоит свеч».

Комментарии (1)


  1. Katrain
    06.10.2025 08:57

    • Оверхед на координацию. Общие зависимости, версии, импортмапы, IMD, правила кеширования — всё это надо дисциплинированно поддерживать.

    Можно просто не выносить общие зависимости и дать каждому микрофронту полную свободу действий — просто дай им свой базовый path, и дальше не твое дело, что у них там. Тот же importmap можно не использовать и спокойно делать подключение внутри хоста.

    single-spa — это про полную независимость хоста и микрофронтов, просто шарим какое-то состояние. Если хочешь стандартизировать работу — это уже к любой другой либе для микрофронтов.

    но требует взрослой инженерной культуры: импортмапы, единые версии зависимостей, правила деплоя и кеширования, etc.

    За два года работы в продакшене с этой либой и поддержкой хоста — абсолютно противоположное мнение. Буквально, даю очередной микрофронтовой тиме условно полную свободу действий. Они могут хоть 16 react там использовать и деплоиться, когда захотят т.к. в хосте важно только ссылка на JS-бандл микрофронта