Cross-Framework Components

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Image for post
Проблема MxN

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

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

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

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

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

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

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

Image for post
Решение задачи 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. Следующая диаграмма иллюстрирует этот процесс:

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

Это мощный способ, который работает даже в теневом 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.

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

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

Как определить здесь конвертеры? Особую сложность может представлять соединение с пользовательским элементом. Для этого мы используем событие (под названием 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

Предыдущая статьяГрафы: основы теории, алгоритмы поиска
Следующая статьяВыбор оптимального алгоритма поиска в Python