У меня есть проблема. Приложение написано на 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