👨‍🍳
Как готовить production с webpack 5 module federation?

Вадим Малютин

телеграм: t.me/mfpjke

сайт: mltn.dev

Я Senior Software Engineer в Grid Dynamics

Со-основатель первой виртуальной примерки одежды в вебе clo-z.app

Со-основатель IT-сообщества VectorWay

Со-автор приложения для тренировки глаз, написанный на Flutter

Сертифицированный разработчик AWS – 853/1000 score

Previous experience:

Yandex

Bekitzur

Мотивация

Целевая Аудитория

  • Не слышал, но очень интересно
  • Зачем микрофронтенды, когда есть монолит?
  • В поисках
  • Уже разрабатываем, но не выкатились
  • Выкатились, но интересно как у других

Путь

До доклада

  • Умею в монолит
  • Могу в реакт
  • Не силен в микрофронтенды

После доклада

  • Умею готовить микрофронтенды
  • Знаю подводные камни и решения

Проблема

О проекте

  • Одна сфера
  • Много команд
  • Много сайтов

Следует

  • Сложная навигация
  • Сложная коммуникация
  • Сложно выкатывать сквозные фичи

Что хотим получить?

  • Довольных пользователей
  • Единый дизайн
  • Независимость разработки/деплоя
  • Скорость разработки
  • Меньше ресурсов на UI

Решение

Микрофронтенды => Скорость
Монорепа => Единообразие

Микрофронтенды

  • Независимо разрабатываем
  • Независимо деплоим
  • Независимо тестируем

Монорепозиторий

  • Один CI/CD
  • Нет проблем с зависимостями/версиями пакетов
  • Не нужно линковать пакеты
  • Не нужно много репозиториев

Выберем технологию

  • SSI
  • Iframe
  • Realms
  • Shadow DOM
  • Single-SPA
  • Webpack 5

Watch

Объявляем remote

              
                new ModuleFederationPlugin({
                  name: "watch",
                  filename: "remoteEntry.js",
                  library: { type: "var", name: "watch" },
                  exposes: {
                    "./page": "./src/components/page",
                  }
                })
              
            

Показываем откуда раздаем статику

              
                output: {
                  path: path.resolve(process.cwd(), "dist"),
                  filename: "[name].[contenthash].js",
                  publicPath: "http://localhost:3003/",
                }
              
            

AppHost

Объявляем хост

              
                new ModuleFederationPlugin({
                  name: "app_host",
                  remotes: {
                    watch: "watch",
                  }
                })
              
            

Добавляем скрипты

              
                new HtmlWebpackTagsPlugin({
                  tags: ["http://localhost:3003/remoteEntry.js"],
                  // нужно, чтобы скрипты загрузились до app_host
                  append: false,
                  publicPath: false,
                }),
              
            

Где-то в коде

            
              const WatchPage = lazy(() => import("watch/page"));
              ...
              render() {
                return (
                  <Switch>
                    <Route path="/watch">
                      <Suspense fallback="Loading...">
                        <WatchPage />
                      </Suspense>
                    </Route>
                  </Switch>
                )
              }
            
          

Как добавить микрофронтенды?

Сам добавлю скрипты

              
                new HtmlWebpackTagsPlugin({
                  tags: ["http://localhost:3003/remoteEntry.js"],
                  // нужно, чтобы скрипты загрузились до app_host
                  append: false,
                  publicPath: false,
                }),
                new ModuleFederationPlugin({
                  name: "app_host",
                  remotes: {
                    watch: "watch",
                  }
                })
              
            

Module federation добавляет скрипты

              
                new ModuleFederationPlugin({
                  name: "app_host",
                  remotes: {
                    watch: "watch@http://localhost:3003/remoteEntry.js",
                  }
                })
              
            

Как это выглядит в браузере?

              
                <head>
                  <script
                    src="http://localhost:3003/remoteEntry.js"
                    defer
                  ></script>
                  <script
                    src="http://localhost:3001/app.ed5a105fae04a74d5e4b.js"
                    defer
                  ></script>
                </head>
              
            

Что такое remoteEntry.js?

Какой способ использовать?

  • Сам добавлю скрипты
  • Module federation добавляет скрипты

app-host/webpack.config.ts

              
                new HtmlWebpackTagsPlugin({
                  tags: ["http://localhost:3003/remoteEntry.js", ...],
                  // нужно, чтобы скрипты загрузились до app_host
                  append: false,
                  publicPath: false,
                }),
              
            

Какой способ использовать?

  • Сам добавлю скрипты
  • Module federation добавляет скрипты

Ошибки

Не загрузился микрофронтенд?


                react-dom.production.min.js:216 Error: Container missing
                while loading "./page" from 6448
                  at n (remotes loading:39)
                  at remotes loading:60
                  at u (remotes loading:54)
                  at remotes loading:68
                  at Array.forEach ()
                  at Object.f.f.remotes (remotes loading:31)
                  at ensure chunk:6
                  at Array.reduce ()
                  at Function.f.e (ensure chunk:5)
                  at index.tsx:8
              
              
                optimization: {
                  splitChunks: {
                    cacheGroups: {
                      react: {
                        test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
                        name: "react",
                        chunks: "all",
                      },
                      commons: {
                        test: /[\\/]node_modules[\\/]/,
                        name: "vendors",
                        chunks: "all",
                        priority: -20,
                      },
                    },
                  },
                }
              
            

Где еще могут быть ошибки?

watch/webpack.config.ts

                
                  output: {
                    path: path.resolve(process.cwd(), "dist"),
                    filename: "[name].[contenthash].js",
                    publicPath: "http://localhost:3002/",
                  },
                
              

app-host/webpack.config.ts

                
                  plugins: [
                    new ModuleFederationPlugin({
                      name: "app_host",
                      filename: "remoteEntry.js",
                      library: { type: "var", name: "app_host" },
                      remotes: {
                        watch: "watch@http://localhost:3003/remoteEntry.js",
                      }
                    })
                  ]
                
              

Наименование в remotes

              
                remotes: {
                  "mf-app": "mf-app@http://localhost:3003/remoteEntry.js",
                  "mf-app": "mf_app@http://localhost:3003/remoteEntry.js",
                  "mf_app": "mf_app@http://localhost:3003/remoteEntry.js",
                  "mfApp": "mfApp@@http://localhost:3003/remoteEntry.js"
                }
              
            

Как использовать компоненты?

watch/webpack.config.ts

              
                new ModuleFederationPlugin({
                  name: "watch",
                  filename: "remoteEntry.js",
                  library: { type: "var", name: "watch" },
                  exposes: {
                    "./page": "./src/components/page",
                  }
                })
              
            

watch/webpack.config.ts

              
                output: {
                  path: path.resolve(process.cwd(), "dist"),
                  filename: "[name].[contenthash].js",
                  publicPath: "http://localhost:3002/",
                },
              
            

app-host/webpack.config.ts

              
                new ModuleFederationPlugin({
                  name: "app_host",
                  remotes: {
                    watch: "watch@http://localhost:3003/remoteEntry.js",
                  }
                })
              
            

app-host/app.tsx

              
                import React, { lazy, Suspense } from 'react'
                const WatchPage = lazy(() => import('watch/page'))
                ...
                render() {
                  return (
                    <Suspense fallback="Loading...">
                      <WatchPage />
                    </Suspense>
                  )
                }
              
            

Но как анимировать?

  • Экспоузить уже с анимаций
  • Написать обертку для React.lazy

Обертка для React.lazy

              
                /**
                 * @param factory – () => import('microfront/component')
                 */
                export function preloadLazy(factory) {
                  const Component = lazy(factory)
                  Component.preload = factory
                  return Component
                }
              
            

Используем обертку

              
                const WatchPage = preloadLazy(() => import('watch/page'))

                const App = () => {
                  const [animate, setAnimate] = useState(false)
                  useLayoutEffect(() => {
                    WatchPage.preload().then(() => setAnimate(true))
                  }, [])

                  return (
                    <Suspense fallback="...">
                      <Animation in={animate}>
                        <WatchPage />
                      </Animation>
                    </Suspense>
                  )
                }
              
            

Анимируем переходы между страницами

  • Находим компонент
  • Микрофронт => подгружаем
  • Микрофронт => Suspense
  • Скачался => анимируем
  • Навигация => сбрасываем состояние

Я не хочу скачивать react дважды


              new ModuleFederationPlugin({
                ...
                shared: {
                  react: {
                    eager: true, # используем общий модуль при начальной загрузке
                    singleton: true, # используем только один экземпляр
                    requiredVersion: dependencies.react,
                  }
                }
              })
            

Хочу пошарить context

Создаем контекст shared/contexts/index.ts

              
                import { createContext, useContext } from 'react'

                export const UserContext = createContext({ id: '', name: '' })
                export const useUser = () => useContext(UserContext)
              
            

Добавляем в app-host/webpack.config.ts

              
                shared: {
                  '@contexts': {
                    singleton: true,
                    import: path.resolve('./shared/contexts')
                  }
                }
              
            

Добавляем React дерево

              
                import { UserContext } from '@contexts'
                ...
                <UserContext.Provider value={{ id: '1234', name: 'Viktor' }}>
                  <WatchPage />
                </UserContext.Provider>
              
            

Используем в микрофронтенде

              
                import { useUser } from '@contexts'

                export const WatchPage = () => {
                  const user = useUser()
                  return <div>{user.name}</div>
                }
              
            

Деплоим

Как обычно деплоят?

Что еще нужно учитывать?

  • Какая у вас инфраструктура?
  • Нужна ли доступность везде?
  • Большая ли нагрузка?

Как деплоим мы?

Доступность везде? => CDN нам не нужен
Где храним статику? => Облачное хранилище
CI/CD => Docker
Webpack dev server => Nginx
Оркестрация => Kubernetes

Облачное хранилище

Nginx

Docker

Kubernetes

Инфраструктура

У нас свой cloud storage

А у вас?

Как заливаем файлы?

  • Заливаем только нужные бандлы
  • Файлы сохраняются application/octet-stream
  • Нет multiple upload

Как храним файлы?

Без версий

              
                /bucket
                ├── app-host
                │   ├── 888.330d58fad93b1eb0dfa5.js
                │   ├── app.d4c4be8e2eb88907410a.js
                │   ├── index.html
                │   └── react.39acefad56af0f11d471.js
                └── watch
                    ├── 888.3b49de53a0194486d9db.js
                    ├── 938.85ead56334c64eec9261.js
                    ├── app.c47cc95c56185a4b15b4.js
                    ├── index.html
                    ├── react.0ee026e74f05c2e958a1.js
                    └── remoteEntry.js
              
            

С версиями

              
                /bucket
                ├── app-host
                │   ├── 2.0.0
                │   │   ├── index.html
                │   │   ├── 888.3b49de53a0194486d9db.js
                │   └── 2.0.1
                │       ├── index.html
                │       ├── 938.85ead56334c64eec9261.js
                └── watch
                    ├── 2.0.0
                    │   ├── index.html
                    │   ├── 938.85ead56334c64eec9261.js
                    └── 2.0.1
                        ├── index.html
                        ├── 938.85ead56334c64eec9261.js
                        └── remoteEntry.js
              
            

Не умеем в public bucket

Свой CDN

С pre-sign url

Custom Nginx

  • ngx_devel_kit
  • set-misc
  • lua-nginx-module

А как их добавить?

  • Собрать из исходников
  • Скачать из dockerhub

https://hub.docker.com/r/openresty/openresty/

Как должно работать?

  • API
  • Bundles
  • Routing

Как выглядит nginx.conf?

API

              
                server {
                  listen 3001;
                  ...
                  location = /graphql {
                    proxy_pass 'http://${BACKEND_URL}:8000/grahpql';
                  }
                }
              
            

Bundles

              
                server {
                  listen 3001;
                  ...
                  location / {
                    try_url $uri @proxy;
                  }
                }
                server {
                  listen 3002;
                  ...
                }
                server {
                  listen 3003;
                  ...
                }
              
            

Bundles

              
                location @proxy {
                  set $bucket ${BUCKET};
                  set $key $1;
                  set $signature '';
                  set $access_key ${ACCESS_KEY};
                  set $secret_key ${SECRET_KEY};

                  set_by_lua 'return ngx.cookie_time(ngx.time())';
                  set $string_to_sign
                    '$request_method\n\n\n\nx-amz-date:$now\n/$bucket/$key';
                  set_hmac_sha1 $signature $secret_key $string_to_sign;
                  set_encode_base64 $access_key $signature;

                  error_page 404 = @index_proxy;
                }
              
            

Routing

              
                location @index_proxy {
                  set $bucket ${BUCKET};
                  set $key 'app-host/index.html';
                  set $signature '';
                  set $access_key ${ACCESS_KEY};
                  set $secret_key ${SECRET_KEY};

                  set_by_lua 'return ngx.cookie_time(ngx.time())';
                  set $string_to_sign
                    '$request_method\n\n\n\nx-amz-date:$now\n/$bucket/$key';
                  set_hmac_sha1 $signature $secret_key $string_to_sign;
                  set_encode_base64 $access_key $signature;
                }
              
            

А как обстоят дела с env переменными?

На входе nginx.conf.template

              
                server {
                  listen 3001;
                  ...
                  location = /graphql {
                    proxy_pass 'http://${BACKEND_URL}:8000/grahpql';
                  }
                }
              
            
                
                  envsubtr '${BACKEND_URL}' < nginx.conf.template \
                    > /etc/nginx/conf.d/default.conf
                
              

И тут врывается Kubernetes

На выходе /etc/nginx/conf.d/default.conf

              
                server {
                  listen 3001;
                  ...
                  location = /graphql {
                    proxy_pass 'http://backend-app:8000/grahpql';
                  }
                }
              
            

Хардкодим ip...

На выходе /etc/nginx/conf.d/default.conf

              
                server {
                  listen 3001;
                  ...
                  location = /graphql {
                    proxy_pass 'http://158.134.171.13:8000/grahpql';
                  }
                }
              
            

Снова врывается инфраструктура

              
                server {
                  listen 3001;
                  ...
                  location / {
                    try_url $uri @proxy;
                  }
                }
                server {
                  listen 3002;
                  ...
                }
                server {
                  listen 3003;
                  ...
                }
              
            

Новый nginx.conf.template

              
                server {
                  listen 3001;
                  ...
                  location ~ ^/bundles/(.*) {
                    try_url $uri @proxy;
                  }
                }
              
            

watch/webpack.config.prod.ts

              
                output: {
                  path: path.resolve(process.cwd(), "dist"),
                  filename: "[name].[contenthash].js",
                  publicPath: "http://your-domain.com/bundles/watch"
                }
              
            

app-host/webpack.config.prod.ts

              
                remotes: {
                  watch:
                    "watch@https://domain.com/bundles/watch/remoteEntry.js"
                }
              
            

Бонус

Версионирование

              
                /bucket
                ├── app-host
                │   ├── 2.0.0
                │   │   ├── index.html
                │   │   ├── 888.3b49de53a0194486d9db.js
                │   └── 2.0.1
                │       ├── index.html
                │       ├── 938.85ead56334c64eec9261.js
                └── watch
                    ├── 2.0.0
                    │   ├── index.html
                    │   ├── 938.85ead56334c64eec9261.js
                    └── 2.0.1
                        ├── index.html
                        ├── 938.85ead56334c64eec9261.js
                        └── remoteEntry.js
              
            
              
                remotes: {
                  watch: `promise new Promise(resolve => {
                    ...
                    resolve({
                      get: (request) => window.watch.get(request),
                      init: (arg) => {
                        try {
                          return window.watch.init(arg)
                        } catch(e) {
                          console.log('remote container already initialized')
                        }
                      }
                    })
                  })
                },
              
            

Вместо ... могло быть

              
                fetch('http://version-api.com?mf=watch')
                  .then(watchVersion => {
                    const url = "http://domain.com/bundles/"
                      + watchVersion + "/remoteEntry.js";
                    const script = document.createElement('script');
                    script.src = url;
                    script.defer = true;
                    script.onload = () => {
                      resolve(...)
                    }
                    document.head.appendChild(script);
                  });
              
            

А еще CI/CD

Разбор полетов

Как проходит запрос?

Как деплоится?

Как фронт понимает, что что-то обновилось?

Как кэшировать?

Выводы

  • Быстрая и независимая разработка/деплой
  • Снизились ресурсы на разработку UI
  • Просто держать единый стиль интерфейса
  • В микрофронтендах ещё много неизведанного
  • Усложнилась архитектура
  • Всегда закладывайте время на неопределённости

Вадим Малютин

телеграм: t.me/mfpjke

чатик: t.me/microfrontends

сайт: mltn.dev

Q&A