Привет! Мы в 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,

        }
      }

    })

Особенности

Проперти `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. Продумать валидатор проверки уникальности имен плагинов и пересечения зависимостей для оптимизации бандла.

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