У меня есть проблема. Приложение написано на Angular, а библиотека компонентов на React. Делать клон библиотеки слишком дорого. Значит, нужно использовать React-компоненты в Angular-приложении с минимальными затратами. Разбираемся как это делать.
Дисклеймер
Я совсем не специалист в Angular. Пробовал первую версию в 2017, потом немного смотрел на AngularDart, и вот сейчас столкнулся с поддержкой приложения на современной версии фреймворка. Если вам кажется, что решения странные или "из другого мира", вам не кажется.
Решение представленное в статье еще не было обкатано в реальных проектах и представляет собой только концепт. Используйте его на свой страх и риск.
Проблема
Я сейчас поддерживаю и развиваю довольно большое приложение на Angular 8. Плюс, есть пара небольших приложений на React и планы построить еще десяток (тоже на React). Все приложения используются для внутренних нужд компании и должны быть в едином стиле. Единственный логичный путь — создать библиотеку компонентов, на основе которой можно быстро строить любые приложения.
Но в текущей ситуации нельзя просто взять и написать библиотеку на React. В самом большом приложении использовать её не получиться — оно написано на Angular. Я не первый, кто столкнулся с этой проблемой. Первое, что находиться в гугле по запросу "react in angular" — репозиторий Microsoft. К сожалению, в этом решении совсем нет документации. Плюс, в ридми проекта явно сказано, что пакет используется внутри Microsoft, у команды нет явных планов по его развитию и использовать его стоит только на свой страх и риск. Я не готов тащить такой неоднозначный пакет в продакшн, поэтому решил написать свой велосипед.

Официальный сайт @angular-react/core
Решение
React — это библиотека, предназначенная для решения одной конкретной задачи — управления DOM-деревом документа. Мы можем поместить любой React-элемент в произвольный DOM-узел.
const Hello = () => <p>Hello, React!</p>;
render(<Hello />, document.getElementById('#hello'));Причем, нет никаких ограничений на повторные вызовы функции render. То есть, можно каждый компонент отдельно рендерить в DOM. И это как раз то, что поможет сделать первый шаг в интеграции.
Подготовка
В первую очередь, важно понимать, что React по умолчанию не требует никаких особенных условий при сборке. Если не использовать JSX, а ограничиться вызовами createElement, то никаких шагов предпринимать не придется, все просто будет работать из коробки.
Но, конечно, мы привыкли использовать JSX и не хочется терять его. К счастью, по умолчанию Angular использует TypeScript, который умеет трансформировать JSX в вызовы функции. Нужно просто добавить флаг компилятора --jsx=react или в tsconfig.json в разделе compilerOptions дописать строку "jsx": "react".
Интеграция отображения
Для начала, нам нужно добиться, чтобы React-компоненты отображались внутри Angular-приложения. То есть, чтобы получившиейся в результате работы бибилотеки DOM-элементы занимали положенные места в дереве элементов.
Каждый раз при использовании React-компонента думать о корректном вызове функции render слишком сложно. Плюс, в будущем нам нужно будет интегрировать компоненты на уровне данных и обработчиков событий. В таком случае, имеет смысл создать Angular-компонент, который будет икапсулировать в себе всю логику создания и контроля React-элемента.
// Hello.tsx
export const Hello = () => <p>Hello, React!</p>;
// hello.component.ts
import { createElement } from 'react';
import { render } from 'react-dom';
import { Hello } from './Hello';
@Component({
  selector: 'app-hello',
  template: `<div #react></div>`,
})
export class HelloComponent implements OnInit {
  @ViewChild('react', { read: ElementRef, static: true }) ref: ElementRef;
  ngOnInit() {
    this.renderReactElement();
  }
  private renderReactElement() {
    const props = {};
    const reactElement = createElement(Hello, props);
    render(reactElement, this.ref.nativeElement);
  }
}Код Angular-компонента предельно прост. Он сам по себе рендерит только пустой контейнер и получает на него ссылку. При этом в момент инициализации вызывается рендер React-элемента. Он создается с помощью функции createElement и передается в функцию render, которая помещает его в DOM-узел созданный из Angular. Использовать такой компонент можно как любой другой Angulat-компонент, никаких специальных условий.
Интеграция входных данных
Обычно, при отображении элементов интерфейса нужно передавать в них данные. Тут все тоже достаточно прозаично — при вызове createElement можно передать в компонент любые данные через пропсы.
// Hello.tsx
export const Hello = ({ name }) => <p>Hello, {name}!</p>;
// hello.component.ts
import { createElement } from 'react';
import { render } from 'react-dom';
import { Hello } from './Hello';
@Component({
  selector: 'app-hello',
  template: `<div #react></div>`,
})
export class HelloComponent implements OnInit {
  @ViewChild('react', { read: ElementRef, static: true }) ref: ElementRef;
  @Input() name: string;
  ngOnInit() {
    this.renderReactElement();
  }
  private renderReactElement() {
    const props = {
      name: this.name,
    };
    const reactElement = createElement(Hello, props);
    render(reactElement, this.ref.nativeElement);
  }
}Теперь можно передать в Angular-компонент строку name, она попадет в React-компонент и будет отрендерена. Но если строка изменится по каким-то внешним причинам, React не узнает об этом и мы получим устаревшее отображение. В Angular есть метод жизненного цикла ngOnChanges, который позволяет отслеживать изменения параметров компонента и реакции на него. Реализуем интерфейс OnChanges и добавим метод:
// ...  
  ngOnChanges(_: SimpleChanges) {
    this.renderReactElement();
  }
// ...Достаточно просто повторно вызвать функцию render с элементом созданным из новых пропсов, и библиотека сама разберется какие части дерева следует перерендерить. Локальное состояние внутри компонента тоже сохранится.
После этих манипуляций Angular-компонент можно использовать привычным образом и передавать ему данные.
<app-hello name="Angular"></app-hello>
<app-hello [name]="name"></app-hello>Для более тонкой работы с обновлением компонентов можно посмотреть в сторону стратегии обнаружения изменений. Я не буду рассматривать это подробно.
Интеграция выходных данных
Остается еще одна проблема — реакция приложения на события внутри React-компонентов. Применим декоратор @Output и передадим коллбэк в компонент через пропсы.
// Hello.tsx
export const Hello = ({ name, onClick }) => (
  <div>
    <p>Hello, {name}!</p>
    <button onClick={onClick}>Say "hello"</button>
  </div>
);
// hello.component.ts
import { createElement } from 'react';
import { render } from 'react-dom';
import { Hello } from './Hello';
@Component({
  selector: 'app-hello',
  template: `<div #react></div>`,
})
export class HelloComponent implements OnInit {
  @ViewChild('react', { read: ElementRef, static: true }) ref: ElementRef;
  @Input() name: string;
  @Output() click = new EventEmitter<string>();
  ngOnInit() {
    this.renderReactElement();
  }
  private renderReactElement() {
    const props = {
      name: this.name,
      onClick: () => this.сlick.emit(`Hello, ${this.name}!`),
    };
    const reactElement = createElement(Hello, props);
    render(reactElement, this.ref.nativeElement);
  }
}Готово. При использовании компонента можно регистрировать обработчики событий и реагировать на них.
 <app-hello [name]="name" (click)="sayHello($event)"></app-hello>Получилась полнофункциональная обертка React-компонента для использования внутри Angular-приложения. В нее можно передавать данные и реагировать на события внутри нее.
One more thing...
Для меня, самое удобное в Angular — это Two-way Data Binding, ngModel. Это удобно, просто, требует совсем небольшого количества кода. Но в текущей реализации интеграции невозможно. Это можно исправить. Если честно, я не очень понимаю, как этот механизм работает с точки зрения внутреннего устройства. Поэтому, допускаю, что мое решение супер-неоптимально и буду рад, если вы напишите в комментариях более красивый способ поддержать ngModel.
В первую очередь, необходимо реализовать интерфейс ControlValueAccessor (из пакета @angular/forms и добавить новый провайдер в компонент.
import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms';
const REACT_VALUE_ACCESSOR: any = {
  provide: NG_VALUE_ACCESSOR,
  useExisting: forwardRef(() => HelloComponent),
  multi: true
};
@Component({
  selector: 'app-hello',
  template: `<div #react></div>`,
  providers: [PREACT_VALUE_ACCESSOR],
})
export class PreactComponent implements OnInit, OnChanges, ControlValueAccessor {
  // ...
}Этот интерфейс требует реализовать методы onBlur, writeValue, registerOnChange, registerOnTouched. Все они отлично описаны в документации. Реализуем их.
// Колбэк по умолчанию
const noop = () => {};
@Component({
  selector: 'app-hello',
  template: `<div #react></div>`,
  providers: [PREACT_VALUE_ACCESSOR],
})
export class PreactComponent implements OnInit, OnChanges, ControlValueAccessor {
  // ...
  private innerValue: string; // Поле для хранения текущего значения ngModel
  private onTouchedCallback: Callback = noop;
  private onChangeCallback: Callback = noop;
  // Снаружи доступ до значения будет происходить
  // только через эти геттер и сеттер
  get value(): string {
    return this.innerValue;
  }
  set value(v: string) {
    if (v !== this.innerValue) {
      this.innerValue = v;
      // При изменении значения снаружи, вызываем колбэк
      this.onChangeCallback(v);
    }
  }
  writeValue(value: string) {
    if (value !== this.innerValue) {
      this.innerValue = value;
      // Вызываем ререндер компонента при изменении значения извне
      this.renderReactElement();
    }
  }
  // Запоминаем колбэки
  registerOnChange(fn: Callback) {
    this.onChangeCallback = fn;
  }
  registerOnTouched(fn: Callback) {
    this.onTouchedCallback = fn;
  }
  // Реагируем на блюр
  onBlur() {
    this.onTouchedCallback();
  }
}После этого, необходимо обеспечить передачу всего этого в React-компонент. К сожалению, React не умеер работать с Two-way Data Binding, поэтому передадим ему значение и колбэк для его изменения. Модифицирем метод renderReactElement.
// ...
  private renderReactElement() {
    const props = {
      name: this.name,
      onClick: () => this.сlick.emit(`Hello, ${this.name}!`),
      model: {
        value: this.value,
        onChange: v => {
          this.value = v;
        }
      },
    };
   const reactElement = createElement(Hello, props);
   render(reactElement, this.ref.nativeElement);
  }
// ...И в React-компоненте воспользуемся этим значением и колбэком.
export const Hello = ({ name, onClick, model }) => (
    <div>
      <p>Hello, {name}!</p>
      <button onClick={onClick}>Say "hello"</button>
      <input
        value={model.value}
        onChange={e => model.onChange(e.target.value)}
      />
   </div>
  );Вот теперь, мы действительно интегрировали React в Angular. Использовать получившийся компонент можно как угодно.
<app-hello
  [name]="name"
  (click)="sayHello($event)"
  [(ngModel)]="name"
></app-hello>Итог
React — очень простая библиотека, которую легко интегрировать с чем угодно. При использовании показанного подхода можно не только использовать любые React-компоненты внутри Angular-приложений на постоянной основе, но и постепенно мигрировать все приложение.
В этой статья я совсем не касался вопросов стилизации. Если использовать классические CSS-in-JS решения (styled-components, emotion, JSS), никаких дополнительных действий предпринимать не придется. Но если проект требует более производительных решений (astroturf, linaria, CSS Modules) нужно будет поработать над конфигурацией веб-пака. Тематическая статья — Customize Webpack Configuration in Your Angular Application.
Для полноценной миграции приложения с Angular на React нужно решить еще проблему внедрения сервисов в React-компоненты. Простой способ — получать сервис в компоненте-обертке и передавать через пропсы. Сложный способ — написать прослойку которая будет доставать сервисы из инжектора по токену. Рассмотрение этого вопроса за рамками статьи.
Бонус
Важно понимать, что при таком подходе к 85КБ Angular добавляется почти 40КБ кода react и react-dom. Это может оказать существенное влияние на скорость работы приложения. Я рекомендую рассмотреть использование миниатюрного Preact, который весит всего 3КБ. Его интеграция почти не отличается.
- В 
tsconfig.jsonдобавляем новую опцию компиляции —"jsxFactory": "h", она укажет, что для преобразования JSX нужно использовать функциюh. Теперь в каждый файл с JSX кодом —import { h } from 'preact'. - Все вызовы 
React.createElementзаменяем наPreact.h. - Все вызовы 
ReactDOM.renderзаменяем наPreact.render. 
Готово! Прочтите инструкцию по переходу с React на Preact. Отличий практически нет.
UPD 19.9.19 16.49
Тематическая ссылка из комментариев — Micro Frontends
UPD 20.9.19 14.30
Другая тематическая ссылка из комментариев — Micro Frontends
Комментарии (6)

ThisMan
19.09.2019 16:38У меня есть проблема. Приложение написано на Angular, а библиотека компонентов на React.
Для начало бы я разобрался, почему библиотека компонентов написана на реакте, а не ангуляре. Потому что поддерживать такого монстра жуть.

igorkamyshev Автор
19.09.2019 16:47Потому что все остальные приложения написаны на реакте и все будущие будут написаны на реакте.

ezhikov
19.09.2019 19:44А вы не думали использовать какой-нибудь stencil для библиотеки компонентов, а потом использовать получившиеся веб-компоненты вообще где угодно?

igorkamyshev Автор
19.09.2019 20:19Я думал об этом, да. Но пока не готов рисковать временем и делать что-то на веб-компонентах. Слишком нестабильная штука.
          
 
SirEdvin
Тут точно должна быть такая ссылка
igorkamyshev Автор
Факт, да. Хорошая ссылка, добавлю, спасибо.
Checkmatez
Лучше такую ссылку добавить
igorkamyshev Автор
Да, тоже хорошая ссылка. Добавил.