Всем привет, меня зовут Иван и я фронтенд-разработчик.

На моём комментарии про микрофронтенды набралось целых три лайка, поэтому я решил написать статью с описанием всех шишек, что наш стрим набил и набивает в результате внедрения микрофронтендов.

Начнём с того, что ребята с Хабра (@artemu78, @dfuse, @Katsuba) уже писали про Module Federation, так что, моя статья - это не что-то уникальное и прорывное. Скорее, это шишки, костыли и велосипеды, которые полезно знать тем, кто собирается использовать данную технологию.

Причина

Причина, по которой решено было внедрять микросервисный подход на фронте, довольно простая - много команд, а проект один, нужно было как-то разделить зоны ответственности и распараллелить разработку. Как раз в тот момент, мне на глаза попался доклад Павла Черторогова про Webpack 5 Module Federation. Честно, это перевернуло моё видение современных веб-приложений. Я очень вдохновился и начал изучать и крутить эту технологию, чтобы понять, можно ли применить это в нашем проекте. Оказалось, всё что нужно, это дописать несколько строк в конфиг Webpack, создать пару компонентов-хелперов, и... всё завелось.

Настройка

Итак, что же нужно сделать, чтобы запустить микрофронтенды на базе сборки Webpack 5?

Для начала, убедитесь, что используете Webpack пятой версии, потому что Module Federation там поддерживается из коробки.

Настройка shell-приложения

Так как, до внедрения микрофронтендов, у нас уже было действующее приложение, решено было использовать его в качестве точки входа и оболочки для подключения других микрофронтендов. Для сборки использовался Webpack версии 4.4 и при обновлении до 5 версии возникли небольшие проблемы с некоторыми плагинами. К счастью, это решилось простым поднятием версий плагинов.

Чтобы создать контейнер на базе сборки Webpack и при помощи этого контейнера иметь возможность импортировать ресурсы с удаленных хостов добавляем в Webpack-конфиг следующий код:

const webpack = require('webpack');

// ...

const { ModuleFederationPlugin } = webpack.container;

const deps = require('./package.json').dependencies;

module.exports = {
  // ...
  output: {
    // ...
    publicPath: 'auto', // ВАЖНО! Указывайте либо реальный publicPath, либо auto
  },
  module: {
    // ...
  },
  plugins: [
    // ...
    new ModuleFederationPlugin({
      name: 'shell',
      filename: 'shell.js',
      shared: {
        react: { requiredVersion: deps.react },
        'react-dom': { requiredVersion: deps['react-dom'] },
        'react-query': {
          requiredVersion: deps['react-query'],
        },
      },
      remotes: {
        widgets: `widgets@http://localhost:3002/widgets.js`,
      },
    }),
  ],
  devServer: {
    // ...
  },
};

Теперь нам нужно забутстрапить точку входа в наше приложение, чтобы оно запускалось асинхронно, для этого создаем файл bootstrap.tsx и кладем туда содержимое файла index.tsx

// bootstrap.tsx

import React from 'react';
import { render } from 'react-dom';

import { App } from './App';
import { config } from './config';

import './index.scss';

config.init().then(() => {
  render(<App />, document.getElementById('root'));
});

А в index.tsx вызываем этот самый bootstrap

import('./bootstrap');

В общем то всё, в таком виде уже можно импортировать ваши микрофронтенды - они указываются в объекте remotes в формате <name>@<адрес хоста>/<filename>. Импортировать модуль подключенный таким образом можно как обычный компонент, так , как будто он находится в локальной папке.

import React from 'react';

// ...

import Todo from 'widgets/Todo';

// ...

const queryClient = new QueryClient();

export const App = () => {
  // ...

  return (
    <QueryClientProvider client={queryClient} contextSharing>
      <Router>
        <Layout sidebarContent={<Navigation />}>
          <Switch>
            {/* ... */}

            <Route exact path='/'>
              <Todo />
            </Route>

            {/* ... */}
          </Switch>
        </Layout>
      </Router>
    </QueryClientProvider>
  );
};

Но нам такая конфигурация не подходит, ведь на момент сборки приложения мы ещё не знаем откуда будем брать микрофронтенд, к счастью, есть готовое решение, поэтому возьмем код из примера для динамических хостов, так как наше приложение написано на React, то оформим хэлпер в виде React-компонента LazyService:

// LazyService.tsx

import React, { lazy, ReactNode, Suspense } from 'react';

import { useDynamicScript } from './useDynamicScript';
import { loadComponent } from './loadComponent';
import { Microservice } from './types';
import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary';

interface ILazyServiceProps<T = Record<string, unknown>> {
  microservice: Microservice<T>;
  loadingMessage?: ReactNode;
  errorMessage?: ReactNode;
}

export function LazyService<T = Record<string, unknown>>({
  microservice,
  loadingMessage,
  errorMessage,
}: ILazyServiceProps<T>): JSX.Element {
  const { ready, failed } = useDynamicScript(microservice.url);

  const errorNode = errorMessage || <span>Failed to load dynamic script: {microservice.url}</span>;

  if (failed) {
    return <>{errorNode}</>;
  }

  const loadingNode = loadingMessage || <span>Loading dynamic script: {microservice.url}</span>;

  if (!ready) {
    return <>{loadingNode}</>;
  }

  const Component = lazy(loadComponent(microservice.scope, microservice.module));

  return (
    <ErrorBoundary>
      <Suspense fallback={loadingNode}>
        <Component {...(microservice.props || {})} />
      </Suspense>
    </ErrorBoundary>
  );
}

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

// useDynamicScript.ts
  
import { useEffect, useState } from 'react';

export const useDynamicScript = (url?: string): { ready: boolean; failed: boolean } => {
  const [ready, setReady] = useState(false);
  const [failed, setFailed] = useState(false);

  useEffect(() => {
    if (!url) {
      return;
    }

    const script = document.createElement('script');

    script.src = url;
    script.type = 'text/javascript';
    script.async = true;

    setReady(false);
    setFailed(false);

    script.onload = (): void => {
      console.log(`Dynamic Script Loaded: ${url}`);
      setReady(true);
    };

    script.onerror = (): void => {
      console.error(`Dynamic Script Error: ${url}`);
      setReady(false);
      setFailed(true);
    };

    document.head.appendChild(script);

    return (): void => {
      console.log(`Dynamic Script Removed: ${url}`);
      document.head.removeChild(script);
    };
  }, [url]);

  return {
    ready,
    failed,
  };
};

loadComponent это обращение к Webpack-контейнеру, по сути - обычный динамический импорт.

// loadComponent.ts

export function loadComponent(scope, module) {
  return async () => {
    // Initializes the share scope. This fills it with known provided modules from this build and all remotes
    await __webpack_init_sharing__('default');

    const container = window[scope]; // or get the container somewhere else
    // Initialize the container, it may provide shared modules
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  };
}

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

// types.ts

export type Microservice<T = Record<string, unknown>> = {
  url: string;
  scope: string;
  module: string;
  props?: T;
};
  • url - имя хоста + имя контейнера (например, http://localhost:3002/widgets.js), с которого мы хотим подтянуть модуль

  • scope - параметр name, который мы укажем в удаленном конфиге ModuleFederationPlugin

  • module - имя модуля, который мы хотим подтянуть

  • props - опциональный параметр, если вдруг наш микросервис требует пропсы, нужно их типизировать

Вызов компонента LazyService происходит следующим образом:

import React, { FC, useState } from 'react';

import { LazyService } from '../../components/LazyService';
import { Microservice } from '../../components/LazyService/types';
import { Loader } from '../../components/Loader';
import { Toggle } from '../../components/Toggle';
import { config } from '../../config';

import styles from './styles.module.scss';

export const Video: FC = () => {
  const [microservice, setMicroservice] = useState<Microservice>({
    url: config.microservices.widgets.url,
    scope: 'widgets',
    module: './Zack',
  });

  const toggleMicroservice = () => {
    if (microservice.module === './Zack') {
      setMicroservice({ ...microservice, module: './Jack' });
    }

    if (microservice.module === './Jack') {
      setMicroservice({ ...microservice, module: './Zack' });
    }
  };

  return (
    <>
      <div className={styles.ToggleContainer}>
        <Toggle onClick={toggleMicroservice} />
      </div>
      <LazyService microservice={microservice} loadingMessage={<Loader />} />
    </>
  );
};

В общем-то, по коду видно, что мы можем динамически переключать наши модули, а основной url хранить, например, в конфиге.

Так, с shell-приложением вроде разобрались, теперь нужно откуда-то брать наши модули.

Настройка микрофронтенда

Для начала проделываем все те же манипуляции что и в shell-приложении и убеждаемся, что версия Webpack => 5

Настраиваем ModuleFederationPlugin, но уже со своими параметрами, эти параметры указываем при подключении модуля в основное приложение.

// ...

new ModuleFederationPlugin({
      name: 'widgets',
      filename: 'widgets.js',
      shared: {
        react: { requiredVersion: deps.react },
        'react-dom': { requiredVersion: deps['react-dom'] },
        'react-query': {
          requiredVersion: deps['react-query'],
        },
      },
      exposes: {
        './Todo': './src/App',
        './Gallery': './src/pages/Gallery/Gallery',
        './Zack': './src/pages/Zack/Zack',
        './Jack': './src/pages/Jack/Jack',
      },
    }),

// ...

В объекте exposes указываем те модули, которые мы ходим отдать наружу, точку входа в приложение так же нужно забутстрапить. Если в микрофронтенде нам не нужны модули с других хостов, то компонент LazyService тут не нужен.

Вот и всё, получен работающий прототип микрофронтенда.

Выглядит круто, работает тоже круто. Общие зависимости не грузятся повторно, версии библиотек рулятся плагином, можно динамически переключать модули, в общем, сказка. Если копать глубже, то это очень гибкая технология, можно использовать её не только с React и JavaScript, но и со всем, что переваривает Webpack, то есть теоретически можно подружить части приложения написанные на разных фреймворках, это конечно не очень хорошо, но сделать так можно. Можно собрать модули и положить на CDN, можно использовать контейнер как общую библиотеку компонентов для нескольких приложений. Возможностей реально много.

Что мы получаем

Независимую разработку и развёртывание

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

Повышенную отказоустойчивость

Так как приложение теперь состоит из нескольких частей, которые расположены на разных хостах, то при недоступности одного микрофронтенда, основной функционал продолжить работать. Конечно, если shell-приложение будет недоступно, то Module Federation тут не поможет. Ещё можно продублировать критичные микрофронтенды на несколько хостов расположенных в разных локациях и подключать их в порядке приоритета, таким образом, если один хост будет недоступен, подключится следующий и так по порядку.

Проблемы

Когда удалось запустить это в нашем проекте я был доволен, нет, очень доволен, но это длилось недолго, после того как началась реальная работа над микрофронтендами, начали всплывать наши любимые подводные камни, а теперь поговорим он них подробнее.

Потеря контекстов в React-компонентах

Как только понадобилось работать с контекстом библиотеки react-router, то возникли проблемы, при попытке использовать в микрофронтенде хук useLocation, например, приложение вылетало с ошибкой.

Ошибка при попытке обращения к контексту shell-приложения из микрофронтенда

Для взаимодействия с бэкендом мы используем Apollo, и хотелось, чтобы ApolloClient объявлялся только единожды в shell-приложении. Но при попытке из микрофронтенда просто использовать хук useQuery, в рантайме приложение вылетало с такой же ошибкой как и для useLocation.

Экспериментальным путём было выяснено, для того чтобы контексты правильно работали, нужно в микрофронтендах использовать версию npm-пакета не выше, чем в shell-приложение, так что за этим нужно внимательно следить.

Дублирование UI-компонентов в shell-приложении и микрофронтенде

Так как разработка ведётся разными командами, есть шанс, что разработчики напишут компоненты с одинаковым функционалом и в shell-приложении и в микрофронтенде. Чтобы этого избежать, есть несколько решений:

  1. Выносить UI-компоненты в отдельный npm-пакет и использовать его как shared-модуль

  2. "Делиться" компонентами через ModuleFederationPlugin

В принципе, у обоих подходов есть свои плюсы, но мы выбрали первый, потому что так удобнее и прозрачнее управлять библиотекой компонентов. Да и саму технологию Module Federation хотелось использовать как механизм для построения микрофронтендов, а не аналог npm.

Типизация

Если вы ведёте разработку на TypeScript, могут возникнуть сложности с типизацией микрофронтендов, потому что в Module Federation пока нет механизма, с помощью которого можно было бы передавать типы на другой хост. В нашем продукте нам пока не приходилось типизировать микрофронтенды - мы стараемся реализовывать их так, чтобы для работы им не требовались пропсы. Но если вам нужно типизировать свой микрофронтенд, то придётся либо расписывать .d.ts файлы руками, либо написать какой-то свой инструмент для сбора деклараций типов с других микрофронтендов.

Ещё есть инструмент emp-tune-dts-plugin, но про него я ничего не могу сказать, так как сам им не пользовался.

Заключение

Пока что выглядит так, что переход на Webpack 5 Module Federation решает проблему, которая стояла перед нашим стримом, а именно - разделение зоны ответственности и распараллеливание разработки. При этом, нет больших накладных расходов при разработке, а настройка довольно проста даже для тех, кто не знаком с этой технологией.

Минусы у этого подхода конечно же есть, накладные расходы для развертывания зоопарка микрофронтендов будут значительно выше, чем для монолита. Если над вашим приложением работает одна-две команды и оно не такое большое, то наверное не стоит делить его на микросервисы.

Но для нашей конкретной проблемы, это решение подошло хорошо, посмотрим, как оно покажет себя в будущем, технология развивается и уже появляются фреймворки и библиотеки, которые под капотом используют Module Federation.

Полезные ссылки

Репозиторий из примера

Документация Module Federation в доках Webpack 5

Примеры использования Module Federation

Плейлист по Module Federation на YouTube