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/


Интересные статьи

Интересные статьи

Как IT-индустрия мы исключительно хорошо умеем создавать большие и сложные программные системы. Но сейчас мы начинаем наблюдать рост массивных и сложных систем, построенных вокруг данных,...
Одна из самых важных (на мой взгляд) функций в Битрикс24 это бизнес-процессы. Теоретически они позволяют вам полностью избавиться от бумажных служебок и перенести их в эл...
Мне было необходимо делать 2 раза в сутки бэкап сайта на «1С-Битрикс: Управление сайтом» (файлов и базы mysql) и хранить историю изменений за 90 дней. Сайт расположен на VDS под уп...
Ваш сайт работает на 1С-Битрикс? Каждому клиенту вы даёте собственную скидку или назначаете персональную цену на товар? Со временем в вашей 1С сложилась непростая логика ценообразования и формирования...
По мере того, как растёт сложность клиентских приложений, размеры их бандлов становятся всё больше и больше. В этой ситуации сильнее всего страдают люди, вынужденные, по разным причинам, пользова...