Как использовать React в приложениях Angular

React в приложениях Angular может понадобится в двух случаях.

  • В экосистеме React есть компонент, на разработку которого, вероятно, уйдут недели, например компонент Timeline.
  • Сотрудничество с компанией, использующей React, которой необходимо интегрировать его в существующее приложение.

В этой статье я расскажу, как интегрировать React в обоих сценариях. Начнем с самого простого случая, когда нужно использовать компонент React.

Рендеринг компонента React

Создадим директиву, которая принимает компонент React и пропсы и осуществляет рендеринг на хост. Предположу, что вы уже знакомы с React.

import { ComponentProps, createElement, ElementType } from 'react';
import { createRoot } from 'react-dom/client';

@Directive({
selector: '[reactComponent]',
standalone: true
})
export class ReactComponentDirective<Comp extends ElementType> {
@Input() reactComponent: Comp;
@Input() props: ComponentProps<Comp>;

private root = createRoot(inject(ElementRef).nativeElement)

ngOnChanges() {
this.root.render(createElement(this.reactComponent, this.props))
}

ngOnDestroy() {
this.root.unmount();
}

}

Директива берет компонент React и пропсы, создает корень и совершает повторный рендеринг при каждом его изменении.

В этом примере мы будем рендерить компонент React Select внутри ленивого компонента страницы todos. Мы установим его с помощью npm i react-select и передадим директиве:

import Select from 'react-select';
import type { ComponentProps } from 'react';

@Component({
standalone: true,
imports: [CommonModule, ReactComponentDirective],
template: `
<h1>Todos page</h1>
<button (click)="changeProps()">Change</button>
<div [reactComponent]="Select" [props]="selectProps"></div>
`
})
export class TodosPageComponent {
Select = Select;
selectProps: ComponentProps<Select> = {
onChange(v) {
console.log(v)
},
options: [
{ value: 'chocolate', label: 'Chocolate' },
{ value: 'strawberry', label: 'Strawberry' },
{ value: 'vanilla', label: 'Vanilla' }
]
}

changeProps() {
this.selectProps = {
...this.selectProps,
options: [{ value: 'changed', label: 'Changed' }]
}
}
}

Обратите внимание: код React будет загружаться только при навигации по этой странице, потому что компонент todo загружается отложено.

Мы можем пойти дальше и загружать целые куски React по мере необходимости. Настроим директиву следующим образом:

import { Directive, ElementRef, Input } from '@angular/core';
import type { ComponentProps, ElementType } from 'react';
import type { Root } from 'react-dom/client';

@Directive({
selector: '[lazyReactComponent]',
standalone: true
})
export class LazyReactComponentDirective<Comp extends ElementType> {
@Input() lazyReactComponent: () => Promise<Comp>;
@Input() props: ComponentProps<Comp>;

private root: Root | null = null;

constructor(private host: ElementRef) { }

async ngOnChanges() {
const [{ createElement }, { createRoot }, Comp] = await Promise.all([
import('react'),
import('react-dom/client'),
this.lazyReactComponent()
]);

if (!this.root) {
this.root = createRoot(this.host.nativeElement);
}

this.root.render(createElement(Comp, this.props))
}

ngOnDestroy() {
this.root?.unmount();
}
}

Код был изменен для использования функции import вместо eager imports. Теперь мы можем задействовать ее в компоненте страницы todos:

import { CommonModule } from '@angular/common';
import { Component, ElementRef, OnDestroy, OnInit } from '@angular/core';
import type { ComponentProps } from 'react';

@Component({
standalone: true,
imports: [CommonModule, LazyReactComponentDirective],
template: `
<h1>Todos page</h1>
<button (click)="showSelect = true">Show React Component</button>
<ng-container *ngIf="showSelect">
<button (click)="changeProps()">Change</button>
<div [lazyReactComponent]="Select" [props]="selectProps"></div>
</ng-container>
`
})
export class TodosPageComponent {
showSelect = false;
selectProps: ComponentProps<typeof import('react-select').default> = {
onChange(v) {
console.log(v)
},
options: [
{ value: 'chocolate', label: 'Chocolate' },
{ value: 'strawberry', label: 'Strawberry' },
{ value: 'vanilla', label: 'Vanilla' }
]
}

Select = () => import('react-select').then(m => m.default);

changeProps() {
this.selectProps = {
...this.selectProps,
options: [{ value: 'change', label: 'Change' }]
}
}
}

Рендеринг приложения React

Процесс рендеринга приложения практически идентичен рендерингу компонента с одним небольшим отличием. Наша цель  —  открыть injector приложения Angular отрендеренному приложению React, чтобы можно было использовать в нем сервисы Angular. Для этого пригодится React Context, который открывает injector и рендерит React-приложение внутри него:

import { Injector } from '@angular/core';
import { PropsWithChildren, createContext, useContext } from 'react';
import { createRoot, Root } from 'react-dom/client'

const InjectorCtx = createContext<Injector | null>(null)

export function NgContext(props: PropsWithChildren<{ injector: Injector }>) {
return createElement(InjectorCtx.Provider, {
children: props.children,
value: props.injector
})
}

function useInjector(): Injector {
const injector = useContext(InjectorCtx);

if (!injector) {
throw new Error('Missing NgContext')
}

return injector;
}

Мы создали Context, предоставляющий инжектор Angular и хук useInjector. Далее реализуем сервис, который рендерит React-компонент:

// ... THE CONTEXT CODE IS ABOVE ...

@Injectable({ providedIn: 'root' })
export class NgReact {
injector = inject(Injector);

createRoot(host: HTMLElement) {
return createRoot(host);
}

render<Comp extends ElementType>(
root: Root,
Comp: Comp,
compProps?: ComponentProps<Comp>
) {
root.render(
createElement(NgContext, {
injector: this.injector,
}, createElement(Comp, compProps))
)
}
}

Метод render() рендерит предоставленный React-компонент с помощью провайдера NgContext, чтобы он мог получить доступ к предоставленному injector Angular.

Я создал React-приложение с помощью инструмента Nx, который использует функцию useInjector:

import NxWelcome from './nx-welcome';

export function App() {
return (
<>
<NxWelcome title="react-platform" />
...
</>
);
}

import { Router } from '@angular/router';
import { useInjector } from '@myorg/ng-react';

export function NxWelcome({ title }: { title: string }) {
const injector = useInjector();

return <>
....
<button onClick={() => injector.get(Router).navigateByUrl('/')}>Home</button>
...
</>
}

Мы переходим на домашнюю страницу при каждой нажатии на кнопку home, используя router Angular, который получаем из injector.

Отрендерим его в компоненте страницы todos:

import { Component, ElementRef, inject } from '@angular/core';

import { App } from '@myorg/app-name';
import { NgReact } from '@myorg/ng-react';

@Component({
standalone: true,
template: ``
})
export class TodosPageComponent {
private ngReact = inject(NgReact);
private root = this.ngReact.createRoot(inject(ElementRef).nativeElement);

ngOnInit() {
this.ngReact.render(this.root, App)
}

ngOnDestroy() {
this.root.unmount();
}
}

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

Читайте нас в TelegramVK и Дзен


Перевод статьи Netanel Basal: Using React in Angular Applications

Предыдущая статьяПочему в Python по-прежнему нужна функция map()
Следующая статьяКак оптимизировать навигацию в Jetpack Compose