Давно не виделись хабровчане. Сегодня мы поговорим на избитую и всем уже изрядно надоевшую тему, но слегка под другим углом. Если вы уже работали с микрофронтендами в вебе, сама загрузка отдельной сборки внутри Capacitor вряд ли удивит. WebView исполняет JavaScript, а уже он загружает сборку через Module Federation и монтирует её как компонент.
Сложности начинаются, когда этому микрофронту нужен, например, доступ к камере или геолокация. Сразу возникает несколько вопросов:
Кто регистрирует плагины и запрашивает разрешения?
Как собственная маршрутизация микрофронта уживается с системной кнопкой “Назад” и диплинками?
Что можно обновлять через ОТА обновления, а что нет?
Разберём это на примере воображаемого(но реалистичного) Capacitor-приложения: страница профиля выступает оболочкой, а адреса доставки загружаются отдельным микрофронтендом.
Об авторе: Илья Скаков — мобильный разработчик, специализируется на гибридных приложениях на Capacitor и React Native.
Оглавление:
Термины и границы ответственности
Понятие | В этой статье |
|---|---|
Capacitor | Стартовая веб-часть приложения в WebView. Владеет маршрутизацией верхнего уровня, регистрацией плагинов и контрактами с MFE. |
MFE | Независимо собираемая веб-функция, которую оболочка загружает и монтирует в своей области. В примерах — Delivery MFE. |
Контракт | Типизированный набор методов и данных, через который MFE запрашивает возможности устройства и навигацию. |
Диплинк | Ссылка, которая открывает конкретный экран приложения: |
Два слоя приложения
У обычного Capacitor приложения есть собственно свой бандл и нативные проекты Android/iOS. С микрофронтендами бандл делится ещё на две части:
Capacitor-приложение │ ├─ Capacitor │ ├─ стартовый бандл, лежащий в приложении │ ├─ Capacitor runtime и плагины │ ├─ авторизация, верхнеуровневый роутер │ ├─ загрузчик MFE │ └─ контракт │ └─ Delivery MFE ├─ собственный бандл и доменная логика ├─ собственная маршрутизация └─ вызовы разрешённых плагинов через контракт

Capacitor здесь — стартовая веб-часть приложения. Он открывается первым, знает о текущем пользователе, создаёт роутер и регистрирует плагины.
Delivery MFE можно собирать и выпускать отдельной командой. На устройстве он работает в том же WebView, что и Capacitor.
Из схемы следуют два правила:
MFE не регистрирует плагины Capacitor самостоятельно.
Для обращения к нативным возможностям 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 | Контракт | Чем реализует оболочка |
|---|---|---|
Сделать фото документа |
| плагин Camera и продуктовый сценарий |
Выбрать файл |
| плагин File Picker |
Прочитать QR |
| плагин Barcode Scanner |
Открыть системные настройки |
| нативный адаптер |
Подтвердить действие вибрацией |
| плагин Haptics |
Открыть другой раздел приложения |
| роутер оболочки |
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 должен:
проверить, доступна ли фича и авторизован ли пользователь;
сохранить намерение перехода, пока MFE ещё загружается;
передать 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.
Ссылки:

