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