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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

А если мы возьмем один из фреймворков в качестве промежуточного представления, то получим 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>
);
};
}

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

Что такое 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, поэтому потребуется заново монтировать базовый компонент с помощью заданных функций жизненного цикла.

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

  1. componentDidMount отвечает за монтирование и использует захваченный узел DOM;
  2. componentDidUpdate выполняет либо повторное монтирование (если узел DOM изменился), либо легкую операцию обновления;
  3. 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).

Заключение

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

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

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен

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

--

--