Webpack Module Federation — микрофронтенд на современных технологиях

от автора

Привет, Хабр! В Netcracker мы уже давно используем микрофронтендную архитектуру, и с 2017 года начали разрабатывать собственный платформенный инструмент построения микрофронтендов.

Недавно на митапе мы показывали, как делать сложные приложения, разрабатываемые разными командами в разных релизных циклах и даже технологиях. В режиме live coding соединили Angular, React и Vue в одном SPA. Было много вопросов про Webpack Module federation. Поскольку мы уже переходим на этот фреймворк, здесь мы поделимся наработками, как сделать Angular host application + React/Angular/Vue microfrontends с возможностью независимого версионирования зависимостей.

Задачи

Итак задача — построить рабочий прототип по всем правилам работы микросервисного мира во фронтенде. А значит, у нашего прототипа должны быть:

  1. Отсутствие связанности между плагинами;

  2. Удобная общая шина для общения между плагинами;

  3. True-роутинг в хостовом приложении;

  4. Максимальное переиспользование повторяющихся «глупых» компонентов.

Кажется нетрудно, но есть несколько интересных нюансов. Во-первых, надо обеспечить вседозволенность в вопросе выбора фреймворков и их версий. Во-вторых, нужно, чтобы каждый компонент мог зависеть от любых нужных библиотек. В-третьих, нужно придумать, как всё это уместить в одно большое приложение с возможностью «шарить общее», и максимально переиспользовать пересекающиеся компоненты.

Минимальные требования

Для high-level описания будущего прототипа мы выделили вот такие требования:

  1. Каждая разработка должна храниться в отдельном репозитории, иметь собственный CI/CD;

  2. На этапе билда никто не должен знать о будущем соседстве. Под знанием, конечно же, имеются в виду технические настройки самого билдера;

  3. Загрузка плагинов должна быть динамическая, в runtime;

  4. Настройка места дислокации плагинов должна быть динамическая;

  5. Не должно быть кастомизаций текущих open-source решений;

  6. Необходимо создать только концептуальную идею, прототип, без разработки громоздких фреймворков;

  7. Хост-приложение должно уметь встраивать плагины, написанные на разных фреймворках, без ограничения по зависимостям и их версиям;

  8. Прототип должен динамически уметь конфигурировать набор плагинов в приложении;

  9. У каждого плагина должна быть возможность использовать любые библиотеки вне зависимости от имеющихся в хост-приложении.

Вот с таким набором требований мы и приступили к разработке. Много кода впереди!

Прототип хоста

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

Шаги для перехода на концепт 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 для хостового приложения все готово. Теперь добавим немного кода, реализующего идею динамики в рантайме.

  1. Дальше по плану: реализация lazy-роута на модуль, загруженный из удаленного плагина

  2. Реализация вставки Angular (v.) компоненты в хостовое приложение на Angular (v.)

  3. Реализация вставки React компоненты в наше хостовое приложение на Angular (v.*)

Для обеспечения загрузки модуля из удаленного плагина нам нужно знать:

  1. URL до удаленного плагина;

  2. Имя удаленного плагина (то, что написано в library.name в “webpack.config.js”);

  3. Имя модуля, который мы экспоузим в удаленном плагине;

  4. Имя angular-модуля, чтобы правильно загрузить его в loadChildren методе;

  5. Имя angular-роута для использования в хостовом приложении;

  6. Человекочитаемое имя, чтобы положить его в текст ссылки, которая навигирует нас на только что подгруженный модуль.

В итоге у меня получилась вот такая небольшая конфигурация для конкретного роута:

// 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!

Планы по развитию

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

  1. Добавить обработку несуществующего типа плагина;

  2. Дописать рекурсию для вложенных роутов;

  3. Написать шину общения между плагинами;

  4. Правильно обработать кейс навигации между фрагментами без хардкода;

  5. Добавить адаптер для Vue-компонент;

  6. Продумать валидатор проверки уникальности имен плагинов и пересечения зависимостей для оптимизации бандла.

Отзывы, соображения, повествования о том, как сделано сейчас у вас, приветствуются в комментариях. 🙂

ссылка на оригинал статьи https://habr.com/ru/company/netcracker/blog/568054/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *