Привет! Мы в Netcracker уже давно используем микрофронтендную архитектуру, и с 2017 года начали разрабатывать собственный платформенный инструмент построения микрофронтендов, о котором сегодня и пойдет речь.
А недавно мы проводили митап, в котором в режиме live coding показали, как дружить Angular, React и Vue в одном SPA. Было много вопросов про Webpack Module federation. Поскольку мы уже переходим на этот фреймворк, здесь мы поделимся наработками, как сделать Angular host application + React/Angular/Vue microfrontends с возможностью независимого версионирования зависимостей.
Рассмотрим все на рабочем прототипе.
Задачи
Итак задача — построить рабочий прототип по всем правилам работы микросервисного мира во фронтенде. А значит, у нашего прототипа должны быть:
Отсутствие связанности между плагинами;
Удобная общая шина для общения между плагинами;
True-роутинг в хостовом приложении;
Максимальное переиспользование повторяющихся "глупых" компонентов.
Кажется нетрудно, но есть несколько интересных нюансов. Во-первых, надо обеспечить вседозволенность в вопросе выбора фреймворков и их версий. Во-вторых, нужно, чтобы каждый компонент мог зависеть от любых нужных библиотек. В-третьих, нужно придумать, как всё это уместить в одно большое приложение с возможностью "шарить общее", и максимально переиспользовать пересекающиеся компоненты.
Минимальные требования
Для high-level описания будущего прототипа мы выделили вот такие требования:
Каждая разработка должна храниться в отдельном репозитории, иметь собственный CI/CD;
На этапе билда никто не должен знать о будущем соседстве. Под знанием, конечно же, имеются в виду технические настройки самого билдера;
Загрузка плагинов должна быть динамическая, в runtime;
Настройка места дислокации плагинов должна быть динамическая;
Не должно быть кастомизаций текущих open-source решений;
Необходимо создать только концептуальную идею, прототип, без разработки громоздких фреймворков;
Хост-приложение должно уметь встраивать плагины, написанные на разных фреймворках, без ограничения по зависимостям и их версиям;
Прототип должен динамически уметь конфигурировать набор плагинов в приложении;
У каждого плагина должна быть возможность использовать любые библиотеки вне зависимости от имеющихся в хост-приложении.
Вот с таким набором требований мы и приступили к разработке. Много кода впереди!
Прототип хоста
Нетерпеливые читатели, которые любят наглядность — ловите ссылку на репозиторий :) Но лучше оставайтесь с нами и следите за руками.
Шаги для перехода на концепт Module Federation не сильно разнятся от приложения к приложению. Разница будет только в наборе shared пакетов и модулей, которые мы публикуем для будущего использования.
Ниже — пошаговая инструкция и пример перехода существующего angular-приложения. Мы создаем из него host, который будет агрегировать плагины по роутам.
1) Добавим в package.json возможность разрешать зависимости основываясь на webpack 5:
"resolutions": {
"webpack": "^5.0.0"
}
2) Сделаем yarn пакетным менеджером по умолчанию:
ng config cli.packageManager yarn
3) Добавм в проект пакет @angular-architects/module-federation:
yarn add @angular-architects/module-federation
4) Сконигурим хостовое приложение таким образом, чтобы оно могло шарить свои зависимости. На этом этапе используем только дефолтный скоуп.
const webpack = require("webpack");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const mf = require("@angular-architects/module-federation/webpack");
const path = require("path");
const dependencies = require("./package.json").dependencies;
const sharedMappings = new mf.SharedMappings();
sharedMappings.register(
path.join(__dirname, 'tsconfig.json'),
[/* mapped paths to share */]);
module.exports = {
output: {
uniqueName: "angularShell",
publicPath: "auto"
},
optimization: {
runtimeChunk: false
},
resolve: {
alias: {
...sharedMappings.getAliases(),
}
},
plugins: [
new webpack.ProvidePlugin({
"React": "react",
}),
new ModuleFederationPlugin({
shared: {
'@angular/common/http': {
requiredVersion: dependencies['@angular/common'],
singleton: false,
eager: true
},
'@angular/common': {
version: dependencies['@angular/common'],
singleton: false,
eager: true
},
'@angular/core': {
version: dependencies['@angular/core'],
requiredVersion: dependencies['@angular/core'],
singleton: false,
eager: true
},
'@angular/platform-browser': {
version: dependencies['@angular/platform-browser'],
requiredVersion: dependencies['@angular/platform-browser'],
singleton: false,
eager: true
},
'@angular/platform-browser-dynamic': {
version: dependencies['@angular/platform-browser-dynamic'],
requiredVersion: dependencies['@angular/platform-browser-dynamic'],
singleton: false,
eager: true
},
'@angular/router': {
version: dependencies['@angular/router'],
requiredVersion: dependencies['@angular/router'],
singleton: false,
eager: true
},
'@angular/cdk/a11y': {
version: dependencies['@angular/cdk/a11y'],
requiredVersion: dependencies['@angular/cdk/a11y'],
singleton: false,
eager: true
},
'@angular/animations': {
version: dependencies['@angular/animations'],
requiredVersion: dependencies['@angular/animations'],
singleton: false,
eager: true
},
}
})
],
};
Чисто технически, по настройке самого Module Federation для хостового приложения все готово. Теперь добавим немного кода, реализующего идею динамики в рантайме.
Дальше по плану:Реализация lazy-роута на модуль, загруженный из удаленного плагина
Реализация вставки Angular (v.*) компоненты в хостовое приложение на Angular (v.*)
Реализация вставки React компоненты в наше хостовое приложение на Angular (v.*)
Для обеспечения загрузки модуля из удаленного плагина нам нужно знать:
URL до удаленного плагина;
Имя удаленного плагина (то, что написано в `library.name` в “webpack.config.js”);
Имя модуля, который мы экспоузим в удаленном плагине;
Имя angular-модуля, чтобы правильно загрузить его в `loadChildren` методе;
Имя angular-роута для использования в хостовом приложении;
Человекочитаемое имя, чтобы положить его в текст ссылки, которая навигирует нас на только что подгруженный модуль.
В итоге у меня получилась вот такая небольшая конфигурация для конкретного роута:
// sample-configuration.ts
{
"type": "angular",
"subType": "module",
"remoteEntry": "http://localhost:4201/remoteEntry.js",
"remoteName": "angular_mfe_1",
"exposedModule": "MfeModule",
"displayName": "First lazy module plugin",
"routePath": "firstModule",
"moduleName": "BusinessModule"
}
Она загружается при старте приложения. Потом роуты в приложении обновляются.
// federation-plugin.service.ts
this.router.resetConfig(buildRoutes(routes));
this.loadRemoteContainersByRoutes(routes);
Для наглядности сразу посмотрим на конфигурацию этого плагина.
// webpack.config.js
new ModuleFederationPlugin({
name: "angular_mfe_1",
library: {type: "var", name: "angular_mfe_1"},
filename: "remoteEntry.js",
exposes: {
MfeModule: "./src/app/modules/business-module/business.module.ts",
BusinessComponent: "./src/app/modules/business-module/business/business.component.ts"
},
shared: {....}
})
Думаю, не стоит вдаваться в подробности мапинга конфигурационных значений на проперти Module Federation плагина.
Для того, чтобы можно было использовать компоненты, которые написаны на других фреймворках, нам нужны адаптеры. В нашем случае их будет два: angular в angular и react в angular.
Листинг адаптера над angular компонентой чуть ниже. Вряд ли вы найдете там особенно выдающиеся строчки, потому что в основном это решение опирается на официальную документацию Angular по динамической инициализации компонентов. Но если вам неохота лишний раз лезть в документацию, кликайте на спойлер — в нём готовое решение.
@Component({
selector: 'angular-wrapper',
template: ""
})
export class AngularWrapperComponent implements AfterContentInit {
constructor(private hostRef: ViewContainerRef,
private componentFactoryResolver: ComponentFactoryResolver,
private route: ActivatedRoute) {}
async ngAfterContentInit(): Promise<void> {
this.route.data
.pipe(take(1))
.subscribe(async (data: Data) => {
const configuration: FederationPlugin = data.configuration;
const component = await loadRemoteModule({
remoteEntry: configuration.remoteEntry,
remoteName: configuration.remoteName,
exposedModule: configuration.exposedModule
});
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(component[configuration.moduleName]);
this.hostRef.clear();
const componentRef = this.hostRef.createComponent(componentFactory);
componentRef.changeDetectorRef.detectChanges();
})
}
}
Адаптер над React компонентой выглядит вот так:
@Component({
selector: 'react-wrapper',
template: '',
styles: [":host {height: 100%; overflow: auto;}"]
})
export class ReactWrapperComponent implements AfterContentInit {
constructor(private hostRef: ElementRef,
private route: ActivatedRoute) {}
async ngAfterContentInit(): Promise<void> {
this.route.data
.pipe(take(1))
.subscribe(async (data: Data) => {
const configuration: FederationPlugin = data.configuration;
const component = await loadRemoteModule({
remoteEntry: configuration.remoteEntry,
remoteName: configuration.remoteName,
exposedModule: configuration.exposedModule
});
const ReactMFEModule = component[configuration.moduleName];
const hostElement = this.hostRef.nativeElement;
ReactDOM.render(<ReactMFEModule/>, hostElement);
})
}
}
Плюс, чтобы React работал в Angular хост-приложении, нужно добавить ь `ProvidePlugin` в webpack конфигурацию:
new webpack.ProvidePlugin({
"React": "react",
}),
Оркестратор выбора плагинов напишем таким образом, чтобы в конфигурации можно было указать, с помощью какого фреймворка написан компонент/модуль. Так можно будет точно понимать, какой способ инициализации плагина стоит выбрать. Вот так выглядит схема для конструирования роутов:
А вот листинг кода, который мы используем для конструирования роутов по схеме:
export function buildRoutes(options: ReadonlyArray<Microfrontend>): Routes {
const lazyRoutes: Routes = options?.map((mfe: Microfrontend) => {
switch (mfe.type) {
case "angular": {
switch (mfe.subType) {
case "module": {
return {
path: mfe.routePath,
loadChildren: () => loadRemoteModule(mfe).then((m) => m[mfe.moduleName]),
}
}
case "component": {
return {
path: mfe.routePath,
component: AngularWrapperComponent,
data: {configuration: mfe}
}
}
}
break;
}
case "react": {
return {
path: mfe.routePath,
component: ReactWrapperComponent,
data: {configuration: mfe}
}
}
default: {
return {
path: mfe.routePath, // TODO: add UnknownPluginType component to catch incorrect configuration
data: {configuration: mfe}
}
}
}
});
return [...(lazyRoutes || []), ...APPLICATION_ROUTES];
}
Прототип плагина
Плагин будет отличаться от хостового приложения всего парой строк в `webpack.config.js`.
Примечательной здесь будет секция `exposes`. Тут мы указываем ключевые имена модулей и путь до классов, которые их реализуют.. Имя класса и имя модуля в секции `exposes` могут быть разными.
new ModuleFederationPlugin({
name: "angular_mfe_1",
library: {type: "var", name: "angular_mfe_1"},
filename: "remoteEntry.js",
exposes: {
MfeModule: "./src/app/modules/business-module/business.module.ts",
BusinessComponent: "./src/app/modules/business-module/business/business.component.ts"
},
shared: {
'@angular/common/http': {
version: dependencies['@angular/common'],
requiredVersion: dependencies['@angular/common'],
singleton: true,
},
'@angular/common': {
version: dependencies['@angular/common'],
requiredVersion: dependencies['@angular/common'],
singleton: true,
},
'@angular/core': {
version: dependencies['@angular/core'],
requiredVersion: dependencies['@angular/core'],
singleton: true,
},
'@angular/platform-browser': {
version: dependencies['@angular/platform-browser'],
requiredVersion: dependencies['@angular/platform-browser'],
singleton: true,
},
'@angular/platform-browser-dynamic': {
version: dependencies['@angular/platform-browser-dynamic'],
requiredVersion: dependencies['@angular/platform-browser-dynamic'],
singleton: true,
},
'@angular/router': {
version: dependencies['@angular/router'],
requiredVersion: dependencies['@angular/router'],
singleton: true,
},
'@angular/cdk/a11y': {
version: dependencies['@angular/cdk/a11y'],
requiredVersion: dependencies['@angular/cdk/a11y'],
singleton: true,
},
'@angular/animations': {
version: dependencies['@angular/animations'],
requiredVersion: dependencies['@angular/animations'],
singleton: true,
}
}
})
Особенности
Проперти `uniqueName` должна содержать уникальное имя в рамках всего приложения, иначе начнутся коллизии при загрузке плагинов. В нашем случае проперти `publicPath` должна содержать значение auto, потому что URL до наших плагинов задается в динамической конфигурации и при сборке приложения не известен.
output: {
uniqueName: "angularShell",
publicPath: "auto"
}
Поддержка
когда Module Federation только зарелизили чуть больше года назад, у всех возник вопрос на тему поддержки webpack 5 в angular-cli. На данный момент включили экспериментальную поддержку в Angular 12.
Что касается проектов, созданных с помощью CRA, то на текущий момент react-scripts не поддерживает webpack 5 и для того, чтобы завести на таких проектах Module Federation, придется сделать react-scripts eject, чтобы иметь возможность поменять `webpack.config.js`.
Следить за прогрессом перехода на webpack 5 в react-scripts можно на github.com. Решения react-app-rewired или craco для частичного изменения webpack конфигурации, которые мне попались на глаза во время исследования безболезненного перехода на Module Federation в react-scripts проектах, не взлетели :( Ждем полноценной поддержки в react-scripts!
Планы по развитию
Прототип у нас вышел рабочий, однако всё еще есть довольно много путей развития нашей идеи. Вот что мы собираемся сделать в ближайшем будущем:
Добавить обработку несуществующего типа плагина;
Дописать рекурсию для вложенных роутов;
Написать шину общения между плагинами;
Правильно обработать кейс навигации между фрагментами без хардкода;
Добавить адаптер для Vue-компонент;
Продумать валидатор проверки уникальности имен плагинов и пересечения зависимостей для оптимизации бандла.
Отзывы, соображения, повествования о том, как сделано сейчас у вас, приветствуются в комментариях. :)