Angular
ReactJS
19 September

Angulareact

У меня есть проблема. Приложение написано на Angular, а библиотека компонентов на React. Делать клон библиотеки слишком дорого. Значит, нужно использовать React-компоненты в Angular-приложении с минимальными затратами. Разбираемся как это делать.


Дисклеймер


Я совсем не специалист в Angular. Пробовал первую версию в 2017, потом немного смотрел на AngularDart, и вот сейчас столкнулся с поддержкой приложения на современной версии фреймворка. Если вам кажется, что решения странные или "из другого мира", вам не кажется.


Решение представленное в статье еще не было обкатано в реальных проектах и представляет собой только концепт. Используйте его на свой страх и риск.


Проблема


Я сейчас поддерживаю и развиваю довольно большое приложение на Angular 8. Плюс, есть пара небольших приложений на React и планы построить еще десяток (тоже на React). Все приложения используются для внутренних нужд компании и должны быть в едином стиле. Единственный логичный путь — создать библиотеку компонентов, на основе которой можно быстро строить любые приложения.


Но в текущей ситуации нельзя просто взять и написать библиотеку на React. В самом большом приложении использовать её не получиться — оно написано на Angular. Я не первый, кто столкнулся с этой проблемой. Первое, что находиться в гугле по запросу "react in angular" — репозиторий Microsoft. К сожалению, в этом решении совсем нет документации. Плюс, в ридми проекта явно сказано, что пакет используется внутри Microsoft, у команды нет явных планов по его развитию и использовать его стоит только на свой страх и риск. Я не готов тащить такой неоднозначный пакет в продакшн, поэтому решил написать свой велосипед.


image


Официальный сайт @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КБ. Его интеграция почти не отличается.


  1. В tsconfig.json добавляем новую опцию компиляции — "jsxFactory": "h", она укажет, что для преобразования JSX нужно использовать функцию h. Теперь в каждый файл с JSX кодом — import { h } from 'preact'.
  2. Все вызовы React.createElement заменяем на Preact.h.
  3. Все вызовы ReactDOM.render заменяем на Preact.render.

Готово! Прочтите инструкцию по переходу с React на Preact. Отличий практически нет.


UPD 19.9.19 16.49


Тематическая ссылка из комментариев — Micro Frontends


UPD 20.9.19 14.30


Другая тематическая ссылка из комментариев — Micro Frontends


+11
2.3k 37
Comments 8
Top of the day