
Привет, Хабр! В 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, } } })</code></pre><h3>Особенности</h3><p>Проперти `uniqueName` должна содержать уникальное имя в рамках всего приложения, иначе начнутся коллизии при загрузке плагинов. В нашем случае проперти `publicPath` должна содержать значение auto, потому что URL до наших плагинов задается в динамической конфигурации и при сборке приложения не известен.</p><pre><code>output: { uniqueName: "angularShell", publicPath: "auto" }
Особенности
Проперти `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-компонент;
-
Продумать валидатор проверки уникальности имен плагинов и пересечения зависимостей для оптимизации бандла.
Отзывы, соображения, повествования о том, как сделано сейчас у вас, приветствуются в комментариях. 🙂
ссылка на оригинал статьи https://habr.com/ru/company/netcracker/blog/568054/
Добавить комментарий