Привет, Хабр! Меня зовут Евгений Лабутин, я разработчик в МТС Digital. Сегодня я расскажу вам о своем рецепте приготовления микрофронтендов без использования каких либо фреймворков. Ведь такие фреймворки как Webpack Module Federation, Single-SPA, SystemJS и подобные вам просто не нужны для написания микрофронтендов, ровно так же как вам не нужен jQuery для написания современных фронтендов. Ведь все необходимое для разработки и работы Микрофронтендов уже встроено во все современные браузеры. Интересно? Добро пожаловать в статью.
Терминология
Сначала договоримся о терминологии — что такое микросервисы и что такое микрофронтенды. Ведь даже опытные разработчики часто не понимают разницы и подменяют одно понятие другим. Что же, давайте определимся:
микросервис — это небольшая независимая программа работающая на стороне сервера и выполняющая задачи в небольшой области ответственности. Мою прошлую статью про микросервисы можно почитать по ссылке;
микрофронтенд — это небольшой модуль встраиваемый в независимое фронтендовое приложение. Микрофронтенд никогда не работает как самостоятельно приложение, может встраиваться как в клиентскую часть приложения, так и в серверную часть. Встраивание происходит посредством динамического импорта. Вот об этих микрофронтендах — моя статья.
Именно такая характеристика как встраивание посредством динамического импорта отличает микрофронтенд от подключения внешней библиотеки и сборки монолита.
Где использовался микрофронтенд
Заранее прошу прощения за то, что выпускаю статью позже обещанного. Жизнь бурлит, происходит много всего интересного, не всегда могу добраться до Хабры. Но зато взамен я расскажу о применение сразу на двух проектах для решения разных задач.
Первый проект — МТС Твой бизнес. Там микрофронтенды использовались как инструмент масштабирования, встраивания логики оплаты в чужие приложения, встраивание логики в приложения на тильде и т.п.
Второй проект — SMS-рассылка, в нем микрофронтенды используются как инструмент миграции с legacy-кода из razor на современный стек на NextJS.
Микрофронтенд как инструмент масштабирования
Сначала давайте покажу как использовались микрофронтенды для масштабирования проекта МТС Твой бизнес. Наше приложение имело следующую архитектуру:
И микрофронтенды помогали решать следующие задачи:
Задача 1: Переиспользование общих компонентов между разными приложенииями.
У нас было несколько микросервисов и встал вопрос — как переиспользовать общие элементы, а не копировать их между микросервисами. Ответ очевиден: такие общие элементы как Шапка, Футер, Формочки, Виджеты вынести в отдельный проект и подключать к Микросервисам как Микрофронтенды.
Причем не все приложения написаны на Реакте. У нас также были приложения, написанные на Tilda. И там привычные инструменты встраивания через сборку просто не работают.
Задача 2: Встраивание элементов нашего кабинета в партнерские кабинеты
У нас было около 15 партнеров с которыми мы были интегрированы. В бизнес-сценарии был неприятный момент, при котором клиент должен перейти в наш кабинет, произвести оплату и вернуться в кабинет приложения.
Мы решили вынести функционал отображения состояния сервиса и функцию оплаты приложения в микрофронтенды и встроить их в партнерские кабинеты. Таким образом клиенту не надо было никуда переходиться для оплаты сервиса партнера.
Задача 3: Переиспользование инфраструктурной логики между микросервисами
Во всех наших микросервисах была логика, которую надо было копировать между микросервисами. Это такая логика как логирование, мониторинг, работа с профилем и авторизация и тому подобные. И нам очень не хотелось в случае какой-либо правки в этой логике копировать ее по микросервисами и пересобирать проекты.
К счастью, на проекте мы использовали Чистую Архитектуру. Она позволяет вынести отдельные элементы логики не только в отдельные слои, но и за пределы нашего приложения. Таким образом сервисы логирования, мониторинга, профиля и остальных были вынесены в микрофронтеды и подключались к микросервисам.
Требования к микрофронтендам
Исходя из тех задач, которые должны решать микрофронтенды, были сформированы следующие требования:
независимость от внешнего проекта. Наши микрофронтенды встраивались не только в проекты написанные на Реакте, но и в тильду, в партнерские продукты, написанные на совершенно разных технологиях. Поэтому на окружение хостового приложения надеяться не стоит. Так мы решили все необходимые зависимости — встраивать в Микрофронтенд, включая Реакт и библиотеки;
использование микрофреймворков и легковесных библиотек. В связи с тем, что в наш микрофронтенд входили все его зависимости, решение могло быть тяжеловесным и ухудшать опыт пользователя в приложениях, куда встраивается микрофронтенд. А для того, чтобы не вредить хостовым приложениям нашими микрофронтендами, мы решили отказаться от тяжеловесных фреймворков и библиотек. Поэтому вместо тяжеловесных React, Angular, Vue лучше взять их легковесные аналоги Preact, Svelte, SolidJS и тому подобные. В частности, мы использовали Preact, так как он позволяет переиспользовать опыт, полученный от разработки на React;
использовать модули. Микрофронтенд должен встраиваться в хостовое приложение посредством динамического импорта. Динамический импорт поддерживает уже 95% браузеров. И подключение посредствам динамического импорта гораздо удобнее, чем подключение посредством встраиваемого скрипта;
не использовать глобальные переменные. Глобальные переменные могут привести к конфликтам на хостовом приложении. Поэтому нельзя использовать ни глобальные JS переменные, ни глобальные CSS переменные. И если использование модулей решает проблемы с JS-переменными, то для CSS надо в принципе отказаться от CSS-переменных. И с этой задачей отлично помогает справиться подход CSS-in-JS и такие библиотеки как Styled Components;
независимость от внешних настроек стилей. Все мы используем CSS Reset чтобы стили во всех браузерах выглядели одинаково. Но мы используем разные CSS Reset. И не стоит надеяться что CSS Reset, настроенный в вашем проекте, работает так же, как CSS Reset, написанный в хостовом приложении. Поэтому микрофронтенды должны иметь свой собственный CSS Reset, который будет иметь область действия только в вашем микрофронтенде;
использовать авторизацию по OpenID. Наши микрофронтенды встраивались не только в домены mts.ru, но и в домены партнеров. А авторизация посредством кук на разных доменах не работает, так как куки работают только в рамках своих доменов. Для решения этой проблемы необходимо передавать авторизацию не посредством кук, а посредством заголовков или в теле запросов.
Создание микрофронтендов
После сбора требований для микрофронтендов мы начали их писать. Для написания микрофронтендов мы не использовали никакие фреймворки, ниже я объясню, почему. Вместо этого мы стали использовать нативные возможности JS для Микрофронтендов.
Для этого мы создаем JS-файл, который экспортирует наружу две функции render и clear.
Пример функции render:
import ReactDOM from "react-dom";
import {resolve} from "first-di";
import {App} from "./../../components/App";
import {Logger} from "./../../helpers/Logger";
const logger = resolve(Logger);
let memRootElement: HTMLElement | null = null;
export const render = (elem: HTMLElement, config?: Config) => {
try {
memRootElement = elem;
ReactDOM.render(<App config={config} />, elem);
} catch (error: unknown) {
logger.error("Error on draw widget", error);
}
};
Функция render отвечает за отрисовку микрофронтенда. Она принимает два параметра. Первый — это элемент, в который необходимо врендерить микрофронтенд. Второй это конфигурация с которой необходимо отрендерить микрофронтенд.
Клиент импортирует функцию render в свой проект и самостоятельно запускает отрисовку микрофронтенда в необходимый элемент и нужными параметрами. В функцию отрисовки уже встроен фреймворк для отрисовки микрофронтенда, поэтому хостовому приложению не надо заботиться о наличии необходимой версии фреймворка для отрисовки.
Пример функции clear:
export const clear = (): void => {
ReactDOM.render([], memRootElement);
};
Функция clear служит для зачистки хостового приложения от микрофронтенда. Зачистку необходимо осуществлять для предотвращения утечек памяти и листенеров. Ведь микрофронтенд вполне может вешать листенер на дом элементы или запускать таймеры, которые не убиваются простым удалением элемента из страницы.
Сборка Микрофронтенда
Теперь мы можем собрать наш микрофронтенд в JS-бандл. Для этого подходит практически любой инструмент сборки: rollup, webpack, vite, swcpack. Я приведу примеры нескольких из них.
Пример сборки на Rollup:
// Используемые мною плагины
import nodeResolve from "@rollup/plugin-node-resolve";
import commonJs from "@rollup/plugin-commonjs";
import postcss from 'rollup-plugin-postcss'
import replace from "@rollup/plugin-replace";
import swc from "@rollup/plugin-swc";
import terser from '@rollup/plugin-terser';
export default {
input: [
"src/microfront-first-main.tsx",
"src/microfront-second-main.tsx"
],
plugins: [...], // тут подключаются плагины
output: [
{
dir: "dist",
format: "esm",
entryFileNames: "[name].esm.min.js",
}
]
};
Это мой любимый инструмент сборки. Rollup в связке с Swc позволяет очень быстро собирать микрофронтенды и тестировать результат. Еще одна отличительная черта Rollup — возможность делать сборки одновременно для новых и старых браузеров. Для чего может понадобиться поддержка старых браузеров, расскажу ниже.
Пример сборки на Webpack:
module.exports = {
entry: {
microfrontFirst: "src/microfront-first-main.tsx",
microfrontSecond: "src/microfront-second-main.tsx"
},
plugins: [...],
output: {
filename: "[name].esm.min.js",
path: path.join(__dirname, "dist")
},
};
Как видите такой же простой конфиг что и у Rollup. Но настройка плагинов не такая простая как у Rollup.
Пример сборки на vite:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
emptyOutDir: true,
outDir: "dist",
sourcemap: true,
lib: {
entry: {
"first": "src/microfront-first-main.tsx",
"second": "src/microfront-second-main.tsx"
},
formats: ["es"],
},
},
});
Vite — очень простой сборщик, в котором из коробки уже все настроено, фактически это пресет для сборщика Rollup. Но у него есть существенный недостаток. Для транспиляции typescript в javascript используется быстрый транспиллер esbuild. И проблема в том, что esbuild поддерживает не все фичи typescript. И поэтому если вам нужны продвинутые возможности typescript, такие, как декораторы, рефлексия и подобные, — esbuild вам не подходит и его необходимо заменить на swc. А так как из коробки этого сделать нельзя, то проще вернуться к варианту Rollup + Swc.
Пример конфига swc-pack:
const { config } = require("@swc/core/spack");
module.exports = config({
entry: {
first: __dirname + "/src/microfront-first-main.tsx",
second: __dirname + "/src/microfront-second-main.tsx",
},
output: {
path: __dirname + "/dist",
},
module: {},
});
Хороший быстрый сборщик от авторов swc, настроенный из коробки. Но опять же, имеет существенный недостаток. В момент, когда я его тестировал, он не имел поддержки некоторых CommonJS-модулей. Возможно, этот функционал уже добавили и его стоит попробовать.
В итоге — каким бы сборщиком вы не воспользовались, вы получите сборку, состоящую из подключаемого js-файла и чанков к нему, которые содержат общий код для нескольких микрофронтендов.
Например, в наших микрофронтендах шапки, футера, формочек такой общий код, как фреймворк и библиотеки, были вынесены в отдельные чанки и переиспользовались несколькими микрофронтендами. Таким образом уменьшалось количество кода, подгружаемого вместе с микрофронтендами.
Публикация микрофронтендов
Микрофронтенды публикуем как статичные файлы в отдельном контейнере. Публикуем в интернете на отдельном каталоге внутри домена.
Сборка контейнера очень проста:
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json .npmrc ./
RUN npm ci
COPY . .
RUN npm run lint
RUN npm run build
FROM nginx:1.25-alpine as production
RUN rm -rvf /etc/nginx/conf.d/default.conf
RUN rm -rvf /usr/share/nginx/html/index.html
COPY --from=build /app/dist /usr/share/nginx/html
RUN chown nginx:nginx -R /usr/share/nginx/html
Таким образом когда разработка микрофронтенда завершена, разработчик сливает код в мастер-ветку, отрабатывают CI/CD процессы и новый микрофронтенд публикуется в интернете. Клиенты при запросе микрофронтенда автоматически получат новую версию микрофронтенда без необходимости пересборки хостового приложения.
Подключение микрофронтенда к хостовому приложению
Теперь, когда наш Микрофронтенд написан и опубликован, мы можем подключать его к хостовым приложению.
Подключение выглядит следующим образом:
const microElement = document.querySelector("#micro");
const path = "https://tb.mts.ru/micro/header.esm.min.js";
const widget = await import(path);
widget.render(microElement, {openId: ""});
Для импорта микрофронтенда мы используем нативный динамический импорт, который поддерживают уже 95% браузеров и фактически 100% из поддерживаемых браузеров. Импортируем нашу функции render и clear. И вызываем функцию render для отрисовки микрофронтенда в нужном элементе с нужными параметрами.
Такое подключение имеет ряд преимуществ:
это гораздо проще, чем подключать внешний скрипт и ожидать его загрузки;
такая подгрузка не создает глобальных переменных, так как все переменные в модулях;
вам не надо «ловить» момент прогрузки и инициализации скрипта;
такое подключение можно реализовать не в момент инициализации приложения, а в момент, когда микрофронтенд понадобился, например, по клику. Зачем продгружать форму оплаты если пользователь не нажал на кнопку оплаты?
такое подключение нативно для браузеров и работает везде, независимо от используемых инструментов разработки и сборки;
таким образом можно подключать микрофронтенды к Tilda и другим платформам;
для обновления микрофронтенда не надо пересобирать хостовое приложение. Достаточно опубликовать новую версию микрофронтенда и он появится на всех хостовых приложениях;
этот вид импорта работает не только в браузере, но в nodejs. Таким образом можно переиспользовать серверную логику;
такой микрофронтенд совместим с SSR. Вы можете отрендерить компонент в текст на стороне сервера и гидрировать его на стороне браузера.
Если же для сборки приложений вы используете Webpack, то для него необходимо сделать небольшой костыль:
const microElement = document.querySelector("#micro");
const webpackDynamicImport = new Function(
"return import('https://tb.mts.ru/micro/header.esm.min.js')"
);
const widget = await webpackDynamicImport();
widget.render(microElement, {openId: ""});
Дело в том, что Webpack имеет неправильное поведение и пытается динамический импорт встроить в сборку, что неправильно. Чтобы скрыть динамический импорт от сборщика необходимо спрятать динамический импорт в строке.
Если вам нужна поддержка браузеров, не поддерживающих динамический импорт, то в первую очередь вам стоит задуматься о том, нужна ли вам поддержка устаревших и не поддерживаемых браузеров. И если все же нужна, то решение есть:
const microElement = document.querySelector("#micro");
let widget = nul;
try {
new Function("(async(a=0)=>({...{a}},await import('')))()");
const path = "https://tb.mts.ru/micro/header.esm.min.js";
widget = await import(path);
} catch (err) {
widget = require("https://tb.mts.ru/micro/header.cjs.min.js");
}
widget.render(microElement, {openId: ""});
Здесь мы сначала проверяем, поддерживает ли браузер новые возможности, в том числе динамический импорт. Если поддерживает, то подгружаем версию для новых браузеров. Если все же нет, то подгружаем CommonJS версию микрофронтенда. И как я писал выше, Rollup позволяет делать одновременно сборку для новых и старых браузеров. Но, кроме самой сборки для старых браузеров, понадобится еще и полифил require для браузеров.
Такую версию подключения микрофронтендов мы использовали несколько лет назад, когда поддержка динамического импорта была только у 85% браузеров, но теперь мы полностью отказались от старых браузеров.
Что не так с Webpack Module Federation?
Распиаренная и долгожданная технология которая по факту оказалась устаревшей и ненужной. Ключевая проблема Module Federation заключается в том, что это не инструмент разработки микрофронтендов. Это инструмент сборки распределенного монолита.
В итоге Webpack Module Federation приносит следующие проблемы:
Vendor Lock. Вы замыкаете свою разработку на этот проприетарный инструмент. С большой долей вероятности вам понадобится встроить ваш микрофронтенд в продукт, где нет MF и тогда вы столкнетесь с серьезными проблемами;
вы не можете встроить такие микрофронтенды в клиентские приложения, где нет системы сборки MF. Попробуйте, например, встроить MF в Tilda или проект на jQuery.
При обновлении микрофронтенда в MF необходимо пересобирать хостовое приложение, что осложняет раскатывание новой версии микрофронтенда на множество хостовых приложений.
самое страшное что есть в MF — это общие зависимости. Проблема настолько серьезная, что способна убить всю вашу разработку. Для примера возьмем такую общую зависимость, как React. Разработчики React периодически выпускают новые версии, в которых есть ломающие изменения. И проблема MF в том, что он заставляет всех разработчиков микрофронтендов и хостовых приложений работать в единой версии реакта. Пока проект небольшой, вы можете договориться со всеми разработчиками на обновление реакта. А как только ваш проект разросся до десятка микрофронтендов и хостовых приложений — вы уже никогда не сможете договориться о единовременном обновлении версии реакта. Таким образом вы попадаете в дурацкую ситуацию, когда реакт настолько устарел, что работать уже нельзя, а обновить его вы не можете, потому что у всех все сломается.
Получается, что MF подходит только для случаев, когда вы производите сборку распределенного монолита.
И совершенно не подходит для случаев когда ваши микрофронтенды встраиваются во множество хостовых приложений.
Но, к счастью, нативный подход не имеет таких недостатков, у него открытый стандарт, поддерживаемый всеми браузерами. Он без проблем встраивается в любое количество хостовых приложений.
Частые ошибки в разработке микрофронтендов
Также я встречал много микрофронтендов от коллег и собрал популярные ошибки которые встретил у них:
импорт микрфоронтенда через script в head. Самая частая ошибка — это когда микрофронтенд подключается как внешний скрипт, который самостоятельно подгружается, инициализируется, создает глобальные переменные для вызова функционала. Дело в том, что функционал из данного скрипта может понадобиться еще до того, как скрипт прогрузился и инициализировался. Тогда вы словите ошибку. А отлавливание момента инициализации заставит вас написать несколько строчек лишнего кода. Динамический импорт — это одна простая строчка, которая вернет готовый скрипт. К тому же такие скрипты, как правило, собраны в монолитный бандл и не используют чанки, что раздувает размер скрипта;
скрипт сам начинает работать. Часто бывает что скрипт сам загрузился и начал что-то мониторить на странице. Такая тактика, как правило, не эффективна и потребляет клиентские ресурсы. Гораздо эффективнее будет подгрузить микрофронтенд в момент, когда он понадобился и запустить его исполнение. Например, форму оплаты не надо подгружать, когда клиент зашел на вашу страницу. Форму оплаты можно подгрузить динамическим импортом, когда пользователь нажал кнопку Купить;
не учитывается особенность SPA. Это вариация предыдущего пункта, когда скрипт загрузился, нашел необходимые элементы и подвязался к ним. После чего происходит переход между страницами SPA и возвращение на страницу, где находятся необходимые элементы. Естественно скрипт ничего не знает о таких переходах и изменениях в контенте страницы, а необходимые кнопки перестают работать. Динамический импорт и запуск в нужный момент решают эту проблему;
не освобождается память. Проблема также касается SPA-приложений, когда микрофронтенд отрендерился и происходит SPA-переход. При этом микрофронтенд не убивается, в нем остаются висеть таймауты, листенеры и прочий функционал приводящий к утечке памяти. Специально для решения таких проблем с микрофронтендами необходимо экспортировать функцию clear которая будет зачищать элемент от микрофронтенда.
Возвращаемся к первоначальным задачам и целям:
Микрофронтенды встроены в наши микросервисы и обновляются без необходимости пересборки хостовых приложений.
Микрофронтенды встраиваются в кабинеты партнеров, написанные на самых разных технологиях, и обновляются без необходимости пересборки партнерских кабинетов.
Микрофронтенды встраиваются в Тильду и обновляются без правки в Тильде.
Общая логика микросервисов вынесена в микрофронтенды и обновляется без необходимости пересборки хостовых приложений.
Все работает просто идеально. Причем нативные функции браузера справляются со своими задачами даже лучше, чем специализированные фреймворки для микрофронтендов.
Как ведется разработка микрофронтендов?
И вот, мы сделали микрофронтенды, настроили их сборку, публикацию, подключение. Теперь вопрос — а как же удобно разрабатывать микрофронтенды? Чтобы вести разработку и сразу же видеть результат, не дожидаясь сборки и обновления микрофронтенда на сервере.
Для этого мы сделали проект, содержащий Workspace, в котором располагается еще два проекта Components и Docs. В первом ведется непосредственно разработка микрофронтендов, из которых потом собирается статика для публикации. Во втором находится проект на NextJS, который одновременно выполняет функцию документации и тестовой площадки.
Таким образом разработчик в абстрактном окружении разрабатывает микрофронтенд, а сборка в реалтайме попадает в документацию и NextJS через HotReload и сразу отображается изменения. В результате разработчик одновременно делает документацию к компоненту и проверяет, как микрофронтенд работает. Этой же документацией пользуются дизайнеры для встраивания микрофронтендов в Тильду. А тестировщики проверяют функционал микрофронтендов перед их релизом.
Микрофронтенд как инструмент миграции с legacy
Как обещал — расскажу о том, как используются микрофронтенды на другом проекте. В частности, стоит задача перейти с legacy-технологий на современный фронтенд.
В качестве легаси выступает фронт, написанный на C# и Razor шаблонизаторе. В качестве целевого решения был выбран NextJS. А в задаче — требования совершить беспростойную миграцию с легаси на новый стек.
Решение задачи следующее. Рядом с легаси-проектом был создан новый проект на NextJS. Код шаблонов Razor постранично копируется в шаблоны React с попутной правкой синтаксиса Razor на React. Проект немаленький, за один спринт совершить переход нереально. Поэтому за один спринт переводим по одной странице.
Но вот вопрос — страница в старом проекте уже не работает, страница в новом проекте еще не работает. То есть сама страница работает, но окружение страницы еще не мигрировало, поэтому в новом пустом окружение страница работать не будет. Как минимум, сначала надо решить вопросы авторизации, роутинга и тому подобные проблемы. И тут на спасение приходят микрофронтенды.
Микрофронтенд позволяет собрать уже мигрированную страницу в подключаемый JS-файл и подключить к легаси-проекту на Razor. В качества инструмента сборки используется описанный выше Rollup + SWC, в качестве механизма подключения — динамический импорт, о котором я также говорил ранее.
В итоге Razor страница примет следующий вид:
@using WebApp.Helpers;
@{
Layout = "~/Views/Shared/_Grid_12.cshtml";
}
@section PageHead{
}
<div id="naming-template-page"></div>
<script type="text/javascript">
(async () => {
const { injectInRazor } = await import("/app-build/first-page.min.js");
injectInRazor("naming-template-page");
})()
</script>
Таким образом микрофронтенды позволяют нам, не останавливая основное производство, делать плавный переход на новый стек. Таким же подходом можно воспользоваться для миграции откуда угодно куда угодно. И, возможно, я им воспользуюсь в далеком будущем для миграции с древнего, тяжелого и тормозного Реакта на модный, стильный, молодежный фреймворк из будущего.
Спасибо за уделенное статье время!
Понравилась статья? Нажмите нравится и порекомендуй коллегам!
Остались вопросы или пожелания? С удовольствием пообщаюсь в комментариях к статье!
Нашли очепятку? Сообщите о ней в личку!