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


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

Каким образом можно эффективно создать эту возможность самостоятельно и что нужно учитывать, чтобы получить отличный результат?

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

Прочная основа

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

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

Для начала стоит определить точки взаимодействия. Имейте в виду, что нет необходимости подвергать сомнению установленные варианты. Например, если приложение уже использует React повсеместно, значит спокойно используйте для маршрутизации пакет router-router.

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

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

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

В конечном счете этот подход также решает проблему MxN.

Решение проблемы MxN

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

Предположим, у нас есть M языков программирования и N типов машин. Сколько компиляторов нам нужно написать? Очевидно, ответ будет MxN. Кажется, что все довольно просто. Однако математическая часть далеко не самая сложная. Проблема заключается в сохранении масштабирования при добавлении новых типов машин и новых языков программирования.

Например, взяв 4 языка и 3 машинные архитектуры, мы получим 12 ребер (MxN).

Проблема MxN

Решить эту проблема очень просто: нужно ввести промежуточный язык (или промежуточное представление). Таким образом, все M языков программирования компилируются в один промежуточный язык, который затем компилируется в целевую архитектуру. Вместо того, чтобы масштабировать MxN, мы получаем M+N. Добавление новой выходной архитектуры так же просто, как добавление компиляции из промежуточного языка в новую архитектуру.

Посмотрим, как меняется пример диаграммы при добавлении промежуточного представления (intermediate representation — IR). Теперь получаем только 7 ребер (M+N).

Решение проблемы MxN

То же самое можно выполнить и для поддержки IDE. Вместо поддержки M языков программирования для N IDE мы используем единый стандарт языковой поддержки (так называемый Language Server Protocol — LSP).

Это и есть секретный ингридиент, благодаря которому команда TypeScript (как и другие) может поддерживать VS Code, Sublime, Atom и многие другие редакторы. Они просто обновляют реализацию LSP, а остальное происходит само собой. Поддержка новой IDE так же проста, как написание плагина LSP для соответствующей IDE.

Как эта информация может помочь при использовании компонентов между фреймворками? Если у нас есть M фреймворков, то для обмена компонентами между N из них снова получаем MxN. Решения этой задачи можно достичь с помощью опыта из примеров выше. Нам нужно найти подходящее «промежуточное представление».

На следующей диаграмме показан пример для 3 фреймворков. Промежуточное представление позволяет конвертировать в различные фреймворки и из них. Всего у нас 6 ребер (2N).

Решение задачи MxN для обмена между фреймворками

А если мы возьмем один из фреймворков в качестве промежуточного представления, то получим 4 ребра (2N — 2), сэкономив два конвертера и улучшив производительность в случае, когда для большинства компонентов используется один определенный фреймворк.

В Piral мы выбрали React в качестве промежуточного решения. Для этого были веские причины:

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

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

Простой враппер

Нам нужен четко определенный жизненный цикл компонентов. Полный жизненный цикл можно указать через интерфейс ComponentLifecycle, как показано ниже:

interface ComponentLifecycle<TProps> { /** * Вызывается при установке компонента. * @param element - контейнер, содержащий элемент. * @param props - свойства, которые нужно перенести. * @param ctx - связанный контекст. */ mount(element: HTMLElement, props: TProps, ctx: ComponentContext): void; /** * Вызывается, когда нужно обновить компонент. * @param element - контейнер, содержащий элемент. * @param props - свойства, которые нужно перенести. * @param ctx - связанный контекст. */ update?(element: HTMLElement, props: TProps, ctx: ComponentContext): void; /** * Вызывается при отключении компонента. * @param element - контейнер, в котором находился элемент. */ unmount?(element: HTMLElement): void; }

Один этот жизненный цикл не имеет большого смысла. Его нужно подключить к компоненту (в данном случае к компоненту React), чтобы он монтировался в дереве рендеринга.

Определим простую функцию для создания функционального компонента React, оборачивающего жизненный цикл:

function wrap<T>(component: ComponentLifecycle<T>): React.ComponentType<T> { return (props: T) => { const { createPortal, destroyPortal } = useGlobalActions(); const [id] = React.useState(createPortal); const router = React.useContext(__RouterContext); React.useEffect(() => { return () => destroyPortal(id); }, []); return ( <ErrorBoundary> <PortalRenderer id={id} /> <ComponentContainer innerProps={{ ...props }} $portalId={id} $component={component} $context={{ router }} /> </ErrorBoundary> ); }; }

Кроме того, можно ввести значения, переносимые контекстом, такие как контекст маршрутизатора (содержащий, помимо прочего, historylocation и другие элементы).

Что такое createPortal и destroyPortal? Это глобальные действия, с помощью которых можно регистрировать или удалять запись в портале. Портал использует дочерний элемент ReactPortal, чтобы проецировать элемент из дерева рендеринга React в другое место в дереве DOM. Следующая диаграмма иллюстрирует этот процесс:

Проекция рендеринга

Это мощный способ, который работает даже в теневом DOM. Таким образом, промежуточное представление может использоваться (то есть проецироваться) где угодно, например в узле, который визуализируется другим фреймворком, таким как Vue.

Обработкой ошибок занимается граница ошибок — особо ничем не примечательный компонент, поэтому давайте перейдем сразу к PortalRenderer и ComponentContainer.

PortalRenderer максимально прост. В конце концов, все сводится к тому, чтобы получить ReactPortal и визуализировать его. Поскольку эти порталы должны быть распространены глобально, мы можем перейти к хранилищу, чтобы извлечь их:

const PortalRenderer: React.FC<PortalRendererProps> = ({ id }) => { const children = useGlobalState(m => m.portals[id]); return <>{children}</>; };

Теперь все движение происходит в ComponentContainer. Для расширенного доступа к полному жизненному циклу React используем класс Component.

class ComponentContainer<T> extends React.Component<ComponentContainerProps<T>> { private current?: HTMLElement; private previous?: HTMLElement; componentDidMount() { const node = this.current; const { $component, $context, innerProps } = this.props; const { mount } = $component; if (node && isfunc(mount)) { mount(node, innerProps, $context); } this.previous = node; } componentDidUpdate() { const { current, previous } = this; const { $component, $context, innerProps } = this.props; const { update } = $component; if (current !== previous) { previous && this.componentWillUnmount(); current && this.componentDidMount(); } else if (isfunc(update)) { update(current, innerProps, $context); } } componentWillUnmount() { const node = this.previous; const { $component } = this.props; const { unmount } = $component; if (node && isfunc(unmount)) { unmount(node); } this.previous = undefined; } render() { const { $portalId } = this.props; return ( <div data-portal-id={$portalId} ref={node => { this.current = node; }} /> ); } }

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

Итак, рассмотрим три важнейшие части, которые связаны с жизненным циклом:

  • componentDidMount отвечает за монтирование и использует захваченный узел DOM;
  • componentDidUpdate выполняет либо повторное монтирование (если узел DOM изменился), либо легкую операцию обновления;
  • componentWillUnmount отвечает за отсоединение.
  • Атрибут data-portal-id нужен для дальнейшего нахождения хост-узла при использовании ReactPortal.

    Представьте, что мы находимся в дереве, контролируемом таким фреймворком, как Vue, и хотим визуализировать компонент из другого фреймворка. В этом случае нам понадобится промежуточное представление, которое, как уже было определено, также является компонентом React.

    Монтирование этого компонента React в дереве Vue работает через DOM, но будет отображаться через портал. Таким образом мы синхронизируемся с обычным деревом рендеринга React и получаем все преимущества.

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

    Код будет выглядеть следующим образом:

    function findPortalId(element: HTMLElement | ShadowRoot) { const portalId = 'data-portal-id'; let parent: Node = element; while (parent) { if (parent instanceof Element && parent.hasAttribute(portalId)) { const id = parent.getAttribute(portalId); return id; } parent = parent.parentNode || (parent as ShadowRoot).host; } return undefined; }

    Этот код также можно использовать в теневом DOM, что разумно, если мы также применяем веб-компоненты.

    Пример

    Теперь посмотрим, как этот процесс может выглядеть в приложении.

    Допустим, мы определили компонент React для подключения к глобальному состоянию и отображения значения из счетчика.

    const tileStyle: React.CSSProperties = { fontWeight: 'bold', fontSize: '0.8em', textAlign: 'center', color: 'blue', marginTop: '1em', }; export const ReactCounter = () => { const count = useGlobalState(m => m.count); return <div style={tileStyle}>From React: {count}</div>; };

    Теперь на него можно ссылаться в другом компоненте. Например, в компоненте Svelte мы можем использовать пользовательский компонент, такой как показан в следующем коде:

    <script> export let columns; export let rows; export let count = 0; </script> <style> h1 { text-align: center; } </style> <div class="tile"> <h3>Svelte: {count}</h3> <p> {rows} rows and {columns} columns <svelte-extension name="ReactCounter"></svelte-extension> </p> <button on:click='{() => count += 1}'>Increment</button> <button on:click='{() => count -= 1}'>Decrement</button> </div>

    Имейте в виду, что svelte-extension (в этом примере) — это путь для получения доступа к конвертеру, идущий от промежуточного представления (React) к Svelte.

    Использование этого простого примера в действии выглядит согласно ожиданиям:

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

    Как определить здесь конвертеры? Особую сложность может представлять соединение с пользовательским элементом. Для этого мы используем событие (под названием render-html), которое запускается после подключения веб-компонента.

    const svelteConverter = ({ Component }) => { let instance = undefined; return { mount(parent, data, ctx) { parent.addEventListener('render-html', renderCallback, false); instance = new Component({ target: parent, props: { ...ctx, ...data, }, }); }, update(_, data) { Object.keys(data).forEach(key => { instance[key] = data[key]; }); }, unmount(el) { instance.$destroy(); instance = undefined; el.innerHTML = ''; }, }; };

    Кроме того, Svelte значительно все упрощает. Создание нового экземпляра компонента Svelte фактически прикрепляет его к заданной цели (target).

    Заключение

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

    Используя компонент-агрегатор, можно эффективно разделить производителей и потребителей. Чтобы охватить все случаи, будет достаточно одного двунаправленного конвертора для каждого фреймворка. Этот подход допускает множество вариантов использования от независящей от фреймворков разработки до быстрого прототипирования или экспериментов с новыми технологиями.


    Перевод статьи Florian Rappl: Cross-Framework Components


    Поделиться статьей:


    Вернуться к статьям

    Комментарии

      Ничего не найдено.