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
    • 8
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 8

      0

      Тут точно должна быть такая ссылка

        0
        Факт, да. Хорошая ссылка, добавлю, спасибо.
          0
          Лучше такую ссылку добавить
            0
            Да, тоже хорошая ссылка. Добавил.
          +3
          У меня есть проблема. Приложение написано на Angular, а библиотека компонентов на React.

          Для начало бы я разобрался, почему библиотека компонентов написана на реакте, а не ангуляре. Потому что поддерживать такого монстра жуть.

            +1
            Потому что все остальные приложения написаны на реакте и все будущие будут написаны на реакте.
            0

            А вы не думали использовать какой-нибудь stencil для библиотеки компонентов, а потом использовать получившиеся веб-компоненты вообще где угодно?

              0
              Я думал об этом, да. Но пока не готов рисковать временем и делать что-то на веб-компонентах. Слишком нестабильная штука.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое