16 June

Web Apps: Micro Frontend фреймворк с поддержкой Module Federation

Website development

Хочу представить фреймворк для написания микрофронтеднов с поддержкой Webpack Module Federation. Фреймворк позволяет связывать приложения написанные на любых библиотеках, ванильном JS, и даже IFrame, если дела совсем плохи.


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


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


Проблема усугубляется если команда хочет переходить на новый стек/фреймворк. Мы пытались плавно перейти с одного фреймворка на другой, заменив один монолит другим. Весь период перехода приходилось поддерживать оба продукта, и когда мы, наконец, закончили финальный монолит оказался совершенно огромным. В какой-то момент была высказана идея начать дробить его на под-приложения, но каждое из них разрабатывалось разными командами, имело разный интерфейс общения с хост-приложением, а некоторые под-приложения вообще были IFrame, т.к. нужна была максимальная изоляция. Такой подход, не смотря на минусы зоопарка, проработал какое-то время и был весьма успешен. Но на долгую перспективу он не работал.


Как включить приложение написанное на одном фреймворке в другое приложение на другом фреймворке? **Нужен абстрактный оркестратор!**

Прежде чем мы перейдем непосредственно к фреймворку, обозначим базовые термины:


image


  • Хост — это приложение которое содержит в себе более мелкие приложения
  • Под-приложение (или просто Приложение) — это IFrame, Глобальное Приложение или Приложение-Веб Компонент, которые включаются в хост, они в свою очередь могут быть хостами для более вложенных приложений
  • Оркестратор: Web Apps Framework — агент, позволяющий хосту общаться с приложениями, загружать их и тд
  • Приложения не говорят друг с другом напрямую, но хост может их соединить

Web Apps Framework


Web Apps framework это фреймворко-независимый способ внедрить любое приложение в любое приложение, с обобщенным коммуникационным интерфейсом и другими бонусами.


Репозиторий и демо.


Рассмотрим скриншот из демо:



Стрелки указывают на разные приложения, которые бесшовно связаны, но при этом могут быть отдельно разработаны и задеплоены. Два из них даже IFrame, что может быть полезно, если нужно внедрить лютый легаси-код, который вообще может быть написан без JS, или приложение которому нужна максимальная изоляция стилей и кода.


Само собой, существуют и другие фреймворки, делающие то же самое, но Web Apps отличается особым минимализмом по части оберток и загрузчиков, и предоставляет унифицированный интерфейс для всех фреймворков: Angular, Vue, React, jQuery, без JS вообще, и т.д.


Есть два способа вставить приложение, как веб-компонент, и более оптимизированная версия для React. Все что нужно — указать URL и вуаля, приложение будет загружено и вставлено в DOM хоста, а все обработчики событий автомагически будут созданы:


import {useApplication, eventType, useListenerEffect, dispatchEvent} from '@ringcentral/web-apps-host-react';

const Page = () => {
    const {Component, node} = useApplication({
        id: 'xxx',
        type: 'script',
        url: [ 
           'http://example.com/styles.css', // стили тоже можно указывать
           'http://example.com/bundle.js',
           'http://example.com/entry.js'
        ]
    });
    useListenerEffect(node, eventType.message, (message) => {
        alert(`message from app: ${message}`);
    });
    return <>
        <Component someProp="someValue" />
        <button onClick={e => {
            dispatchEvent(node, eventType.message, {foo: 'bar'})
        }>Send Message</button>
    </>;
};

Коммуникация может быть через события или через props (они же HTML attributes, в зависимости от способа подключения). События передаются в любой тип приложений, включая IFrame, через одинаковый интерфейс, так что хост может подменять приложения в любой момент, без необходимости знать тип. Это пригодится если вам надо загружать приложение для главной контентной области в зависимости от URL, откуда может быть взять ID приложения (в демо сделано именно так).


Вот пример как внедрять приложение в хосты, которые не написаны на React:


// где-то в коде хоста
import '@ringcentral/web-apps-host-web-component';
// и просто добавить это в HTML/DOM
<web-app id='appId' url='["http://example.com"]' type="iframe" />

Как написать приложение?


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


Глобальное приложение — просто скрипт который получает DOM Node и вставляет в него приложение
Приложение-веб компонент — приложение загружается как скипт и создает Custom Element, в который завернуто все остальное, позволяет добиться более хорошей изоляции от хоста благодаря Shadow DOM и Shadow CSS
Приложения IFrame Apps — для дополнительной изоляции и безопасности, когда хост не может полностью доверять приложению и показывать его в той же среде, что и он сам


Глобальные приложения


Допустим мы хотим внедрить приложение на React. С поддержкой Module Federation, точка входа будет выглядеть как просто default export функции, принимающей DOM Node и рисующей в ней приложение:


import React from "react";
import {render, unmountComponentAtNode} from "react-dom";
import {registerAppCallback} from "@ringcentral/web-apps-react";
import App from "./App";

export default (node) => {
    ReactDOM.render(<App foo={node.getAttribute('foo')} />, node);
    return () => ReactDOM.unmountComponentAtNode(node);
});

И все.


Мы плотно работали с авторами Module Federation (спасибо Zack Jackson) чтобы интеграция была максимально легкой.


Можно добавить MutationObserver, тогда можно пробросить изменения динамически в Реакт приложение в качестве props.


Если на хосте нет поддержки Module Federation, не беда, есть выход через простой JSONP-вызов:


import React from "react";
import {render, unmountComponentAtNode} from "react-dom";
import {registerAppCallback} from "@ringcentral/web-apps-react";
import App from "./App";

registerAppCallback('%appId%', (node) => {
    ReactDOM.render(<App foo={node.getAttribute('foo')} />, node);
    return () => ReactDOM.unmountComponentAtNode(node);
});

Приложения на Web Component


Другой способ внедрить приложение — завернуть его в Web Component для лучшей изоляции CSS стилей посредством Shadow DOM и Shadow CSS:


import React from "react";
import {render, unmountComponentAtNode} from "react-dom";
import {App} from './app';

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

template.innerHTML = `
    <style>/* shadow CSS */</style>
    <div class="container"></div>
`;

customElements.define('web-app-%appId%', class extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({mode: 'open'});
        this.shadowRoot.appendChild(
            document.importNode(template.content, true)
        );
        this.mount = this.shadowRoot.querySelector('.container');
    }

    connectedCallback() { render(
        <App 
            authtoken={this.getAttribute('authtoken')}
            node={this}
        />, 
        this.mount
    ) }

    disconnectedCallback() { unmountComponentAtNode(this) }    
});

Поскольку на изменения attributes можно подписаться, их можно пробросить в Реакт приложение.


Приложения IFrame


Для использования IFrame не нужно вообще никакого кода в самом простом случае. Но если вы хотите коммуницировать, то можно добавить следующее:


import {
    IFrameSync, dispatchEvent, eventType
} from '@ringcentral/web-apps-sync-iframe';

export const sync = new IFrameSync({
    history: 'html5',
    id: 'iframe',
    origin: window.location,
});

// теперь можно слушать и посылать события
const node = sync.getEventTarget();
dispatchEvent(node, eventType.message, 'Hello from IFrame');
node.addEventListener(eventType.message, message => alert(message));

А что насчет общих библиотек?


Очевидно, как только у каждого приложения появляется свой билд-процесс, то все общие зависимости вроде react or react-dom начинают дублироваться. Иногда на это можно закрыть глаза, но иногда это проблема. Можно, конечно, обойтись externals, и другими трюками, но, к счастью, есть и более хорошее решение: Webpack 5 Module Federation.


Фреймворк Web Apps поддерживает Module Federation из коробки, и позволяет иметь общие зависимости между хостом и приложеиями, равно как и между приложениями (чего невозможно добиться стандартными костылями). Фреймворк и так довольно минималистичен, а федерация позволяет оптимизировать все еще больше. Module Federation также умеет автоматически догружать зависимости если хост не может их предоставить, грузить нужные версии. Настраивается все абсолютно стандартно:


// на хосте в webpack.config.js
module.exports = {
    ..., // all the usual stuff
    plugins: [
        new ModuleFederationPlugin({
            name: 'web-app-host',
            library: {type: 'var', name: 'web-app-host'},
            shared: ['react', 'react-dom'],
        }),
    ]
};

// в приложении в webpack.config.js
module.exports = {
    ..., // all the usual stuff
    plugins: [
        new ModuleFederationPlugin({
            name: 'web_app_federated',
            library: {type: 'var', name: 'web_app_federated'},
            filename: 'remoteEntry.js',
            exposes: {
                './index': './src/index',
            },
            shared: {
                'react-dom': 'react-dom',
                moment: '^2.24.0',
                react: 'react',
            },
        }),
    ]
};

Вот и все, теперь хост и приложение могут иметь общие зависимости, чисто и прозрачно.


Еще немного об IFrame


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


Сообщения могут быть реализованы через PostMessage, но Web Apps упрощает и стандартизирует интерфейс, а также уберегает от "потопа" из широковещательных post messages.


А вот взаимодействие с попапами несколько хитрее.



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


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



Как можно увидеть, границы больше нет.


Также фреймворк умеет синхронизировать высоту фрейма и содержимое фрейма, чтобы длинные тексты отображались без подрезок.


Итого


Подытожим ключевые возможности Web Apps Framework:


  • Поддержка Module Federation
  • Синхронизация Location
  • Возможность "глубоких ссылок" — “приложение из приложения” или “приложение на хост” или “хост на приложение”
  • Унифицированный событийный интерфейс между хостом и приложениями
  • Поддержка подгонки размера IFrame под содержимое
  • Поддержка всплывающих окон в IFrame
  • Следование Веб стандартам
  • Написан на TypeScript
  • React и Web Component обертки для хоста
  • Бесконечная вложенность приложений — приложение может являться хостом для следующих приложения
  • Бесшовная навигация

Репозиторий с примерами и демо.


Благодарности


Zack Jackson, Chris Van Rensburg и Антону Бульёнову.

Tags:microfrontendsreactangularvuejsweb components
Hubs: Website development
0
1.4k 15
Comments 7