javascript

Capacitor: от веба к мобильным приложениям. Часть 5. Микрофронтенды в Capacitor

  • пятница, 2 октября 2026 г. в 00:00:09
https://habr.com/ru/articles/1089150/

Давно не виделись хабровчане. Сегодня мы поговорим на избитую и всем уже изрядно надоевшую тему, но слегка под другим углом. Если вы уже работали с микрофронтендами в вебе, сама загрузка отдельной сборки внутри Capacitor вряд ли удивит. WebView исполняет JavaScript, а уже он загружает сборку через Module Federation и монтирует её как компонент.

Сложности начинаются, когда этому микрофронту нужен, например, доступ к камере или геолокация. Сразу возникает несколько вопросов:

  • Кто регистрирует плагины и запрашивает разрешения?

  • Как собственная маршрутизация микрофронта уживается с системной кнопкой “Назад” и диплинками?

  • Что можно обновлять через ОТА обновления, а что нет?

Разберём это на примере воображаемого(но реалистичного) Capacitor-приложения: страница профиля выступает оболочкой, а адреса доставки загружаются отдельным микрофронтендом.

Об авторе: Илья Скаков — мобильный разработчик, специализируется на гибридных приложениях на Capacitor и React Native.

Оглавление:


Термины и границы ответственности

Понятие

В этой статье

Capacitor

Стартовая веб-часть приложения в WebView. Владеет маршрутизацией верхнего уровня, регистрацией плагинов и контрактами с MFE.

MFE

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

Контракт

Типизированный набор методов и данных, через который MFE запрашивает возможности устройства и навигацию.

Диплинк

Ссылка, которая открывает конкретный экран приложения: myapp://delivery/addresses/42/edit.

Два слоя приложения

У обычного Capacitor приложения есть собственно свой бандл и нативные проекты Android/iOS. С микрофронтендами бандл делится ещё на две части:

Capacitor-приложение
│
├─ Capacitor
│  ├─ стартовый бандл, лежащий в приложении
│  ├─ Capacitor runtime и плагины
│  ├─ авторизация, верхнеуровневый роутер
│  ├─ загрузчик MFE
│  └─ контракт
│
└─ Delivery MFE
   ├─ собственный бандл и доменная логика
   ├─ собственная маршрутизация
   └─ вызовы разрешённых плагинов через контракт
Граница между Capacitor, MFE и нативными возможностями
Граница между Capacitor, MFE и нативными возможностями

Capacitor здесь — стартовая веб-часть приложения. Он открывается первым, знает о текущем пользователе, создаёт роутер и регистрирует плагины.

Delivery MFE можно собирать и выпускать отдельной командой. На устройстве он работает в том же WebView, что и Capacitor.

Из схемы следуют два правила:

  1. MFE не регистрирует плагины Capacitor самостоятельно.

  2. Для обращения к нативным возможностям MFE использует методы, которые Capacitor передал через контракт.

Кто владеет оболочкой Capacitor

Можно импортировать @capacitor/core прямо в MFE и вызвать registerPlugin():

Пример зависимости, которую не стоит создавать в MFE
import { registerPlugin } from '@capacitor/core'

const Camera = registerPlugin('Camera')

Если плагин уже есть в приложении, вызов может сработать. Но MFE теперь знает имя нативного плагина и зависит от его API.

При изменении плагина придётся согласовывать релизы Capacitor и MFE. Придётся также определить, кто показывает объясняющий экран перед запросом разрешения, фиксирует отказ и поддерживает старые версии приложения.

Эти решения я бы оставил команде Capacitor:

  • он подключает Android/iOS плагины;

  • он управляет разрешениями в AndroidManifest.xml и Info.plist;

  • он определяет пользовательскую политику запроса разрешений;

  • он сохраняет единое поведение на Android, iOS и web;

  • он может запретить ту или иную возможность для MFE фиче флагом контракта или по состоянию авторизации.

Нативный код плагина попадает в установленное приложение при сборке APK/AAB/IPA. Публикацией MFE его не добавить.

Контракт между оболочкой Capacitor и MFE

В нашем варианте MFE получает обычный объект с методами, которые нужны доставке: узнать координаты, дать тактильный отклик и отправить событие аналитики. Такой объект — не «обёртка над всеми плагинами», а минимальная публичная поверхность для конкретной функции.

// contracts/delivery-platform.ts
export type DeliveryLocation = {
  latitude: number
  longitude: number
}

export type DeliveryPlatform = {
  location: {
    getCurrent(): Promise<DeliveryLocation>
  }
  feedback: {
    success(): Promise<void>
  }
  analytics: {
    track(name: string, properties?: Record<string, string>): Promise<void>
  }
}

Capacitor реализует интерфейс через плагин или собственный адаптер:

// mobile-shell/src/platform.ts
import { registerPlugin } from '@capacitor/core'
import type { DeliveryPlatform } from '@app/contracts/delivery-platform'

type AppHostPlugin = {
  getCurrentLocation(): Promise<{ latitude: number; longitude: number }>
  feedbackSuccess(): Promise<void>
  track(options: {
    name: string
    properties?: Record<string, string>
  }): Promise<void>
}

const AppHost = registerPlugin<AppHostPlugin>('AppHost')

export const deliveryPlatform: DeliveryPlatform = {
  location: {
    getCurrent: () => AppHost.getCurrentLocation(),
  },
  feedback: {
    success: () => AppHost.feedbackSuccess(),
  },
  analytics: {
    track: (name, properties) => AppHost.track({ name, properties }),
  },
}

MFE получает его через пропсы:

export const DeliveryApp = ({ platform }: { platform: DeliveryPlatform }) => {
  const locate = async () => {
    const point = await platform.location.getCurrent()
    await platform.analytics.track('delivery_location_received')
    console.log(point)
  }

  return <button onClick={() => void locate()}>Определить адрес</button>
}

Для разработчика MFE переданный объект выглядит как обычная npm-зависимость. Ему достаточно знать методы контракта; детали остаются в Capacitor.

Такой контракт удобно вынести в пакет монорепы или отдельную библиотеку. Там будут типы, схемы данных, коды ошибок и правила версий. @capacitor/*, React-компоненты и платформенный код останутся в приложениях.

packages/
└─ app-contracts/
   ├─ delivery-platform.ts
   ├─ navigation.ts
   └─ versions.ts

Как MFE вызывает нативную возможность

Названия методов лучше брать из задачи пользователя, а не из API плагина. Это позволяет заменить реализацию, не меняя код MFE.

Плохо:

platform.plugins.geolocation.requestPermissions()
platform.plugins.geolocation.getCurrentPosition()

Хорошо:

platform.location.getCurrent()

За вызовом метода могут стоять @capacitor/geolocation, собственный плагин Capacitor, серверный резервный сценарий или браузерный navigator.geolocation. MFE в любом случае получает координаты через один API.

Также можно описать и другие действия:

Нужно MFE

Контракт

Чем реализует оболочка

Сделать фото документа

documents.takePhoto()

плагин Camera и продуктовый сценарий

Выбрать файл

documents.pickFile()

плагин File Picker

Прочитать QR

payment.scanQr()

плагин Barcode Scanner

Открыть системные настройки

settings.openNotifications()

нативный адаптер

Подтвердить действие вибрацией

feedback.success()

плагин Haptics

Открыть другой раздел приложения

navigation.open(...)

роутер оболочки

MFE не зависит от конкретной реализации на Android/iOS. Чтобы запуститься в браузере, ему можно передать другую реализацию контракта:

const browserPlatform: DeliveryPlatform = {
  location: {
    getCurrent: async () => ({ latitude: 55.7558, longitude: 37.6173 }),
  },
  feedback: { success: async () => undefined },
  analytics: { track: async () => undefined },
}

Разрешения и ответственность

Когда MFE вызывает метод геолокации, системный диалог показывает приложение, а не React-компонент. MFE сообщает, что пользователю нужна текущая точка для доставки.

Запрос проходит через Capacitor:

Delivery MFE
  → вызывает platform.location.getCurrent()

Capacitor
  → проверяет авторизацию, фиче-флаги и текущее состояние
  → при необходимости объясняет, зачем нужно разрешение
  → вызывает плагин
  → возвращает координаты или доменную ошибку

Ошибки тоже лучше описать в контракте, чтобы MFE не разбирал сообщения Android/iOS:

export type LocationErrorCode =
  | 'permission-denied'
  | 'permission-permanently-denied'
  | 'service-disabled'
  | 'unavailable'

export type LocationError = {
  code: LocationErrorCode
}

Тогда MFE сможет показать понятное состояние после отказа:

try {
  const location = await platform.location.getCurrent()
  setLocation(location)
} catch (error) {
  if (
    typeof error === 'object' &&
    error !== null &&
    'code' in error &&
    error.code === 'permission-denied'
  ) {
    setMessage('Разрешите доступ к геолокации, чтобы выбрать адрес.')
  } else {
    setMessage('Не удалось определить местоположение.')
  }
}

Команда Capacitor подключает плагин, добавляет разрешения в AndroidManifest.xml и Info.plist и определяет политику запроса. Команда MFE выбирает момент в своём сценарии и показывает результат или отказ.

Роутинг: где заканчивается Capacitor и начинается MFE

У Capacitor и MFE могут быть свои роутеры. Если оба независимо меняют URL и обрабатывают кнопку «Назад», пользователь легко окажется не на том экране.

Я бы разделил маршруты по областям:

Роутер Capacitor
├─ /home
├─ /profile
└─ /delivery/*        → монтирует Delivery MFE

Delivery роутер
├─ /addresses         → список адресов
├─ /addresses/new     → создание адреса
└─ /addresses/:id/edit → редактирование адреса

Capacitor выбирает крупную область и при необходимости загружает MFE. Внутри доставки маршрутами занимается MFE.

Передать путь MFE можно двумя способами.

Вариант 1: общая история браузера

Capacitor и MFE используют window.history, заранее договорившись о маршрутах и полях состояния. Для небольшого приложения этого достаточно. Нужно только следить, чтобы MFE не записал состояние, которое Capacitor не умеет обрабатывать.

window.history.pushState(
  { shellRoute: 'delivery', remoteRoute: '/addresses/new' },
  '',
  '/delivery/addresses/new',
)

Вариант 2: Capacitor передаёт маршрут через контракт

Capacitor хранит текущий маршрут и передаёт в MFE путь вместе с функцией для перехода:

type DeliveryNavigation = {
  path: string
  navigate(path: string, options?: { replace?: boolean }): void
}
<DeliveryRemote
  platform={deliveryPlatform}
  navigation={deliveryNavigation}
/>

Так Capacitor может логировать переходы, проверять доступ, собирать URL и обрабатывать нажатие кнопки «Назад» в одном месте. MFE при этом может использовать React Router для своих экранов, не меняя глобальную историю напрямую.

Если MFE несколько, я бы выбрал второй вариант.

Маршрут диплинка и обработка кнопки Назад
Маршрут диплинка и обработка кнопки Назад

Диплинки

Capacitor разбирает URL и понимает, какому MFE передать маршрут. У MFE нет причины самостоятельно подписываться на системный диплинк:

myapp://delivery/addresses/42/edit
  → оболочка: область delivery
  → оболочка загружает Delivery MFE
  → оболочка передаёт путь /addresses/42/edit
  → Delivery MFE открывает экран редактирования

При таком переходе Capacitor должен:

  1. проверить, доступна ли фича и авторизован ли пользователь;

  2. сохранить намерение перехода, пока MFE ещё загружается;

  3. передать MFE только путь, за который оно отвечает.

Если MFE ещё загружается, событие ему отправлять рано: он его не получит. Оболочка может сохранить ожидающий маршрут в своём состоянии и передать его при первом рендере MFE.

Кнопка «Назад» и история браузера

Нажатие системной кнопки «Назад» на Android нужно согласовать с историей WebView. Capacitor сначала проверяет, есть ли куда вернуться внутри текущей области, и только затем покидает её.

Например, пользователь возвращается так:

Редактирование адреса
  → список адресов
  → предыдущий маршрут оболочки
  → выход из приложения

Контракт навигации позволяет оболочке знать маршрут MFE и передать ему событие back. Если MFE вернулся на предыдущий экран, он отвечает handled: true. Иначе оболочка продолжает собственную навигацию. MFE предоставляет обработчик при монтировании, а вызывает его оболочка.

type DeliveryRemote = {
  onBack(): Promise<{ handled: boolean }>
}
const result = await deliveryRemote.onBack()
if (!result.handled) {
  shellRouter.back()
}

Загрузка и обновление MFE

Бандл Capacitor и бандл MFE собираются и публикуются отдельно:

Capacitor
  → обновляется через публикацию в store или <abbr title="over-the-air, обновление веб-части без публикации новой нативной сборки">OTA</abbr>-обновление

Delivery MFE
  → публикуется на CDN как remoteEntry.js

Для нового MFE не нужен APK/IPA. Ему доступны только возможности, уже поставленные с установленной версией приложения и контрактом.

На стороне Capacitor понадобятся:

  • экран загрузки;

  • резервный сценарий для сетевой ошибки или несовместимой версии;

  • кэш последней проверенной версии, если функция должна работать без сети;

  • откат к предыдущей рабочей версии.

Продумайте и кэширование. Если remoteEntry.js публикуется по одному и тому же URL, кэш может сохранить старые чанки после обновления. Версионированный релиз помогает избежать такой проблемы:

https://cdn.example.com/delivery/3.8.0/remoteEntry.js
https://cdn.example.com/delivery/3.8.0/assets/...

Актуальный разрешённый релиз оболочка узнаёт из манифеста:

{
  "name": "delivery",
  "version": "3.8.0",
  "entry": "https://cdn.example.com/delivery/3.8.0/remoteEntry.js",
  "minimumShellVersion": "1.4.0"
}

Для манифеста тоже нужны HTTPS, список разрешённых доменов и, в зависимости от модели угроз, подпись или проверка целостности.

Почему одного URL remoteEntry.js недостаточно

После обновления entry-файла браузерный кэш может сохранить старые чанки. Поэтому версия должна входить и в URL remoteEntry.js, и в URL его ассетов; оболочка должна хранить последнюю проверенную версию и уметь откатиться к ней.

Совместимость версий

Capacitor и MFE выпускаются независимо, а у пользователей могут оказаться разные сочетания их версий. Совместимость придётся описать в контракте.

Допустим, Delivery MFE начал вызывать platform.location.openSettings(), но у части пользователей стоит старая оболочка без этого метода. Тут есть три варианта:

  • MFE требует minimumShellVersion и не загружается на старой оболочке;

  • функция опциональна, а MFE показывает резервный сценарий;

  • оболочка сначала выпускает новый метод, сохраняет его совместимым, и только потом MFE начинает им пользоваться.

Я бы начал с третьего варианта: выпустил новый метод в оболочке без удаления старых, включил функцию в MFE через фиче-флаг и дождался обновления приложения у пользователей. После этого метод можно сделать обязательным.

Версию контракта стоит передавать явно:

type PlatformInfo = {
  shellVersion: string
  contractVersion: 3
}

TypeScript проверит типы во время сборки. Совместимость установленного Capacitor и опубликованного MFE нужно проверять при загрузке.

Итог

Module Federation работает внутри Capacitor так же, как в браузере. Специальные хитрые решения нужны, когда MFE обращается к возможностям устройства или участвует в системной навигации.

Если вы уже делили Capacitor-приложение на MFE, расскажите в комментариях, как согласовали навигацию и версии контрактов между командами.

В следующей части разберем безопасность Capacitor приложений. На этом у меня все. Пишите любые интересующие вас вопросы в комментарии и в личку VK | Telegram.


Ссылки: