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

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

@javascriptv


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();
  }
}



Report Page